Tutorials

Automate Weekly Website Security Scans & Alerts with Native PHP

Automate Weekly Website Security Scans & Alerts with Native PHP

A security score is most useful when it becomes a trend, not a one-off report someone forgets in a browser tab. In this project, a small Native PHP 8.3 application runs every week, analyzes a business website’s public HTTPS and browser security posture, compares the result with the last successful scan, and emails the owner only when the score drops.

The analyzer performs bounded, non-invasive checks. It does not exploit vulnerabilities, authenticate to the site, or replace a penetration test. That distinction belongs in both your documentation and any alert sent to the owner.

Get access and copy the service token

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Website Security Analyzer service page.
  3. Choose the 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. We will use the Bearer form because it keeps the credential out of URLs, proxy access logs, and browser history.

Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the environment configuration everywhere the scanner runs, verify a scan, and remove any obsolete secret copies.

Confirm access with the exact endpoint

The integration uses POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website with a JSON body containing url. Test it before writing application code:

export WEBSITE_SECURITY_TOKEN='YOUR_SERVICE_TOKEN'

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

Replace the example URL with the business’s real public HTTPS URL. Never commit the token or leave it in shell history on a shared machine.

Native PHP has no built-in convention for loading a project .env file. For production, let the process supervisor provide the environment. We will use a protected systemd environment file:

WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
MONITORED_URL=https://www.example-business.test
[email protected]
[email protected]
STATE_FILE=/var/lib/weekly-security-scan/score.json

Store it as /etc/weekly-security-scan.env, owned by root and readable only by root. The systemd service will read it without putting the secret in source control.

A small architecture with explicit boundaries

The application has four responsibilities: an API client, a defensive response mapper, a local score store, and an email notifier. A CLI entry point coordinates them. The previous score changes only after both analysis and any required alert succeed, so a mail failure does not silently consume the score drop.

weekly-security-scan/
├── bin/scan.php
├── composer.json
├── src/Security.php
└── tests/SecurityTest.php

Use Composer for autoloading and PHPUnit, while keeping production networking on native cURL:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.5"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "autoload-dev": {
    "classmap": ["tests/"]
  },
  "scripts": {
    "test": "phpunit tests"
  }
}

Build the API boundary and retry policy

The response mapper recognizes only the documented concepts: score, severity-grouped findings, tls details, and recommendations. Finding and TLS records remain opaque because inventing undocumented nested fields would make the integration brittle.

<?php
// src/Security.php
namespace App;

use JsonException;
use RuntimeException;

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

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

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

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

    public static function fromArray(array $data): self
    {
        $score = $data['score'] ?? null;
        $findings = $data['findings'] ?? null;
        $tls = $data['tls'] ?? null;
        $recommendations = $data['recommendations'] ?? null;

        if ((!is_int($score) && !is_float($score)) ||
            !is_finite((float) $score)) {
            throw new ScanFailure('invalid_response', null, false, 'Invalid score');
        }

        if (!is_array($findings) || !is_array($tls) ||
            !is_array($recommendations)) {
            throw new ScanFailure(
                'invalid_response',
                null,
                false,
                'Required response sections are missing'
            );
        }

        foreach ($findings as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new ScanFailure(
                    'invalid_response',
                    null,
                    false,
                    'Findings are not grouped by severity'
                );
            }
        }

        foreach ($recommendations as $recommendation) {
            if (!is_string($recommendation)) {
                throw new ScanFailure(
                    'invalid_response',
                    null,
                    false,
                    'Invalid recommendation'
                );
            }
        }

        return new self($score, $findings, $tls, $recommendations);
    }
}

final class CurlTransport implements Transport
{
    public function __construct(
        private int $connectTimeout = 5,
        private int $responseTimeout = 25,
        private int $maxAttempts = 3,
    ) {}

    public function postJson(string $url, array $headers, array $body): HttpResponse
    {
        $payload = json_encode($body, JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            $responseHeaders = [];
            $curl = curl_init($url);

            curl_setopt_array($curl, [
                CURLOPT_POST => true,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
                CURLOPT_TIMEOUT => $this->responseTimeout,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_POSTFIELDS => $payload,
                CURLOPT_HEADERFUNCTION => static function (
                    $handle,
                    string $line
                ) use (&$responseHeaders): int {
                    $parts = explode(':', $line, 2);
                    if (count($parts) === 2) {
                        $responseHeaders[strtolower(trim($parts[0]))] =
                            trim($parts[1]);
                    }
                    return strlen($line);
                },
            ]);

            $content = curl_exec($curl);
            $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
            $error = curl_error($curl);
            curl_close($curl);

            if ($content === false) {
                if ($attempt < $this->maxAttempts) {
                    $this->pause($attempt, null);
                    continue;
                }
                throw new ScanFailure('network', null, true, $error);
            }

            $retryableStatus =
                $status === 408 || $status === 429 || $status >= 500;

            if ($retryableStatus && $attempt < $this->maxAttempts) {
                $this->pause(
                    $attempt,
                    $responseHeaders['retry-after'] ?? null
                );
                continue;
            }

            return new HttpResponse($status, $content, $responseHeaders);
        }

        throw new ScanFailure('network', null, true, 'Request failed');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            $seconds = min(30, (int) $retryAfter);
        } else {
            $seconds = min(4, 2 ** ($attempt - 1));
        }

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

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

    public function __construct(
        private Transport $transport,
        private string $token,
    ) {}

    public function analyze(string $url): SecurityReport
    {
        $response = $this->transport->postJson(
            self::ENDPOINT,
            [
                'Authorization: Bearer ' . $this->token,
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            ['url' => $url],
        );

        if ($response->status < 200 || $response->status >= 300) {
            $category = match (true) {
                in_array($response->status, [401, 403], true)
                    => 'authentication',
                $response->status === 429 => 'quota',
                $response->status >= 500 => 'upstream',
                default => 'request',
            };

            throw new ScanFailure(
                $category,
                $response->status,
                $category === 'quota' || $category === 'upstream',
                'Analyzer returned HTTP ' . $response->status,
            );
        }

        try {
            $data = json_decode(
                $response->body,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException $exception) {
            throw new ScanFailure(
                'invalid_response',
                $response->status,
                false,
                'Analyzer returned invalid JSON'
            );
        }

        if (!is_array($data)) {
            throw new ScanFailure(
                'invalid_response',
                $response->status,
                false,
                'Analyzer returned an invalid document'
            );
        }

        return SecurityReport::fromArray($data);
    }
}

The transport retries transient network errors, HTTP 408, quota responses, and server failures with bounded backoff. It deliberately does not retry authentication or validation failures. After the third attempt, the caller receives a structured failure instead of an ambiguous empty result.

Persist the baseline and send a meaningful alert

<?php
// Append to src/Security.php
namespace App;

interface Mailer
{
    public function send(string $to, string $subject, string $body): void;
}

final class NativeMailMailer implements Mailer
{
    public function __construct(private string $from)
    {
        if (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException('Invalid sender address');
        }
    }

    public function send(string $to, string $subject, string $body): void
    {
        if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException('Invalid owner address');
        }

        $sent = mail($to, $subject, $body, [
            'From' => $this->from,
            'Content-Type' => 'text/plain; charset=UTF-8',
        ]);

        if (!$sent) {
            throw new ScanFailure('mail', null, true, 'Mail delivery failed');
        }
    }
}

final class FileScoreStore
{
    public function __construct(private string $path) {}

    public function load(): int|float|null
    {
        if (!is_file($this->path)) {
            return null;
        }

        $data = json_decode(
            (string) file_get_contents($this->path),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        return is_int($data['score'] ?? null) ||
            is_float($data['score'] ?? null)
            ? $data['score']
            : throw new RuntimeException('Invalid score state');
    }

    public function save(SecurityReport $report): void
    {
        $temporary = $this->path . '.tmp.' . getmypid();
        $json = json_encode([
            'score' => $report->score,
            'checked_at' => gmdate(DATE_ATOM),
        ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

        if (file_put_contents($temporary, $json, LOCK_EX) === false) {
            throw new RuntimeException('Cannot write score state');
        }

        chmod($temporary, 0600);

        if (!rename($temporary, $this->path)) {
            throw new RuntimeException('Cannot replace score state');
        }
    }
}

final class WeeklyMonitor
{
    public function __construct(
        private WebsiteSecurityClient $client,
        private FileScoreStore $store,
        private Mailer $mailer,
    ) {}

    public function run(string $url, string $owner): array
    {
        $report = $this->client->analyze($url);
        $previous = $this->store->load();
        $alerted = false;

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

            $body = "The website security score dropped.\n\n"
                . "Website: {$url}\n"
                . "Previous score: {$previous}\n"
                . "Current score: {$report->score}\n"
                . "Findings: " . implode(', ', $counts) . "\n\n"
                . "Recommendations:\n- "
                . implode("\n- ", $report->recommendations)
                . "\n\nThis is a bounded, non-invasive posture analysis,"
                . " not a penetration test.";

            $this->mailer->send(
                $owner,
                'Website security score decreased',
                $body
            );
            $alerted = true;
        }

        $this->store->save($report);

        return compact('previous', 'alerted') + [
            'current' => $report->score,
        ];
    }
}

The first successful run establishes a baseline and sends no email. Later improvements update that baseline silently. A decrease triggers an alert containing score movement, finding counts by whatever severity groups the service returns, and recommendations. TLS details remain available in the domain object but are not dumped into email or logs.

Wire the production command

<?php
// bin/scan.php
declare(strict_types=1);

use App\CurlTransport;
use App\FileScoreStore;
use App\NativeMailMailer;
use App\ScanFailure;
use App\WebsiteSecurityClient;
use App\WeeklyMonitor;

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

function required(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
{
    fwrite(STDERR, json_encode([
        'timestamp' => gmdate(DATE_ATOM),
        'level' => $level,
        'event' => $event,
    ] + $context, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}

try {
    $url = required('MONITORED_URL');
    $parts = parse_url($url);

    if (($parts['scheme'] ?? null) !== 'https' ||
        empty($parts['host']) ||
        isset($parts['user']) ||
        isset($parts['pass'])) {
        throw new RuntimeException('MONITORED_URL must be a public HTTPS URL');
    }

    $state = required('STATE_FILE');
    $lock = fopen($state . '.lock', 'c');
    if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
        logEvent('info', 'scan_skipped', ['reason' => 'already_running']);
        exit(0);
    }

    $monitor = new WeeklyMonitor(
        new WebsiteSecurityClient(
            new CurlTransport(),
            required('WEBSITE_SECURITY_TOKEN')
        ),
        new FileScoreStore($state),
        new NativeMailMailer(required('MAIL_FROM')),
    );

    $result = $monitor->run($url, required('OWNER_EMAIL'));
    logEvent('info', 'scan_succeeded', $result);
    exit(0);
} catch (ScanFailure $failure) {
    logEvent('error', 'scan_failed', [
        'category' => $failure->category,
        'status' => $failure->status,
        'retryable' => $failure->retryable,
    ]);
    exit(1);
} catch (Throwable $failure) {
    logEvent('error', 'scan_failed', ['category' => 'application']);
    exit(1);
}

Logs contain operational metadata but never the token, response body, detailed findings, or email contents. Monitoring can alert on a nonzero exit or repeated scan_failed events.

Test without making external requests

A fake transport makes response mapping and authentication deterministic:

<?php
// tests/SecurityTest.php
use App\HttpResponse;
use App\ScanFailure;
use App\Transport;
use App\WebsiteSecurityClient;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public array $request = [];

    public function __construct(private HttpResponse $response) {}

    public function postJson(string $url, array $headers, array $body): HttpResponse
    {
        $this->request = compact('url', 'headers', 'body');
        return $this->response;
    }
}

final class SecurityTest extends TestCase
{
    public function testMapsDocumentAndSendsBearerToken(): void
    {
        $transport = new FakeTransport(new HttpResponse(200, json_encode([
            'score' => 82,
            'findings' => ['high' => [[]], 'low' => []],
            'tls' => [],
            'recommendations' => ['Review the reported finding.'],
        ], JSON_THROW_ON_ERROR)));

        $report = (new WebsiteSecurityClient($transport, 'test-token'))
            ->analyze('https://business.test');

        self::assertSame(82, $report->score);
        self::assertSame(
            ['url' => 'https://business.test'],
            $transport->request['body']
        );
        self::assertContains(
            'Authorization: Bearer test-token',
            $transport->request['headers']
        );
    }

    public function testRejectsMalformedSuccessResponse(): void
    {
        $transport = new FakeTransport(
            new HttpResponse(200, '{"score":"unknown"}')
        );

        $this->expectException(ScanFailure::class);

        (new WebsiteSecurityClient($transport, 'test-token'))
            ->analyze('https://business.test');
    }

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

        try {
            (new WebsiteSecurityClient($transport, 'expired-token'))
                ->analyze('https://business.test');
            self::fail('Expected a failure');
        } catch (ScanFailure $failure) {
            self::assertSame('authentication', $failure->category);
            self::assertFalse($failure->retryable);
        }
    }
}

Run composer install, composer dump-autoload, and composer test. Keep tokens out of fixtures; these tests require no network connection.

Deploy as a weekly systemd timer

The host needs PHP 8.3 or newer with cURL and JSON, Composer dependencies installed with composer install --no-dev --classmap-authoritative, HTTPS egress, a writable state directory, and a configured local mail-transfer agent. Remember that mail() returning true means the message was accepted locally, not necessarily delivered; monitor the mail queue and configure SPF, DKIM, and DMARC for the sender domain.

# /etc/systemd/system/weekly-security-scan.service
[Unit]
Description=Weekly website security scan
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=security-scan
Group=security-scan
WorkingDirectory=/opt/weekly-security-scan
EnvironmentFile=/etc/weekly-security-scan.env
ExecStart=/usr/bin/php /opt/weekly-security-scan/bin/scan.php
StateDirectory=weekly-security-scan
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/weekly-security-scan

# /etc/systemd/system/weekly-security-scan.timer
[Unit]
Description=Run website security scan weekly

[Timer]
OnCalendar=Mon *-*-* 08:00:00
Persistent=true
RandomizedDelaySec=15m

[Install]
WantedBy=timers.target

Enable the timer with systemctl enable --now weekly-security-scan.timer. Run the service once with systemctl start weekly-security-scan.service, then inspect structured output using journalctl -u weekly-security-scan.service.

Common failures worth planning for

  • HTTP 401 or 403: verify the service-scoped token and whether someone regenerated it. Do not retry indefinitely.
  • HTTP 429: the bounded retry honors an integer Retry-After value up to 30 seconds. Persistent quota failures require checking the active plan or reducing manual runs.
  • HTTP 400-class validation failure: confirm the configured URL is public HTTPS. Retrying an unchanged request will not repair it.
  • Invalid response: fail closed and preserve the previous baseline. An unexpected document must never become a score of zero.
  • Email failure: the old score remains in place, so the next successful run attempts the alert again.
  • Overlapping execution: the nonblocking lock prevents two timer or operator-initiated runs from racing to replace the state file.

Final verification checklist

  • The exact POST endpoint succeeds with the production website URL.
  • The service token exists only in protected environment configuration.
  • PHPUnit tests pass without network access.
  • The first run creates a mode-0600 state file without sending an alert.
  • A controlled lower-score fixture sends one email before updating state.
  • Authentication, quota, malformed-response, and mail failures exit nonzero.
  • Logs expose categories and status codes, but no secrets or detailed findings.
  • The systemd timer is enabled and shows its next weekly execution.

The most important production detail is not the timer or even the email. It is preserving trustworthy state across imperfect networks, expired credentials, changing response data, and mail failures. Once that boundary is reliable, a weekly security score becomes what it should be: a quiet operational signal that speaks up precisely when the website’s posture moves in the wrong direction.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.