|
@@ -453,4 +453,108 @@ final class AuthEndpointsTest extends AppTestCase
|
|
|
);
|
|
);
|
|
|
self::assertSame(403, $response->getStatusCode());
|
|
self::assertSame(403, $response->getStatusCode());
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F17 regression: a successful `GET /api/v1/auth/users/{id}`
|
|
|
|
|
+ * must emit `user.fetched` so iterative enumeration leaves a per-id
|
|
|
|
|
+ * trail in audit_log.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testGetUserFoundEmitsUserFetchedAudit(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $userId = $this->createUser(Role::Operator, isLocal: false);
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ '/api/v1/auth/users/' . $userId,
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token],
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.fetched' ORDER BY id DESC LIMIT 1"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row, 'user.fetched audit row must exist on 200');
|
|
|
|
|
+ self::assertSame('system', $row['actor_kind']);
|
|
|
|
|
+ self::assertSame('user', $row['target_type']);
|
|
|
|
|
+ self::assertSame((string) $userId, $row['target_id']);
|
|
|
|
|
+ self::assertSame('user@example.com', $row['target_label']);
|
|
|
|
|
+ $details = json_decode((string) $row['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($details);
|
|
|
|
|
+ self::assertSame('found', $details['outcome']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F17 regression: a 404 lookup must also emit
|
|
|
|
|
+ * `user.fetched` with outcome=not_found. This is the path attackers
|
|
|
|
|
+ * iterate over to enumerate which ids exist; without this row the
|
|
|
|
|
+ * probe is invisible to SOC tooling.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testGetUserNotFoundEmitsUserFetchedAudit(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ '/api/v1/auth/users/999999',
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token],
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(404, $response->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.fetched' ORDER BY id DESC LIMIT 1"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row, 'user.fetched audit row must exist on 404');
|
|
|
|
|
+ self::assertSame('system', $row['actor_kind']);
|
|
|
|
|
+ self::assertSame('user', $row['target_type']);
|
|
|
|
|
+ self::assertSame('999999', $row['target_id']);
|
|
|
|
|
+ self::assertNull($row['target_label']);
|
|
|
|
|
+ $details = json_decode((string) $row['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($details);
|
|
|
|
|
+ self::assertSame('not_found', $details['outcome']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F17: a malformed id (non-numeric, leading zero, etc.)
|
|
|
|
|
+ * is rejected at the protocol layer with 400 and does NOT emit an
|
|
|
|
|
+ * audit row. The audit signal we care about is keyed on a valid id
|
|
|
|
|
+ * shape, where iterative enumeration becomes meaningful.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testGetUserInvalidIdDoesNotEmitAudit(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ '/api/v1/auth/users/abc',
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token],
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(400, $response->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'user.fetched'"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(0, $count, 'malformed-id 400 must not emit user.fetched');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F17: an iterative scan over many ids leaves one audit
|
|
|
|
|
+ * row per probe. This is the SOC detection signal — a single
|
|
|
|
|
+ * service-token id producing many `user.fetched` rows in a tight
|
|
|
|
|
+ * window is the alert pattern.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testEnumerationProducesOneAuditRowPerProbe(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $headers = ['Authorization' => 'Bearer ' . $token];
|
|
|
|
|
+
|
|
|
|
|
+ for ($i = 1; $i <= 5; $i++) {
|
|
|
|
|
+ $this->request('GET', '/api/v1/auth/users/' . $i, $headers);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'user.fetched'"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(5, $count, 'each /users/{id} probe must emit one audit row');
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|