Tutorials

Symfony: Automate Post-Deployment Website Security Scans with AI Analysis

Symfony: Automate Post-Deployment Website Security Scans with AI Analysis

A deployment can be technically successful while quietly weakening the site it delivered. A proxy change may remove a security header, a certificate chain may be incomplete, or a newly introduced resource policy may leave the browser with less protection than expected.

The practical response is to make an external security check part of the deployment itself. In this tutorial, we will build a Symfony command that submits the production URL to the Website Security Analyzer, maps the result into a stable domain object, retries only recoverable failures, and produces structured output suitable for CI logs.

The analyzer performs bounded, non-invasive analysis of a public HTTPS endpoint and its browser security posture. It is useful for deployment assurance, but it is not a penetration test and should never be presented as one.

Get access to the Website Security Analyzer

Authentication is required for this service; there is no token-free mode in this integration. Complete the following onboarding flow before writing application code:

  1. Register at https://ai.mihajlo.mk/register, or sign in through https://ai.mihajlo.mk/login.
  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 the service-scoped token shown there.

Regenerating this token revokes the previously active token. Treat rotation as a coordinated deployment change: install the new value in the production secret store, redeploy every consumer, verify the scan, and only then remove any obsolete CI configuration.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer header because it keeps the credential out of URLs, proxy histories, and routine access logs.

Confirm the HTTP contract first

The exact request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains one required value, url.

Make a minimal request from a controlled terminal before integrating Symfony:

curl --request POST \
  '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.com"}'

The response contains a score, findings grouped by severity, TLS details, and recommendations. Our application will validate those sections without assuming undocumented fields inside an individual finding or TLS record.

Store local credentials in .env.local, which must remain outside version control:

WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_TARGET_URL=https://www.example.com

In production, set the same environment variable names through the hosting platform or CI secret store instead of copying a local environment file onto the server.

Architecture and project prerequisites

This implementation requires PHP 8.3 or later, an existing Symfony application, Composer, outbound HTTPS access to the API, and a publicly reachable production HTTPS URL. Install the first-party HTTP client and the usual testing support if the project does not already have them:

composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack

The project uses four focused components:

  • SecurityReport owns boundary validation and domain-level response mapping.
  • AnalyzerException exposes structured failure categories without leaking response bodies.
  • WebsiteSecurityAnalyzer owns authentication, HTTP timeouts, retry policy, and logging.
  • WebsiteSecurityScanCommand fixes the target URL through configuration and gives the deployment pipeline a deterministic exit code.

Messenger would add queue infrastructure without helping this particular workflow. A post-deployment verification needs an immediate result, so a synchronous console command is the clearer trade-off. The API call remains isolated enough to move behind Messenger later if scans become scheduled background work.

Map the response at the application boundary

External JSON should not flow unvalidated through the application. The following immutable DTO requires the four documented response sections while deliberately preserving their nested contents:

<?php
// src/Security/SecurityReport.php

namespace App\Security;

use JsonSerializable;
use UnexpectedValueException;

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

    public static function fromPayload(array $payload): self
    {
        if (!array_key_exists('score', $payload) || !is_numeric($payload['score'])) {
            throw new UnexpectedValueException('The analyzer response has no numeric score.');
        }

        foreach (['findings', 'tls', 'recommendations'] as $section) {
            if (!isset($payload[$section]) || !is_array($payload[$section])) {
                throw new UnexpectedValueException(
                    sprintf('The analyzer response has no valid %s section.', $section)
                );
            }
        }

        foreach ($payload['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new UnexpectedValueException(
                    'The findings section is not grouped by severity.'
                );
            }
        }

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

    public function jsonSerialize(): array
    {
        return [
            'score' => $this->score,
            'findings' => $this->findings,
            'tls' => $this->tls,
            'recommendations' => $this->recommendations,
        ];
    }
}

This mapper does not invent fields such as certificate issuer names, header identifiers, or remediation URLs. Consumers can add typed nested objects later when those structures have been confirmed against the official documentation and real fixtures.

Represent operational failures separately from analysis findings:

<?php
// src/Security/AnalyzerException.php

namespace App\Security;

use RuntimeException;
use Throwable;

final class AnalyzerException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Build a bounded, retry-aware API client

The client permits three total attempts. It retries transport failures, HTTP 429 responses, and server-side failures. Authentication and other ordinary client errors fail immediately because repeated identical requests will not repair an invalid token or request.

<?php
// src/Security/WebsiteSecurityAnalyzer.php

namespace App\Security;

use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use UnexpectedValueException;

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

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly LoggerInterface $logger,
        private readonly string $websiteSecurityToken,
    ) {
        if (trim($websiteSecurityToken) === '') {
            throw new InvalidArgumentException('The website security token is empty.');
        }
    }

    public function analyze(string $url): SecurityReport
    {
        $parts = parse_url($url);

        if (
            filter_var($url, FILTER_VALIDATE_URL) === false
            || ($parts['scheme'] ?? null) !== 'https'
            || empty($parts['host'])
        ) {
            throw new InvalidArgumentException(
                'The scan target must be a valid public HTTPS URL.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            $response = null;

            try {
                $response = $this->httpClient->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->websiteSecurityToken,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 30.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    try {
                        return SecurityReport::fromPayload(
                            $response->toArray(false)
                        );
                    } catch (
                        DecodingExceptionInterface
                        | UnexpectedValueException $exception
                    ) {
                        throw new AnalyzerException(
                            'malformed_response',
                            'The analyzer returned an invalid response.',
                            $exception,
                        );
                    }
                }

                if ($status === 401 || $status === 403) {
                    throw new AnalyzerException(
                        'authentication',
                        'The analyzer rejected its service token.'
                    );
                }

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

                if (!$retryable || $attempt === 3) {
                    $kind = $status === 429
                        ? 'rate_limited'
                        : ($status >= 500 ? 'upstream_failure' : 'request_rejected');

                    throw new AnalyzerException(
                        $kind,
                        sprintf('The analyzer returned HTTP %d.', $status)
                    );
                }

                $this->logger->warning('Website security scan will be retried.', [
                    'attempt' => $attempt,
                    'http_status' => $status,
                ]);

                $this->pauseBeforeRetry($response, $attempt);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'transport',
                        'The analyzer could not be reached after three attempts.',
                        $exception,
                    );
                }

                $this->logger->warning('Website security scan transport failure.', [
                    'attempt' => $attempt,
                    'exception_class' => $exception::class,
                ]);

                $this->pauseBeforeRetry(null, $attempt);
            }
        }

        throw new AnalyzerException('internal', 'The scan ended unexpectedly.');
    }

    private function pauseBeforeRetry(
        ?ResponseInterface $response,
        int $attempt,
    ): void {
        $delayMicroseconds = 100_000 * (2 ** ($attempt - 1));

        if ($response !== null) {
            $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;

            if (is_string($retryAfter) && ctype_digit($retryAfter)) {
                $delayMicroseconds = min((int) $retryAfter, 2) * 1_000_000;
            }
        }

        usleep($delayMicroseconds);
    }
}

The Retry-After delay is capped at two seconds so a provider response cannot hold a deployment worker indefinitely. Both the per-operation timeout and total request duration are bounded. Logs contain status, attempt, and exception class, but never the token, request headers, response body, or full exception message.

Wire configuration into Symfony

Bind the two environment-backed values explicitly. This keeps secrets out of source code while allowing normal autowiring for the HTTP client and logger:

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

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

    App\Security\WebsiteSecurityAnalyzer:
        arguments:
            $websiteSecurityToken: '%env(WEBSITE_SECURITY_TOKEN)%'

    App\Command\WebsiteSecurityScanCommand:
        arguments:
            $websiteSecurityTargetUrl: '%env(WEBSITE_SECURITY_TARGET_URL)%'

Create the post-deployment command

The target URL is configuration, not a command argument. That prevents a compromised or mistaken pipeline parameter from turning the paid integration into a general-purpose URL scanner.

<?php
// src/Command/WebsiteSecurityScanCommand.php

namespace App\Command;

use App\Security\AnalyzerException;
use App\Security\WebsiteSecurityAnalyzer;
use InvalidArgumentException;
use JsonException;
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;

#[AsCommand(
    name: 'app:security-scan',
    description: 'Analyzes the configured production website security posture.'
)]
final class WebsiteSecurityScanCommand extends Command
{
    public function __construct(
        private readonly WebsiteSecurityAnalyzer $analyzer,
        private readonly LoggerInterface $logger,
        private readonly string $websiteSecurityTargetUrl,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        try {
            $report = $this->analyzer->analyze(
                $this->websiteSecurityTargetUrl
            );

            $output->writeln(json_encode(
                $report,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
            ));

            return Command::SUCCESS;
        } catch (AnalyzerException | InvalidArgumentException | JsonException $exception) {
            $kind = $exception instanceof AnalyzerException
                ? $exception->kind
                : 'configuration';

            $this->logger->error('Post-deployment security scan failed.', [
                'failure_kind' => $kind,
                'exception_class' => $exception::class,
            ]);

            $output->writeln(sprintf(
                'Security scan failed (%s). Consult application logs.',
                $kind
            ));

            return Command::FAILURE;
        }
    }
}

A successful command writes one JSON document containing the score, grouped findings, TLS details, and recommendations. Avoid automatically failing on a guessed score threshold or an assumed severity vocabulary. Establish a deployment policy only after confirming the service’s scoring semantics and agreeing which documented findings should block a release.

Test without calling the real service

MockHttpClient makes retry and authentication behavior deterministic. The following tests prove that a temporary server failure is retried and an authentication failure is not:

<?php
// tests/Security/WebsiteSecurityAnalyzerTest.php

namespace App\Tests\Security;

use App\Security\AnalyzerException;
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
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    public function testItRetriesARecoverableFailureAndMapsTheReport(): void
    {
        $attempts = 0;

        $client = new MockHttpClient(
            function (string $method, string $url) use (&$attempts): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame(self::ENDPOINT, $url);

                $attempts++;

                if ($attempts === 1) {
                    return new MockResponse('', ['http_code' => 503]);
                }

                return new MockResponse(json_encode([
                    'score' => 91,
                    'findings' => ['high' => [], 'medium' => []],
                    'tls' => ['available' => true],
                    'recommendations' => [],
                ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
            }
        );

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

        $report = $analyzer->analyze('https://www.example.com');

        self::assertSame(2, $attempts);
        self::assertSame(91.0, $report->score);
        self::assertArrayHasKey('high', $report->findings);
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $attempts = 0;

        $client = new MockHttpClient(
            function () use (&$attempts): MockResponse {
                $attempts++;

                return new MockResponse('', ['http_code' => 401]);
            }
        );

        $analyzer = new WebsiteSecurityAnalyzer(
            $client,
            new NullLogger(),
            'invalid-token'
        );

        try {
            $analyzer->analyze('https://www.example.com');
            self::fail('Expected an AnalyzerException.');
        } catch (AnalyzerException $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $attempts);
        }
    }
}

Run the focused test suite with php bin/phpunit tests/Security. These fixtures contain a fake token and synthetic responses; neither real credentials nor copied production payloads belong in test fixtures.

Run it after every production deployment

Execute the scan only after production traffic points to the new release. A pre-release scan would merely inspect the old public version. A small deployment tail can verify reachability and then invoke Symfony:

set -eu

curl --fail \
  --silent \
  --show-error \
  --max-time 10 \
  "$WEBSITE_SECURITY_TARGET_URL" > /dev/null

php bin/console app:security-scan --env=prod --no-debug

Let the deployment system capture the command’s JSON output as an artifact or structured log. Keep the application live if the external analyzer is temporarily unavailable, but mark the post-deployment verification as failed and alert the responsible developer. Automatic rollback based solely on a third-party timeout can make a healthy release less reliable.

Quota exhaustion and rate limiting appear as the structured rate_limited failure after bounded retries. Reduce duplicate deployment triggers or choose an appropriate plan rather than adding unbounded retries.

Common production failures

  • Authentication failure: confirm that the secret belongs to this service and was updated everywhere after regeneration.
  • Request rejected: verify that the configured target is a valid public HTTPS URL and that the JSON body still contains url.
  • Malformed response: retain the safe failure category and compare the contract with the official documentation before changing the mapper.
  • Transport failure: check outbound DNS, TLS, firewall policy, and the deployment worker’s network route.
  • Unexpected target results: confirm that DNS and production traffic had switched before the command ran and that the URL represents the deployed site.

Final verification checklist

  • The service plan is active and the service-scoped token is stored only in environment-backed secret configuration.
  • WEBSITE_SECURITY_TARGET_URL contains the canonical public HTTPS URL.
  • The deterministic tests pass without external network access.
  • The production command returns zero and emits the four mapped report sections.
  • Retries occur only for transport errors, rate limits, and server failures.
  • Logs and CI artifacts contain no token, authorization header, or raw sensitive response.
  • The command runs after every production release becomes publicly reachable.
  • The result is described as a bounded website security analysis, not a penetration test.

The strongest deployment checks are rarely the most elaborate. They are the ones placed at the correct boundary, given strict time limits, made observable, and run consistently. With this command attached to the release path, browser-facing and TLS regressions become visible immediately after deployment—while the implementation remains small enough for a developer or modest team to understand, test, and trust.

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.