Symfony Security Audits: Automate Production Deployment Checks with AI
A deployment can be healthy from the application’s point of view and still weaken the website around it. A proxy change can remove a security header. A certificate chain can be served incorrectly. A new host can expose browser-facing behavior that unit tests never see.
This tutorial adds a bounded, non-invasive security check to a Symfony production deployment. After the new release becomes publicly reachable, a console command submits its HTTPS URL to the Website Security Analyzer, maps the result into a domain object, and returns a meaningful exit code. The result is a repeatable post-deployment check, not a penetration test or a substitute for an authorized security assessment.
Prerequisites
The example targets PHP 8.3 or later and a current Symfony application with the Console, DependencyInjection, HttpClient, and Monolog integrations installed. PHPUnit and Symfony’s testing utilities are used for deterministic HTTP tests.
composer require symfony/console symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack
You also need a production URL that is publicly reachable over HTTPS. Do not feed the service an internal hostname, development machine, private administration interface, or URL containing credentials.
Get access to the analyzer
- Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
- Open the Website Security Analyzer service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token shown there.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because it keeps the credential out of URLs, access logs, and proxy histories.
Regenerating the service token revokes the previously active token. Treat rotation as an operational change: update the production secret, deploy or restart the relevant processes, verify the integration, and only then remove any temporary recovery procedure.
Confirm the endpoint before writing application code
The exact API operation is:
POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
It accepts JSON containing url. Make one minimal request from a trusted shell to confirm the account, plan, token, and public URL:
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.com"}'
Never paste a real token into shell history on a shared machine. For production, supply it through the platform’s secret manager or protected process environment. Symfony can also read an uncommitted .env.local file on a controlled host:
MIHAJLO_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
APP_PUBLIC_URL=https://www.example.com
SECURITY_AUDIT_BLOCKING_SEVERITIES=
Keep the blocking-severity value empty for the first run. Once you have observed the severity group names returned by the service, list the exact groups your deployment policy should reject, separated by commas. This avoids guessing undocumented severity labels or assuming a particular score scale.
Choose a deliberately small architecture
A post-deployment check should be easy to operate. This implementation has three parts:
AuditReportvalidates and represents the API response.WebsiteSecurityAnalyzerowns authentication, timeouts, retries, and HTTP failure classification.RunSecurityAuditCommandpresents a compact result and enforces the configured deployment policy.
Messenger would add no useful reliability here. The deployment process needs the result before it finishes, and the integration performs one bounded request. A synchronous console command makes that contract explicit. If analysis later becomes scheduled or high-volume work, a queue may become justified.
The relevant project structure is:
src/
Command/RunSecurityAuditCommand.php
SecurityAudit/AuditReport.php
SecurityAudit/AnalysisFailed.php
SecurityAudit/WebsiteSecurityAnalyzer.php
tests/
SecurityAudit/WebsiteSecurityAnalyzerTest.php
config/
services.yaml
Map the response at the application boundary
The supplied response contract contains a score, severity-grouped findings, TLS details, and recommendations. The contract does not require application code to know the internal fields of every finding or TLS entry, so the DTO validates the stable outer shape and preserves nested arrays without fabricating a schema.
<?php
// src/SecurityAudit/AuditReport.php
namespace App\SecurityAudit;
final readonly class AuditReport
{
/**
* @param array<string, array<mixed>> $findingsBySeverity
* @param array<string, mixed> $tlsDetails
* @param array<mixed> $recommendations
*/
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tlsDetails,
public array $recommendations,
) {
}
public static function fromApi(array $payload): self
{
if (!isset($payload['score']) || !is_numeric($payload['score'])) {
throw new \UnexpectedValueException('Missing or invalid score.');
}
if (!isset($payload['findings']) || !is_array($payload['findings'])) {
throw new \UnexpectedValueException('Missing or invalid findings.');
}
foreach ($payload['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new \UnexpectedValueException(
'Findings must be grouped by severity.'
);
}
}
if (!isset($payload['tls']) || !is_array($payload['tls'])) {
throw new \UnexpectedValueException('Missing or invalid TLS details.');
}
if (
!isset($payload['recommendations'])
|| !is_array($payload['recommendations'])
) {
throw new \UnexpectedValueException(
'Missing or invalid recommendations.'
);
}
return new self(
(float) $payload['score'],
$payload['findings'],
$payload['tls'],
$payload['recommendations'],
);
}
public function findingCount(string $severity): int
{
return count($this->findingsBySeverity[$severity] ?? []);
}
}
Strict boundary validation is valuable here. If the remote response changes, the deployment receives a clear integration failure instead of silently treating absent security data as a clean result.
Build a bounded, retry-aware API client
The client rejects non-HTTPS targets, sets connection and total-duration limits, retries only transport failures, rate limits, and server failures, and never retries authentication or validation errors. Its logs contain status and attempt metadata, but never the token or response body.
<?php
// src/SecurityAudit/AnalysisFailed.php
namespace App\SecurityAudit;
final class AnalysisFailed extends \RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/SecurityAudit/WebsiteSecurityAnalyzer.php
namespace App\SecurityAudit;
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 readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $serviceToken,
) {
}
public function analyze(string $url): AuditReport
{
$parts = parse_url($url);
if (
filter_var($url, FILTER_VALIDATE_URL) === false
|| !is_array($parts)
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|| empty($parts['host'])
|| isset($parts['user'])
|| isset($parts['pass'])
) {
throw new AnalysisFailed(
'The audit target must be a credential-free HTTPS URL.',
false,
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->httpClient->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->serviceToken,
'json' => ['url' => $url],
'timeout' => 5.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Security analyzer transport failure.', [
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new AnalysisFailed(
'The analyzer could not be reached.',
true,
$exception,
);
}
$this->backoff($attempt);
continue;
}
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR,
);
if (!is_array($payload)) {
throw new \UnexpectedValueException(
'The response is not a JSON object.'
);
}
return AuditReport::fromApi($payload);
} catch (\JsonException|\UnexpectedValueException $exception) {
throw new AnalysisFailed(
'The analyzer returned an invalid response.',
false,
$exception,
);
}
}
if ($status === 401 || $status === 403) {
throw new AnalysisFailed(
'Analyzer authentication or authorization failed.',
false,
);
}
if ($status === 429 || $status >= 500) {
$this->logger->warning('Security analyzer temporary failure.', [
'status' => $status,
'attempt' => $attempt,
]);
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
throw new AnalysisFailed(
'Analyzer capacity or rate limit remained unavailable.',
true,
);
}
throw new AnalysisFailed(
sprintf('Analyzer rejected the request with HTTP %d.', $status),
false,
);
}
throw new \LogicException('Unreachable retry state.');
}
private function backoff(int $attempt): void
{
usleep(250_000 * (2 ** ($attempt - 1)));
}
}
Three attempts with short exponential backoff absorb brief network and service interruptions without turning deployment into an indefinite wait. A persistent 429 is surfaced as retryable so the release can be rechecked later. A 401, 403, malformed request, or invalid response fails immediately because repetition will not repair it.
Expose the audit as a Symfony command
<?php
// src/Command/RunSecurityAuditCommand.php
namespace App\Command;
use App\SecurityAudit\AnalysisFailed;
use App\SecurityAudit\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\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:security-audit',
description: 'Checks the public production website security posture.',
)]
final class RunSecurityAuditCommand extends Command
{
public function __construct(
private readonly WebsiteSecurityAnalyzer $analyzer,
private readonly LoggerInterface $logger,
private readonly string $publicUrl,
private readonly string $blockingSeverityGroups,
) {
parent::__construct();
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
$io = new SymfonyStyle($input, $output);
try {
$report = $this->analyzer->analyze($this->publicUrl);
} catch (AnalysisFailed $exception) {
$this->logger->error('Production security audit failed.', [
'retryable' => $exception->retryable,
'reason' => $exception->getMessage(),
]);
$io->error($exception->getMessage());
return Command::FAILURE;
}
$counts = [];
foreach ($report->findingsBySeverity as $severity => $findings) {
$counts[] = sprintf('%s=%d', $severity, count($findings));
}
$io->text(sprintf('Score: %s', $report->score));
$io->text('Findings: '.($counts === [] ? 'none' : implode(', ', $counts)));
$io->text(sprintf(
'TLS details received: %s',
$report->tlsDetails === [] ? 'no' : 'yes',
));
$io->text(sprintf(
'Recommendations: %d',
count($report->recommendations),
));
$blockingGroups = array_values(array_filter(array_map(
'trim',
explode(',', $this->blockingSeverityGroups),
)));
foreach ($blockingGroups as $severity) {
if ($report->findingCount($severity) > 0) {
$io->error(sprintf(
'Deployment policy blocked by severity group "%s".',
$severity,
));
return Command::FAILURE;
}
}
$this->logger->info('Production security audit completed.', [
'score' => $report->score,
'finding_counts' => $counts,
'recommendation_count' => count($report->recommendations),
]);
$io->success('Production security audit completed.');
return Command::SUCCESS;
}
}
Bind the environment-backed values by argument name:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$serviceToken: '%env(MIHAJLO_SECURITY_ANALYZER_TOKEN)%'
$publicUrl: '%env(APP_PUBLIC_URL)%'
$blockingSeverityGroups: '%env(SECURITY_AUDIT_BLOCKING_SEVERITIES)%'
App\:
resource: '../src/'
Test without contacting production services
MockHttpClient makes the integration deterministic while still exercising the actual request construction and response mapper.
<?php
// tests/SecurityAudit/WebsiteSecurityAnalyzerTest.php
namespace App\Tests\SecurityAudit;
use App\SecurityAudit\AnalysisFailed;
use App\SecurityAudit\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 testItMapsAValidReport(): void
{
$client = new MockHttpClient(
function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
$url,
);
self::assertSame(
['url' => 'https://www.example.com'],
$options['json'],
);
return new MockResponse(json_encode([
'score' => 91,
'findings' => ['high' => [], 'low' => [['id' => 'f1']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported finding.'],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
}
);
$analyzer = new WebsiteSecurityAnalyzer(
$client,
new NullLogger(),
'test-token',
);
$report = $analyzer->analyze('https://www.example.com');
self::assertSame(91.0, $report->score);
self::assertSame(1, $report->findingCount('low'));
self::assertCount(1, $report->recommendations);
}
public function testItDoesNotRetryAuthenticationFailure(): void
{
$client = new MockHttpClient(
new MockResponse('', ['http_code' => 401])
);
$analyzer = new WebsiteSecurityAnalyzer(
$client,
new NullLogger(),
'invalid-token',
);
$this->expectException(AnalysisFailed::class);
$this->expectExceptionMessage(
'Analyzer authentication or authorization failed.'
);
$analyzer->analyze('https://www.example.com');
}
}
php bin/phpunit tests/SecurityAudit/WebsiteSecurityAnalyzerTest.php
Put the check after the production switch
Run the command after traffic has switched to the new release and its ordinary health check passes. Running it earlier may analyze the previous release rather than the one being deployed.
APP_ENV=prod php bin/console app:security-audit --no-interaction
A zero exit code means the analyzer completed and no configured blocking group contained findings. A nonzero exit code means the API failed, its response violated the contract, authentication was rejected, rate limiting persisted, or deployment policy blocked the result.
Decide explicitly what your deployment system should do with that failure. For a small team, marking the deployment failed and alerting a maintainer is often safer than automatically rolling back a functioning release based on a newly introduced external finding. If automatic rollback is required, test that path independently and distinguish a retryable analyzer outage from a genuine policy violation.
Security and operational details that matter
- Scope the target: keep
APP_PUBLIC_URLunder deployment control. Do not turn the command into an endpoint that accepts arbitrary user-supplied URLs. - Protect the token: use masked CI variables or a secret manager. Never log request headers, query strings containing tokens, or complete exception dumps in public build logs.
- Control concurrency: one audit per production deployment is enough. Parallel release jobs can waste quota and amplify rate limiting.
- Keep logs restrained: scores and counts are useful operational signals. Full findings may reveal defensive gaps and belong in access-controlled output.
- Rotate deliberately: because regeneration revokes the prior token, update every production consumer together and verify the new credential promptly.
Common failure modes
A 401 or 403 response usually points to a missing, revoked, incorrectly copied, or unauthorized service token. Confirm plan activation and replace the deployed secret; retries will not help.
A 429 response means the request is rate-limited or available quota cannot currently serve it. The client performs bounded retries, then exits with a retryable failure. Avoid adding aggressive loops around the command.
A transport timeout can indicate DNS, outbound firewall, proxy, or temporary service trouble. Verify that the production runtime can reach the exact HTTPS endpoint.
An invalid-response failure means the returned JSON did not match the required outer contract. Preserve the status and correlation information available in private infrastructure logs, but do not dump credentials or potentially sensitive findings.
An unexpectedly clean result should not be described as proof of security. This service performs bounded, non-invasive analysis of public HTTPS and browser security posture. It does not exercise authenticated workflows, internal infrastructure, business authorization, or the depth of a penetration test.
Final verification checklist
- The service plan is active and the service-scoped token comes from the documentation page’s Service token panel.
- The real token exists only in protected environment-backed configuration.
APP_PUBLIC_URLis the intended public HTTPS production address.- The smoke request succeeds with the exact
POSTendpoint. - The PHPUnit tests pass without making an external request.
- The command runs after the deployed release is publicly reachable.
- Logs record status, score, and counts without exposing the token or full findings.
- Blocking severity groups use names actually returned by the service.
- The team knows whether a failed check alerts, pauses, or rolls back a release.
The strongest deployment checks are rarely the most elaborate. They are the ones that run every time, fail intelligibly, and make their limits obvious. With this command in the release path, public security posture becomes an observable property of each production deployment instead of a task remembered only after something looks wrong.