Туториали

Native PHP 8.3: Automated Website Security Monitoring & Owner Alerts

Native PHP 8.3: Автоматизирано следење на безбедноста на веб-страниците и известувања за сопствениците

A website can look perfectly healthy while its security posture quietly deteriorates: a certificate changes, an important header disappears, or a deployment weakens a browser-facing control. For a small business, the useful response is not another dashboard someone must remember to open. It is a dependable weekly check and a concise email when the score actually drops.

This tutorial builds that monitor with Native PHP 8.3, cURL, SQLite, cron, and the Website Security Analyzer API. The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. Its results are operational signals, not a penetration test, vulnerability certification, or substitute for an authorized security assessment.

Get access and copy the service token

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

This service is not tokenless: every analysis request must authenticate. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because credentials in query strings are more likely to appear in access logs and monitoring tools.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update the deployed environment and verify the monitor before relying on its next scheduled run.

Confirm the API contract before writing the monitor

The exact call is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains url. Start with one direct request so account activation, authentication, DNS, and outbound HTTPS can be checked independently of application code:

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

Replace only the placeholder and URL locally. Do not paste the resulting command, shell history, or response into public tickets. The application will map the documented score, severity-grouped findings, TLS details, and recommendations while validating their types at the API boundary.

Environment configuration

Create an uncommitted .env file. Native PHP does not automatically load it, so the deployment command will export it before starting PHP.

SECURITY_API_TOKEN=YOUR_SERVICE_TOKEN
MONITORED_URL=https://www.example.com
[email protected]
[email protected]
STATE_DATABASE=/opt/security-monitor/var/security.sqlite

Add .env and var/ to .gitignore. Restrict the real file with chmod 600 .env. Use a separate .env.example containing placeholders for documentation.

Choose a small, failure-aware architecture

The design has one scheduled command, one API client, a mapped report object, and a tiny SQLite state store. The stored value is the last successful weekly score. A failed API call never changes it; a first successful run establishes the baseline without sending an alarming “drop” email.

SQLite is a better fit than a database server here: one scheduled process writes one row. A process lock prevents overlapping cron runs. Native mail() keeps the example dependency-light, but it requires a correctly configured local mail transfer agent. It reports acceptance by that agent, not final delivery. A production system without an MTA should replace only the mail adapter with its established transactional email provider.

security-monitor/
├── bin/security-monitor.php
├── src/Http.php
├── src/SecurityAnalyzer.php
├── src/SecurityReport.php
├── tests/SecurityAnalyzerTest.php
├── composer.json
├── .env
└── var/

Install PHP 8.3 with cURL, JSON, PDO SQLite, and mail transport support. Configure Composer autoloading and PHPUnit:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}
composer install
composer dump-autoload
mkdir -p var
chmod 700 var

Build a bounded native cURL transport

The transport enforces TLS verification, a five-second connection timeout, and a thirty-second overall timeout. It marks only plausible network failures as retryable.

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

namespace App;

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

final class TransportFailure extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $retryable,
    ) {
        parent::__construct($message);
    }
}

interface HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        string $body,
        int $connectTimeout,
        int $timeout,
    ): HttpResponse;
}

final class CurlTransport implements HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        string $body,
        int $connectTimeout,
        int $timeout,
    ): HttpResponse {
        $receivedHeaders = [];
        $handle = curl_init($url);

        if ($handle === false) {
            throw new TransportFailure('Could not initialize cURL.', false);
        }

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $timeout,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function (
                \CurlHandle $handle,
                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) {
            $number = curl_errno($handle);
            $retryable = in_array($number, [
                CURLE_COULDNT_RESOLVE_HOST,
                CURLE_COULDNT_CONNECT,
                CURLE_OPERATION_TIMEDOUT,
                CURLE_SEND_ERROR,
                CURLE_RECV_ERROR,
            ], true);

            throw new TransportFailure(
                'HTTPS transport failed: ' . curl_error($handle),
                $retryable,
            );
        }

        return new HttpResponse(
            (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $receivedHeaders,
            $bodyResult,
        );
    }
}

Map the response at the application boundary

Remote JSON is untrusted input even when the service is trusted. The DTO refuses missing or wrongly typed contract fields instead of letting malformed data become a false score.

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

namespace App;

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

    public static function fromArray(array $data): self
    {
        if (!isset($data['score']) ||
            (!is_int($data['score']) && !is_float($data['score']))) {
            throw new \UnexpectedValueException('Response score is missing or invalid.');
        }

        foreach (['findings', 'tls', 'recommendations'] as $field) {
            if (!isset($data[$field]) || !is_array($data[$field])) {
                throw new \UnexpectedValueException(
                    "Response {$field} is missing or invalid."
                );
            }
        }

        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new \UnexpectedValueException(
                    'Findings must be lists grouped by severity.'
                );
            }
        }

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

Add deliberate retries and structured failures

The client retries transient transport failures, HTTP 429, and selected server errors. Authentication and validation failures are returned immediately because repeated calls cannot repair them. Integer Retry-After values are honored with a thirty-second cap; otherwise, the client uses short exponential backoff.

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

namespace App;

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

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

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        private readonly \Closure $sleep,
    ) {}

    public function analyze(string $url): SecurityReport
    {
        $payload = json_encode(
            ['url' => $url],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
        );

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Content-Type: application/json',
                        'Accept: application/json',
                    ],
                    $payload,
                    5,
                    30,
                );
            } catch (TransportFailure $failure) {
                if (!$failure->retryable || $attempt === 3) {
                    throw new ApiFailure(
                        'transport',
                        null,
                        $failure->getMessage(),
                    );
                }

                ($this->sleep)(2 ** ($attempt - 1));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (\JsonException $exception) {
                    throw new ApiFailure(
                        'invalid_response',
                        $response->status,
                        'Analyzer returned invalid JSON.',
                    );
                }

                if (!is_array($decoded)) {
                    throw new ApiFailure(
                        'invalid_response',
                        $response->status,
                        'Analyzer response was not an object.',
                    );
                }

                try {
                    return SecurityReport::fromArray($decoded);
                } catch (\UnexpectedValueException $exception) {
                    throw new ApiFailure(
                        'invalid_response',
                        $response->status,
                        $exception->getMessage(),
                    );
                }
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ApiFailure(
                    'authentication',
                    $response->status,
                    'Analyzer authentication failed.',
                );
            }

            if (in_array($response->status, [400, 422], true)) {
                throw new ApiFailure(
                    'invalid_request',
                    $response->status,
                    'Analyzer rejected the URL or request.',
                );
            }

            $retryable = $response->status === 429 ||
                in_array($response->status, [500, 502, 503, 504], true);

            if (!$retryable || $attempt === 3) {
                $kind = $response->status === 429
                    ? 'quota_or_rate_limit'
                    : 'remote_error';

                throw new ApiFailure(
                    $kind,
                    $response->status,
                    'Analyzer request failed.',
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delay = is_string($retryAfter) && ctype_digit($retryAfter)
                ? min(30, (int) $retryAfter)
                : 2 ** ($attempt - 1);

            ($this->sleep)($delay);
        }

        throw new \LogicException('Retry loop ended unexpectedly.');
    }
}

Implement the weekly command

The command validates configuration, acquires a non-blocking lock, opens SQLite, analyzes the site, and compares the result with the last successful score. It updates state only after email handoff succeeds, so a mail failure is retried on the next run.

<?php
// bin/security-monitor.php
declare(strict_types=1);

use App\CurlTransport;
use App\SecurityAnalyzer;

require dirname(__DIR__) . '/vendor/autoload.php';

function requiredEnv(string $name): string
{
    $value = getenv($name);
    if ($value === false || trim($value) === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }
    return $value;
}

function logEvent(string $level, string $event, array $context = []): void
{
    error_log(json_encode(
        ['level' => $level, 'event' => $event] + $context,
        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
    ));
}

try {
    $token = requiredEnv('SECURITY_API_TOKEN');
    $url = requiredEnv('MONITORED_URL');
    $owner = requiredEnv('OWNER_EMAIL');
    $from = requiredEnv('MAIL_FROM');
    $databasePath = requiredEnv('STATE_DATABASE');

    if (filter_var($url, FILTER_VALIDATE_URL) === false ||
        parse_url($url, PHP_URL_SCHEME) !== 'https') {
        throw new RuntimeException('MONITORED_URL must be a valid HTTPS URL.');
    }

    foreach ([$owner, $from] as $email) {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false ||
            str_contains($email, "\r") || str_contains($email, "\n")) {
            throw new RuntimeException('Configured email address is invalid.');
        }
    }

    $lock = fopen(sys_get_temp_dir() . '/website-security-monitor.lock', 'c');
    if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
        logEvent('warning', 'monitor_already_running');
        exit(75);
    }

    $pdo = new PDO('sqlite:' . $databasePath, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
    $pdo->exec(
        'CREATE TABLE IF NOT EXISTS monitor_state (
            monitored_url TEXT PRIMARY KEY,
            score REAL NOT NULL,
            checked_at TEXT NOT NULL
        )'
    );

    $statement = $pdo->prepare(
        'SELECT score FROM monitor_state WHERE monitored_url = :url'
    );
    $statement->execute(['url' => $url]);
    $previous = $statement->fetchColumn();
    $previousScore = $previous === false ? null : (float) $previous;

    $analyzer = new SecurityAnalyzer(
        new CurlTransport(),
        $token,
        static fn (int $seconds) => sleep($seconds),
    );
    $report = $analyzer->analyze($url);

    if ($previousScore !== null && $report->score < $previousScore) {
        $counts = [];
        foreach ($report->findingsBySeverity as $severity => $items) {
            $counts[] = "{$severity}: " . count($items);
        }

        $body = implode("\n", [
            "The weekly security score for {$url} dropped.",
            "Previous score: {$previousScore}",
            "Current score: {$report->score}",
            'Findings by severity: ' . implode(', ', $counts),
            '',
            'Review the analyzer recommendations and validate changes.',
            'This bounded public-site analysis is not a penetration test.',
        ]);

        $accepted = mail(
            $owner,
            'Website security score decreased',
            $body,
            ['From' => $from, 'Content-Type' => 'text/plain; charset=UTF-8'],
        );

        if (!$accepted) {
            throw new RuntimeException('Local mail transport rejected the message.');
        }

        logEvent('warning', 'score_drop_alerted', [
            'url' => $url,
            'previous_score' => $previousScore,
            'current_score' => $report->score,
        ]);
    }

    $save = $pdo->prepare(
        'INSERT INTO monitor_state (monitored_url, score, checked_at)
         VALUES (:url, :score, :checked_at)
         ON CONFLICT(monitored_url) DO UPDATE SET
            score = excluded.score,
            checked_at = excluded.checked_at'
    );
    $save->execute([
        'url' => $url,
        'score' => $report->score,
        'checked_at' => gmdate(DATE_ATOM),
    ]);

    logEvent('info', 'analysis_completed', [
        'url' => $url,
        'score' => $report->score,
        'previous_score' => $previousScore,
    ]);
    exit(0);
} catch (Throwable $exception) {
    logEvent('error', 'analysis_failed', [
        'exception' => $exception::class,
        'message' => $exception->getMessage(),
    ]);
    exit(1);
}

Logs contain operational state, scores, and exception categories, but never the token, authorization header, response body, or email content. In production, collect stderr and alert on repeated analysis_failed events. A silent monitor is not a healthy monitor.

Test without calling the live service

A fake transport makes response mapping, retries, and authentication behavior deterministic. It also prevents tests from consuming quota or depending on the network.

<?php
// tests/SecurityAnalyzerTest.php
declare(strict_types=1);

use App\HttpResponse;
use App\HttpTransport;
use App\SecurityAnalyzer;
use App\ApiFailure;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        string $body,
        int $connectTimeout,
        int $timeout,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class SecurityAnalyzerTest extends TestCase
{
    public function testMapsSuccessfulReport(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(200, [], json_encode([
                'score' => 87,
                'findings' => ['high' => [], 'medium' => [['id' => 'x']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported medium finding.'],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new SecurityAnalyzer($transport, 'test-token', static fn () => null);
        $report = $client->analyze('https://www.example.com');

        self::assertSame(87.0, $report->score);
        self::assertCount(1, $report->findingsBySeverity['medium']);
        self::assertSame(1, $transport->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $delays = [];
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '2'], '{}'),
            new HttpResponse(200, [], json_encode([
                'score' => 90,
                'findings' => [],
                'tls' => [],
                'recommendations' => [],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new SecurityAnalyzer(
            $transport,
            'test-token',
            static function (int $seconds) use (&$delays): void {
                $delays[] = $seconds;
            },
        );

        self::assertSame(90.0, $client->analyze('https://www.example.com')->score);
        self::assertSame([2], $delays);
        self::assertSame(2, $transport->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, [], '{}'),
        ]);

        $client = new SecurityAnalyzer($transport, 'bad-token', static fn () => null);

        try {
            $client->analyze('https://www.example.com');
            self::fail('Expected ApiFailure.');
        } catch (ApiFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}
composer test
set -a
. ./.env
set +a
php bin/security-monitor.php

Run the command once to establish the baseline. To verify the email path without waiting for a genuine regression, use a disposable copy of the SQLite database and raise its stored score above the next returned score. Never manipulate production state for a test.

Schedule and deploy it safely

Run the monitor from one host only. The following cron entry executes every Sunday at 03:20 in the server’s configured timezone:

20 3 * * 0 cd /opt/security-monitor && set -a && . ./.env && set +a && /usr/bin/php bin/security-monitor.php >> var/cron.log 2>&1

Deploy with a fixed PHP 8.3 binary, install Composer dependencies with composer install --no-dev --classmap-authoritative, and give the scheduler account write access only to var/. Keep source code and .env non-writable by the runtime account where practical. Back up the tiny database if alert continuity matters.

Common failures worth planning for

  • 401 or 403: activation may be incomplete, the token may be wrong, or token regeneration may have revoked the deployed credential. The client deliberately does not retry.
  • 400 or 422: confirm that MONITORED_URL is a public HTTPS URL and that the request body contains url.
  • 429: the plan’s quota or rate limit may have been reached. Retries are bounded; persistent failures remain visible in logs.
  • Timeouts: verify outbound DNS and HTTPS from the cron account. Do not “solve” certificate failures by disabling TLS verification.
  • No email: inspect the local MTA queue and sender policy. A successful mail() call means local acceptance, not inbox delivery.
  • No cron output: cron has a minimal environment. Use absolute paths, source the protected environment file, and confirm timezone expectations.

Final verification checklist

  • The token is service-scoped, environment-backed, absent from Git, and readable only by the deployment account.
  • The direct POST request succeeds against the exact analyzer endpoint.
  • PHPUnit passes without network access.
  • The first command run stores a baseline and sends no drop alert.
  • A controlled disposable-state test produces one owner email when the score decreases.
  • Authentication, rate-limit, malformed-response, transport, and mail failures produce useful token-free logs.
  • Only successful analyses update the stored score.
  • Cron runs weekly on exactly one host, and failed runs are monitored.

The valuable part of this monitor is not its size. It is the discipline encoded around a small API call: constrained credentials, defensive mapping, selective retries, durable comparison state, and an alert tied to meaningful change. That turns a weekly security check from a good intention into quiet, dependable operations.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.