Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
101 changes: 94 additions & 7 deletions src/Report/class-audit-runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $summary The computed report summary.
* @return array<string, mixed> 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<string,mixed>`, 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.
*
Expand Down
21 changes: 21 additions & 0 deletions tests/Integration/report-integration-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}
}
112 changes: 112 additions & 0 deletions tests/Unit/fleet-summary-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* Fleet_Summary tests: the compact fleet digest and its severity→grade mapping.
*
* These are pure-PHP unit tests. Audit_Runner's fleet_summary()/derive_sev() do
* not touch WordPress (no I/O, no globals), so we exercise them via reflection
* without a WP install — only run()/compute() need WordPress.
*
* @package ASA
*/

use ASA\Report\Audit_Runner;
use ASA\Model\Finding;

/**
* @covers \ASA\Report\Audit_Runner
*/
class Fleet_Summary_Test extends ASA_TestCase {

/**
* Invoke a private method on a fresh Audit_Runner.
*
* @param string $method Method name.
* @param mixed[] $args Arguments.
* @return mixed The return value.
*/
private function invoke( $method, array $args ) {
$runner = new Audit_Runner();
$ref = new ReflectionMethod( Audit_Runner::class, $method );
$ref->setAccessible( true );
return $ref->invokeArgs( $runner, $args );
}

/**
* A summary array with the given findings-by-severity counts.
*
* @param array<string,int> $by_severity Severity => count.
* @param int $reachable_write Agent-reachable writes.
* @return array<string,mixed>
*/
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'] );
}
}
Loading