Vodiči

Symfony Security Alert: Automate Weekly Website Scans and Email Owners on Score Drops

Symfony sigurnosno upozorenje: Automatizirajte tjedna skeniranja web-mjesta i vlasnicima e-poštom javite o padu rezultata

A security regression rarely arrives with a dramatic failure page. More often, a certificate setting changes, a protective header disappears, or a deployment quietly weakens the browser-facing posture of an otherwise healthy website. The useful question is not whether someone remembers to check. It is whether the check happens automatically and reaches the person who can act.

This tutorial builds a production-oriented Symfony monitor for a small business website. Once a week, a console command submits the public HTTPS URL for bounded, non-invasive analysis, compares the returned security score with the previous successful result, and emails the owner only when the score drops. The analysis covers public HTTPS and browser security posture; it must not be described or treated as a penetration test.

Get access to the Website Security Analyzer

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

Open the Website Security Analyzer service page, choose the available Free, Plus, or Pro plan, and complete its activation. Then visit the official service documentation. Find the Service token panel and copy the service-scoped token shown there.

The 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 the URL, where query parameters can be captured by access logs and monitoring systems.

Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the application secret, deploy it, and verify the next request rather than leaving an old token in a forgotten environment file.

Confirm the endpoint before writing Symfony code

The exact request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains a single url value. Make one minimal request from a trusted terminal:

curl --fail-with-body \
  --request POST \
  --url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://www.example-business.test"}'

Replace the placeholder URL with the business website. Do not paste a real token into shell history on a shared machine; an environment variable or temporary protected credential file is safer for this manual check.

Store application secrets in .env.local for local development. Symfony does not normally commit that file. In production, inject the same names through the hosting platform or secret manager instead:

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_SITE_URL=https://www.example-business.test
[email protected]
[email protected]
MAILER_DSN=smtp://SMTP_USER:[email protected]:587

Percent-encode reserved characters in SMTP credentials. Never place a real service token in source code, test fixtures, logs, screenshots, or a committed .env file.

Prerequisites and architecture

The project requires PHP 8.3 or later, Composer, the JSON and PDO SQLite extensions, an SMTP transport, and a Symfony application. Install the first-party HTTP client, mailer, locking component, and test support:

composer create-project symfony/skeleton security-monitor
cd security-monitor
composer require symfony/http-client symfony/mailer symfony/lock
composer require --dev symfony/test-pack

The implementation stays synchronous. A scheduled console command is already outside an HTTP request, so adding Messenger would introduce another worker, transport, and failure mode without improving this small weekly workload.

The command acquires a lock, requests an analysis, maps the response into a domain object, loads the previous score from SQLite, sends an alert on a strict decrease, and records the new score. Failed analyses never replace the last known-good baseline. If email delivery fails, the score is also left unchanged, allowing a later command retry to attempt the alert again.

The relevant files are:

config/packages/framework.yaml
config/services.yaml
src/Security/WebsiteAnalysis.php
src/Security/AnalyzerException.php
src/Security/WebsiteSecurityAnalyzer.php
src/Security/SecurityScoreStore.php
src/Security/ScoreDropPolicy.php
src/Command/SecurityScanCommand.php
tests/Security/WebsiteSecurityAnalyzerTest.php
tests/Security/ScoreDropPolicyTest.php

Configure dependency injection and locking

Enable a local filesystem lock. It prevents overlapping scheduler invocations from sending duplicate alerts:

# config/packages/framework.yaml
framework:
    lock: 'flock'

Bind environment-backed values explicitly. The SQLite file must live on persistent storage in production; otherwise a new release could erase the comparison baseline.

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Security\WebsiteSecurityAnalyzer:
        arguments:
            $token: '%env(SECURITY_ANALYZER_TOKEN)%'

    App\Security\SecurityScoreStore:
        arguments:
            $databasePath: '%kernel.project_dir%/var/security-monitor.sqlite'

    App\Command\SecurityScanCommand:
        arguments:
            $siteUrl: '%env(resolve:SECURITY_SITE_URL)%'
            $ownerEmail: '%env(OWNER_EMAIL)%'
            $fromEmail: '%env(ALERT_FROM)%'

Map the API response at the boundary

Remote JSON should not spread through the application as an unvalidated array. The supplied contract includes a score, severity-grouped findings, TLS details, and recommendations. The mapper requires those top-level values but deliberately avoids inventing a schema for their internal details.

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

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

    public static function fromArray(array $data): self
    {
        foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
            if (!array_key_exists($field, $data)) {
                throw new \UnexpectedValueException("Missing response field: {$field}");
            }
        }

        if (!is_numeric($data['score']) || !is_finite((float) $data['score'])) {
            throw new \UnexpectedValueException('The score is not a finite number.');
        }

        if (!is_array($data['findings']) || !is_array($data['tls'])
            || !is_array($data['recommendations'])) {
            throw new \UnexpectedValueException('Invalid analysis collections.');
        }

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

        foreach ($data['recommendations'] as $recommendation) {
            if (!is_string($recommendation)) {
                throw new \UnexpectedValueException('Invalid recommendation.');
            }
        }

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

Build a bounded, retry-aware API client

The client sets connection and total-response bounds. It retries transient transport failures and server errors with short exponential backoff. Authentication and validation failures return immediately. A rate-limit response is retried only when the server supplies a short numeric Retry-After; a long quota delay belongs in the scheduler, not inside a sleeping PHP process.

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

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct("Analyzer failure: {$kind}", 0, $previous);
    }
}
<?php
// src/Security/WebsiteSecurityAnalyzer.php
namespace App\Security;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
    ) {}

    public function analyze(string $url): WebsiteAnalysis
    {
        if (parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new AnalyzerException('invalid_target');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->token,
                    'headers' => ['Accept' => 'application/json'],
                    'json' => ['url' => $url],
                    'timeout' => 8.0,
                    'max_duration' => 15.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $data = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );

                    return WebsiteAnalysis::fromArray($data);
                }

                if ($status === 401 || $status === 403) {
                    throw new AnalyzerException('authentication', $status);
                }

                if ($status === 400 || $status === 422) {
                    throw new AnalyzerException('validation', $status);
                }

                if ($status === 429) {
                    $value = $response->getHeaders(false)['retry-after'][0] ?? null;
                    $seconds = is_numeric($value) ? (float) $value : null;

                    if ($attempt < 3 && $seconds !== null && $seconds <= 5) {
                        usleep((int) ($seconds * 1_000_000));
                        continue;
                    }

                    throw new AnalyzerException('rate_limited', $status);
                }

                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt, $status);
                    continue;
                }

                throw new AnalyzerException('remote_error', $status);
            } catch (AnalyzerException $e) {
                throw $e;
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException('transport', null, $e);
                }

                $this->backoff($attempt, null);
            } catch (\JsonException|\UnexpectedValueException $e) {
                throw new AnalyzerException('malformed_response', null, $e);
            }
        }

        throw new AnalyzerException('transport');
    }

    private function backoff(int $attempt, ?int $status): void
    {
        $milliseconds = 250 * (2 ** ($attempt - 1));
        $this->logger->warning('security_analyzer.retry', [
            'attempt' => $attempt,
            'status' => $status,
            'delay_ms' => $milliseconds,
        ]);
        usleep($milliseconds * 1000);
    }
}

Notice what is absent from the logs: the token, response body, SMTP credentials, and complete findings. Those can be sensitive even when the scan itself is non-invasive.

Persist the last successful score

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

final class SecurityScoreStore
{
    private \PDO $pdo;

    public function __construct(string $databasePath)
    {
        $this->pdo = new \PDO('sqlite:'.$databasePath, null, null, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ]);

        $this->pdo->exec(
            'CREATE TABLE IF NOT EXISTS security_score (
                site_url TEXT PRIMARY KEY,
                score REAL NOT NULL,
                scanned_at TEXT NOT NULL
            )'
        );
    }

    public function previous(string $url): ?float
    {
        $statement = $this->pdo->prepare(
            'SELECT score FROM security_score WHERE site_url = :url'
        );
        $statement->execute(['url' => $url]);
        $value = $statement->fetchColumn();

        return $value === false ? null : (float) $value;
    }

    public function save(string $url, float $score): void
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO security_score (site_url, score, scanned_at)
             VALUES (:url, :score, :scanned_at)
             ON CONFLICT(site_url) DO UPDATE SET
                score = excluded.score,
                scanned_at = excluded.scanned_at'
        );

        $statement->execute([
            'url' => $url,
            'score' => $score,
            'scanned_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        ]);
    }
}

The first successful run establishes a baseline without alarming the owner. Later runs compare against the immediately preceding successful score:

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

final class ScoreDropPolicy
{
    public static function shouldNotify(?float $previous, float $current): bool
    {
        return $previous !== null && $current < $previous;
    }
}

Run the scan and email the owner

<?php
// src/Command/SecurityScanCommand.php
namespace App\Command;

use App\Security\AnalyzerException;
use App\Security\ScoreDropPolicy;
use App\Security\SecurityScoreStore;
use App\Security\WebsiteSecurityAnalyzer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

#[AsCommand(name: 'app:security-scan')]
final class SecurityScanCommand extends Command
{
    public function __construct(
        private WebsiteSecurityAnalyzer $analyzer,
        private SecurityScoreStore $store,
        private MailerInterface $mailer,
        private LockFactory $lockFactory,
        private LoggerInterface $logger,
        private string $siteUrl,
        private string $ownerEmail,
        private string $fromEmail,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $lock = $this->lockFactory->createLock(
            'weekly-security-scan-'.hash('sha256', $this->siteUrl),
            900,
            false,
        );

        if (!$lock->acquire()) {
            $this->logger->notice('security_scan.already_running');
            return Command::SUCCESS;
        }

        try {
            $analysis = $this->analyzer->analyze($this->siteUrl);
            $previous = $this->store->previous($this->siteUrl);
            $alertSent = false;

            if (ScoreDropPolicy::shouldNotify($previous, $analysis->score)) {
                $counts = [];
                foreach ($analysis->findingsBySeverity as $severity => $items) {
                    $counts[] = sprintf('%s: %d', $severity, count($items));
                }

                $recommendations = array_slice($analysis->recommendations, 0, 5);
                $body = sprintf(
                    "The security score for %s dropped from %s to %s.\n\nFindings: %s",
                    $this->siteUrl,
                    $previous,
                    $analysis->score,
                    $counts === [] ? 'no grouped entries returned' : implode(', ', $counts),
                );

                if ($recommendations !== []) {
                    $body .= "\n\nRecommendations:\n- ".implode("\n- ", $recommendations);
                }

                $this->mailer->send(
                    (new Email())
                        ->from($this->fromEmail)
                        ->to($this->ownerEmail)
                        ->subject('Website security score decreased')
                        ->text($body)
                );
                $alertSent = true;
            }

            $this->store->save($this->siteUrl, $analysis->score);
            $this->logger->info('security_scan.completed', [
                'previous_score' => $previous,
                'current_score' => $analysis->score,
                'alert_sent' => $alertSent,
            ]);

            return Command::SUCCESS;
        } catch (AnalyzerException $e) {
            $this->logger->error('security_scan.analyzer_failed', [
                'kind' => $e->kind,
                'status' => $e->status,
            ]);
            return Command::FAILURE;
        } catch (\Throwable $e) {
            $this->logger->error('security_scan.failed', [
                'exception' => $e::class,
            ]);
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

Email delivery and database storage cannot share one transaction. Sending before saving favors eventual notification: a mail failure preserves the old baseline. A rare database failure after successful delivery could cause a duplicate on retry. A transactional outbox can remove that ambiguity, but it is usually disproportionate for one weekly recipient.

Test the boundary and alert rule

MockHttpClient keeps tests deterministic and prevents real API calls:

<?php
// tests/Security/WebsiteSecurityAnalyzerTest.php
namespace App\Tests\Security;

use App\Security\WebsiteSecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    public function testItMapsAValidAnalysis(): void
    {
        $transport = new MockHttpClient(function ($method, $url, $options) {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                $url,
            );
            self::assertSame(
                ['url' => 'https://shop.example.test'],
                json_decode($options['body'], true),
            );

            return new MockResponse(json_encode([
                'score' => 84,
                'findings' => ['high' => [], 'medium' => [['id' => 'x']]],
                'tls' => ['available' => true],
                'recommendations' => ['Review the reported medium finding.'],
            ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
        });

        $client = new WebsiteSecurityAnalyzer(
            $transport,
            new NullLogger(),
            'test-token',
        );

        $result = $client->analyze('https://shop.example.test');

        self::assertSame(84.0, $result->score);
        self::assertCount(1, $result->findingsBySeverity['medium']);
    }
}
<?php
// tests/Security/ScoreDropPolicyTest.php
namespace App\Tests\Security;

use App\Security\ScoreDropPolicy;
use PHPUnit\Framework\TestCase;

final class ScoreDropPolicyTest extends TestCase
{
    public function testOnlyARealDropTriggersAnAlert(): void
    {
        self::assertFalse(ScoreDropPolicy::shouldNotify(null, 80));
        self::assertFalse(ScoreDropPolicy::shouldNotify(80, 80));
        self::assertFalse(ScoreDropPolicy::shouldNotify(80, 85));
        self::assertTrue(ScoreDropPolicy::shouldNotify(80, 79));
    }
}

Run the suite with php bin/phpunit. Add equivalent fixtures for malformed JSON, missing response fields, authentication failure, rate limiting, and exhausted transport retries. Tests must use placeholder tokens and synthetic findings.

Deploy, schedule, and observe

Run the command once interactively in the production environment to establish the baseline:

APP_ENV=prod php bin/console app:security-scan --no-interaction

Then schedule it every Monday. For a traditional single-host deployment, this cron entry runs at 04:17 in the server’s configured timezone:

17 4 * * 1 cd /srv/security-monitor && APP_ENV=prod php bin/console app:security-scan --no-interaction

Container platforms should use their native scheduled-job facility and mount var/security-monitor.sqlite on persistent storage. Multiple replicas also need a shared lock and shared database rather than the local SQLite and flock arrangement shown here.

Forward Symfony logs to your normal log destination and alert on a nonzero command exit. The structured failure kinds distinguish invalid configuration, authentication, validation, rate limiting, transport trouble, malformed responses, and general remote errors without exposing the response payload.

Common production failures

  • Immediate 401 or 403: verify that the service plan remains active and that the deployed token is current. Regenerating the token revokes its predecessor.
  • Validation failure: confirm the configured target is a public HTTPS URL and that the JSON body contains url.
  • Repeated 429 responses: do not increase retries. Check plan capacity and ensure duplicate schedulers are not running.
  • No email: inspect the command exit status and mailer logs, verify MAILER_DSN, and remember that the first successful scan only establishes a baseline.
  • Alerts after every deployment: the SQLite file is not persistent. Mount it outside the release directory or replace the store with the application’s shared database.

Final verification checklist

  1. Confirm the activated plan and service-scoped token belong to the Website Security Analyzer.
  2. Verify the manual POST request succeeds without exposing the token.
  3. Run the automated tests and confirm no request reaches the live service.
  4. Execute the command once and verify that it stores a baseline without sending an alert.
  5. In a non-production test database, seed a higher previous score and confirm exactly one email is produced.
  6. Confirm failures return a nonzero exit code and do not overwrite the previous score.
  7. Verify the scheduler, persistent storage, lock, SMTP delivery, log collection, and token-rotation procedure.

The finished monitor is intentionally modest: one public website, one weekly command, one durable baseline, and one meaningful alert. That restraint is a reliability feature. A security score is not proof that a site is safe, and this analysis is not a penetration test. It is an early-warning signal that turns a quiet regression into a concrete conversation while the change is still fresh enough to fix.

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.