Vodiči

Native PHP 8.3: Build Client Security Dashboards with Historical Remediation Tasks

Nativni PHP 8.3: Izradite nadzorne ploče za sigurnost klijenata s povijesnim zadacima sanacije

A security dashboard becomes valuable when it answers three questions quickly: what changed, what matters now, and what should the team fix next. A single score cannot do that. A small agency needs a durable history of scans, findings grouped by severity, TLS context, and recommendations converted into trackable remediation tasks.

This tutorial builds that workflow in Native PHP 8.3. A scheduled CLI command analyzes each approved client website, stores immutable snapshots in SQLite, and creates tasks from the returned recommendations. A small server-rendered dashboard shows the latest posture, earlier results, and task status. The analyzer remains an external boundary rather than leaking API-specific details throughout the application.

The service performs bounded, non-invasive analysis of public HTTPS and browser security posture. Its output is useful for prioritization and monitoring, but it must not be described as a penetration test or a substitute for one.

Get access and copy the service token

  1. Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
  2. Open the Website Security Analyzer service page.
  3. Choose an available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy its service-scoped token.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because query parameters are more likely to appear in access logs, browser history, and monitoring systems.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: install the new value everywhere that runs scans, restart or reload those processes, verify a scan, and only then consider the rollout complete.

Confirm the exact endpoint

The API call is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON request contains url. Test the credential with a public HTTPS site that you control:

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 15 \
  -X POST \
  -H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://client.example"}' \
  https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Store the token in .env.local, keep that file out of version control, and restrict its filesystem permissions. Production should inject the same variable through the process manager or deployment platform.

WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_DB_PATH=/srv/security-dashboard/var/security.sqlite

Native PHP does not automatically load dotenv files. For local commands, export the file before starting PHP:

set -a
. ./.env.local
set +a

Choose a deliberately small architecture

The API client owns authentication, timeouts, retries, and response validation. A CLI scanner supplies only pre-approved client URLs and persists snapshots. The web endpoint reads those snapshots and changes task status; it never calls the external service during a page request.

This split keeps dashboard latency independent of the analyzer and prevents users from turning an input field into an unrestricted URL scanner. SQLite is a sensible fit for one agency process and a modest scan schedule. If concurrent writers or multiple application nodes become normal, keep the domain boundary and replace only the repository with a server database.

security-dashboard/
├── bin/scan.php
├── config/clients.php
├── public/index.php
├── src/Analyzer.php
├── tests/AnalyzerClientTest.php
├── var/
├── composer.json
└── .env.local

Use Composer for autoloading and PHPUnit 11, which supports PHP 8.3:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}

Build a defensive API boundary

The documented result contains a score, severity-grouped findings, TLS details, and recommendations. Their internal item shapes may evolve, so the mapper validates documented top-level fields while preserving nested data rather than guessing undocumented properties.

<?php
// src/Analyzer.php
declare(strict_types=1);

namespace App;

use JsonException;
use RuntimeException;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body
    ) {}
}

interface Transport
{
    public function post(string $url, array $headers, string $body): HttpResponse;
}

final class CurlTransport implements Transport
{
    public function post(string $url, array $headers, string $body): HttpResponse
    {
        $receivedHeaders = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$receivedHeaders): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $receivedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyResult = curl_exec($handle);
        if ($bodyResult === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Analyzer transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $receivedHeaders, $bodyResult);
    }
}

final readonly class SecuritySnapshot
{
    public function __construct(
        public float $score,
        public array $findings,
        public array $tls,
        public array $recommendations
    ) {}

    public static function fromArray(array $data): self
    {
        if (!array_key_exists('score', $data) || !is_numeric($data['score'])) {
            throw new RuntimeException('Analyzer response has an invalid score.');
        }
        if (!isset($data['findings']) || !is_array($data['findings'])) {
            throw new RuntimeException('Analyzer response has invalid findings.');
        }
        if (!isset($data['tls']) || !is_array($data['tls'])) {
            throw new RuntimeException('Analyzer response has invalid TLS details.');
        }
        if (!isset($data['recommendations']) ||
            !is_array($data['recommendations'])) {
            throw new RuntimeException('Analyzer response has invalid recommendations.');
        }

        foreach ($data['findings'] as $severity => $items) {
            if (!is_array($items)) {
                throw new RuntimeException(
                    'Findings for severity ' . (string) $severity . ' are invalid.'
                );
            }
        }

        foreach ($data['recommendations'] as $recommendation) {
            if (!is_string($recommendation) || trim($recommendation) === '') {
                throw new RuntimeException('Analyzer returned an invalid recommendation.');
            }
        }

        return new self(
            (float) $data['score'],
            $data['findings'],
            $data['tls'],
            array_values($data['recommendations'])
        );
    }
}

final class ApiFailure extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status,
        public readonly bool $retryable
    ) {
        parent::__construct($message);
    }
}

final class AnalyzerClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly mixed $logger = null,
        private readonly mixed $sleeper = null
    ) {}

    public function analyze(string $url): SecuritySnapshot
    {
        $payload = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
        $logger = $this->logger ?? static function (array $event): void {};
        $sleeper = $this->sleeper ?? 'usleep';

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            $started = hrtime(true);

            try {
                $response = $this->transport->post(self::ENDPOINT, [
                    'Authorization: Bearer ' . $this->token,
                    'Accept: application/json',
                    'Content-Type: application/json',
                ], $payload);
            } catch (RuntimeException $exception) {
                $logger([
                    'event' => 'security_analysis',
                    'attempt' => $attempt,
                    'outcome' => 'transport_error',
                ]);

                if ($attempt === 3) {
                    throw new ApiFailure(
                        'Analyzer transport failed after retries.',
                        null,
                        true
                    );
                }
                $sleeper((2 ** ($attempt - 1) * 1_000_000) + random_int(0, 250_000));
                continue;
            }

            $logger([
                'event' => 'security_analysis',
                'attempt' => $attempt,
                'status' => $response->status,
                'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode(
                        $response->body,
                        true,
                        flags: JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new ApiFailure(
                        'Analyzer returned malformed JSON.',
                        $response->status,
                        false
                    );
                }

                if (!is_array($decoded)) {
                    throw new ApiFailure(
                        'Analyzer returned an unexpected document.',
                        $response->status,
                        false
                    );
                }

                return SecuritySnapshot::fromArray($decoded);
            }

            $retryable = $response->status === 429 ||
                $response->status >= 500;

            if (!$retryable || $attempt === 3) {
                throw new ApiFailure(
                    'Analyzer request failed with HTTP ' . $response->status . '.',
                    $response->status,
                    $retryable
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $seconds = ctype_digit((string) $retryAfter)
                ? min(10, (int) $retryAfter)
                : 2 ** ($attempt - 1);

            $sleeper(($seconds * 1_000_000) + random_int(0, 250_000));
        }

        throw new ApiFailure('Analyzer retry loop ended unexpectedly.', null, false);
    }
}

Only transport failures, HTTP 429 responses, and server-side 5xx responses are retried. Authentication, authorization, validation, and other client failures are returned immediately. Backoff is bounded, includes jitter, and honors a numeric Retry-After, then composer test. Add cases for HTTP 401, 429 exhaustion, malformed JSON, transport failure, and invalid recommendation types before changing the boundary code.

Deploy and operate it safely

Create the database directory outside the public document root and grant write access only to the scan process and dashboard process. Serve only public/. Terminate HTTPS at a maintained reverse proxy, add agency authentication, use secure session cookies, and back up the SQLite file consistently.

Schedule each approved slug separately and prevent overlapping runs:

15 6 * * * cd /srv/security-dashboard && \
  /usr/bin/flock -n /run/lock/acme-security-scan.lock \
  /usr/bin/php bin/scan.php acme-studio

Common failure patterns are straightforward to distinguish:

  • 401 or 403: verify activation and the injected service token. If the token was regenerated, the old value is already revoked. Do not retry automatically.
  • 429: the client applies bounded backoff, but repeated responses mean the schedule or plan capacity needs attention.
  • 5xx or transport errors: retries are limited to three attempts. Keep the previous successful snapshot visible while recording the failed run.
  • Malformed or changed response: boundary validation fails closed. Preserve the raw response only in a tightly controlled diagnostic workflow, never ordinary logs.
  • SQLite locking: retain the busy timeout and per-client process lock. Frequent concurrent writers are a signal to move persistence to a server database.

Alert on consecutive failed runs rather than a single transient failure. Track scan duration, HTTP status class, retry count, success age, and the number of open tasks. Those signals reveal both integration trouble and neglected remediation without exposing sensitive findings.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active, and the service-scoped token came from the documentation page’s Service token panel.
  • The token exists only in environment-backed configuration and never appears in source, fixtures, screenshots, URLs, or logs.
  • The client registry contains reviewed public HTTPS URLs; dashboard users cannot submit arbitrary targets.
  • The exact POST endpoint receives a JSON body containing url.
  • Score, severity-grouped findings, TLS details, and recommendations are validated at the API boundary.
  • Recommendations become persistent tasks, and old snapshots and task states remain available as history.
  • Authentication and validation failures are not retried; 429, transport, and 5xx retries are bounded.
  • Automated tests pass without network access, and a manual scan creates a dashboard snapshot.
  • The dashboard is authenticated, CSRF-protected, escaped, and served over HTTPS.
  • The interface labels the result as bounded posture analysis, never as a penetration test.

The finished dashboard does more than display an attractive score. It preserves evidence of change, turns recommendations into accountable work, and remains useful when an external call fails. That is the difference between a one-off API demo and a security workflow an agency can operate with confidence.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.