Symfony: Automatizirajte sigurnosne revizije nakon implementacije uz AI
A deployment can be healthy, fast, and completely reachable while quietly weakening the browser-facing security posture. A missing security header, an unexpected TLS change, or an overly permissive policy may not trigger application tests at all. The practical answer is to make a public security audit part of the deployment lifecycle, immediately after the new release becomes reachable.
This tutorial builds that integration in Symfony on PHP 8.3 or later. A console command submits the production URL to the Website Security Analyzer, maps the response into a typed domain object, reports a concise deployment summary, and fails clearly when the audit itself cannot be completed. The analysis is bounded and non-invasive: it evaluates public HTTPS and browser security posture, but it is not a penetration test.
Get access and copy the service token
Before writing integration code, create or access the account that owns the service subscription:
- 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 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.
The analyzer requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because Symfony can place it directly in an authorization header. Avoid the query parameter in production: URLs are more likely than headers to appear in proxy and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a coordinated configuration change: install the new value in the deployment secret store, deploy it, verify the audit, and only then consider the rotation complete.
Confirm the endpoint before building the feature
The exact API call is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON request body contains the 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"}'
Run this against a public HTTPS address, not localhost, an internal hostname, or a preview protected by private networking. The service must be able to reach the same website that visitors reach.
For local development, put placeholders in .env and real local values in the uncommitted .env.local. In production, inject both variables through the host or CI secret manager rather than committing a production environment file:
# .env
PUBLIC_SITE_URL=https://www.example.com
WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
# .env.local, never committed
WEBSITE_SECURITY_ANALYZER_TOKEN=replace-with-your-service-scoped-token
Choose a deliberately small architecture
This project does not need a controller or Messenger queue. The deployment system already provides reliable orchestration, and an audit command has a useful synchronous contract: success means a response was retrieved and mapped; failure means the check did not complete.
The resulting project structure is compact:
src/
Command/AuditPublicWebsiteCommand.php
SecurityAudit/SecurityAuditResult.php
SecurityAudit/AnalyzerException.php
SecurityAudit/WebsiteSecurityAnalyzer.php
SecurityAudit/WebsiteSecurityAnalyzerClient.php
tests/
SecurityAudit/WebsiteSecurityAnalyzerClientTest.php
config/
services.yaml
The HTTP client owns transport concerns, the result object represents the domain response, and the command handles deployment-oriented presentation. This separation also makes the external call deterministic in tests.
Map the response at the application boundary
External JSON must never be allowed to drift unchecked through the application. The analyzer response supplies a score, severity-grouped findings, TLS details, and recommendations. The mapper below validates their container types without assuming undocumented fields inside individual findings or TLS details.
<?php
// src/SecurityAudit/SecurityAuditResult.php
namespace App\SecurityAudit;
final readonly class SecurityAuditResult
{
/**
* @param array<string, list<array<string, mixed>>> $findings
* @param array<string, mixed> $tls
* @param list<string> $recommendations
*/
public function __construct(
public int|float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {
}
/** @param array<string, mixed> $payload */
public static function fromPayload(array $payload): self
{
if (!isset($payload['score']) || !is_numeric($payload['score'])) {
throw AnalyzerException::invalidResponse('Missing or invalid score.');
}
if (!isset($payload['findings']) || !is_array($payload['findings'])) {
throw AnalyzerException::invalidResponse('Missing severity-grouped findings.');
}
$findings = [];
foreach ($payload['findings'] as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw AnalyzerException::invalidResponse('Invalid findings group.');
}
foreach ($items as $item) {
if (!is_array($item)) {
throw AnalyzerException::invalidResponse('Invalid finding.');
}
}
$findings[$severity] = array_values($items);
}
if (!isset($payload['tls']) || !is_array($payload['tls'])) {
throw AnalyzerException::invalidResponse('Missing or invalid TLS details.');
}
if (!isset($payload['recommendations']) || !is_array($payload['recommendations'])) {
throw AnalyzerException::invalidResponse('Missing recommendations.');
}
$recommendations = [];
foreach ($payload['recommendations'] as $recommendation) {
if (!is_string($recommendation)) {
throw AnalyzerException::invalidResponse('Invalid recommendation.');
}
$recommendations[] = $recommendation;
}
return new self(
score: $payload['score'] + 0,
findings: $findings,
tls: $payload['tls'],
recommendations: $recommendations,
);
}
}
Keeping finding and TLS internals as validated arrays is intentional. It avoids fabricating undocumented field names while still preventing malformed top-level data from contaminating the command.
Implement bounded HTTP behavior
Production integrations need explicit time limits and selective retries. Authentication and validation failures should return immediately; retrying them wastes quota and hides configuration errors. Rate limits, server errors, and transport interruptions may be transient, so this client retries them with capped exponential backoff.
<?php
// src/SecurityAudit/AnalyzerException.php
namespace App\SecurityAudit;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
string $message,
public readonly string $category,
public readonly ?int $statusCode = null,
public readonly bool $retryable = false,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public static function invalidResponse(string $message): self
{
return new self($message, 'invalid_response');
}
}
<?php
// src/SecurityAudit/WebsiteSecurityAnalyzer.php
namespace App\SecurityAudit;
interface WebsiteSecurityAnalyzer
{
public function analyze(string $url): SecurityAuditResult;
}
<?php
// src/SecurityAudit/WebsiteSecurityAnalyzerClient.php
namespace App\SecurityAudit;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class WebsiteSecurityAnalyzerClient implements WebsiteSecurityAnalyzer
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $token,
) {
if ($this->token === '' || $this->token === 'YOUR_SERVICE_TOKEN') {
throw new \InvalidArgumentException('Analyzer token is not configured.');
}
}
public function analyze(string $url): SecurityAuditResult
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new \InvalidArgumentException('PUBLIC_SITE_URL must be a valid HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->httpClient->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->token,
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
return SecurityAuditResult::fromPayload($response->toArray(false));
}
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < 3) {
$this->logger->warning('Security audit request will be retried.', [
'attempt' => $attempt,
'status' => $status,
'host' => parse_url($url, PHP_URL_HOST),
]);
$this->backoff($attempt);
continue;
}
$category = match ($status) {
401, 403 => 'authentication',
400, 422 => 'validation',
429 => 'rate_limit',
default => $status >= 500 ? 'service' : 'http',
};
throw new AnalyzerException(
"Analyzer request failed with HTTP {$status}.",
$category,
$status,
$retryable,
);
} catch (DecodingExceptionInterface $exception) {
throw new AnalyzerException(
'Analyzer returned invalid JSON.',
'invalid_response',
previous: $exception,
);
} catch (TransportExceptionInterface $exception) {
if ($attempt < 3) {
$this->logger->warning('Security audit transport failure; retrying.', [
'attempt' => $attempt,
'host' => parse_url($url, PHP_URL_HOST),
]);
$this->backoff($attempt);
continue;
}
throw new AnalyzerException(
'Analyzer transport failed after three attempts.',
'transport',
retryable: true,
previous: $exception,
);
}
}
throw new \LogicException('Unreachable retry state.');
}
private function backoff(int $attempt): void
{
usleep(250_000 * (2 ** ($attempt - 1)));
}
}
The logs include the destination host, attempt, and status, but never the token, authorization header, full response body, or sensitive exception context. The maximum backoff remains short, while timeout bounds inactivity and max_duration bounds each HTTP operation.
Configure dependency injection
Bind environment values centrally and alias the interface to its concrete client:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$token: '%env(WEBSITE_SECURITY_ANALYZER_TOKEN)%'
$publicUrl: '%env(PUBLIC_SITE_URL)%'
App\:
resource: '../src/'
App\SecurityAudit\WebsiteSecurityAnalyzer:
alias: App\SecurityAudit\WebsiteSecurityAnalyzerClient
This uses Symfony’s first-party HTTP client through HttpClientInterface. In an existing Symfony application, ensure the component is present with composer require symfony/http-client.
Create the deployment command
The command prints only an operational summary. It does not label findings as proof of exploitation, and it does not pretend the result replaces authenticated scanning, code review, dependency auditing, or a penetration test.
<?php
// src/Command/AuditPublicWebsiteCommand.php
namespace App\Command;
use App\SecurityAudit\AnalyzerException;
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-public-site',
description: 'Analyzes the deployed public website security posture.',
)]
final class AuditPublicWebsiteCommand extends Command
{
public function __construct(
private readonly WebsiteSecurityAnalyzer $analyzer,
private readonly LoggerInterface $logger,
private readonly string $publicUrl,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->analyzer->analyze($this->publicUrl);
} catch (AnalyzerException|\InvalidArgumentException $exception) {
$this->logger->error('Post-deployment security audit failed.', [
'category' => $exception instanceof AnalyzerException
? $exception->category
: 'configuration',
'status' => $exception instanceof AnalyzerException
? $exception->statusCode
: null,
]);
$io->error($exception->getMessage());
return Command::FAILURE;
}
$counts = [];
foreach ($result->findings as $severity => $findings) {
$counts[$severity] = count($findings);
}
$this->logger->notice('Post-deployment security audit completed.', [
'score' => $result->score,
'finding_counts' => $counts,
'recommendation_count' => count($result->recommendations),
]);
$io->success(sprintf('Security audit completed. Score: %s', $result->score));
foreach ($counts as $severity => $count) {
$io->writeln(sprintf('%s findings: %d', ucfirst($severity), $count));
}
$io->writeln(sprintf(
'Recommendations: %d; TLS details received: %s',
count($result->recommendations),
$result->tls === [] ? 'no' : 'yes',
));
return Command::SUCCESS;
}
}
Test the boundary without making network calls
MockHttpClient provides a deterministic first-party transport. These tests verify the endpoint, authentication, request body, mapping, retry behavior, and rejection of malformed responses.
<?php
// tests/SecurityAudit/WebsiteSecurityAnalyzerClientTest.php
namespace App\Tests\SecurityAudit;
use App\SecurityAudit\AnalyzerException;
use App\SecurityAudit\WebsiteSecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteSecurityAnalyzerClientTest extends TestCase
{
public function testItSendsAndMapsAnAudit(): void
{
$http = 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('Bearer test-token', $options['normalized_headers']['authorization'][0]);
self::assertSame(
['url' => 'https://www.example.com'],
json_decode($options['body'], true, flags: JSON_THROW_ON_ERROR),
);
return new MockResponse(json_encode([
'score' => 91,
'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported medium finding.'],
], JSON_THROW_ON_ERROR));
},
);
$result = (new WebsiteSecurityAnalyzerClient(
$http,
new NullLogger(),
'test-token',
))->analyze('https://www.example.com');
self::assertSame(91, $result->score);
self::assertCount(1, $result->findings['medium']);
self::assertTrue($result->tls['enabled']);
}
public function testItRetriesAServiceFailure(): void
{
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse(json_encode([
'score' => 100,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR)),
]);
$result = (new WebsiteSecurityAnalyzerClient(
$http,
new NullLogger(),
'test-token',
))->analyze('https://www.example.com');
self::assertSame(100, $result->score);
self::assertSame(2, $http->getRequestsCount());
}
public function testItRejectsMalformedPayloads(): void
{
$http = new MockHttpClient(new MockResponse('{"score":90}'));
$client = new WebsiteSecurityAnalyzerClient($http, new NullLogger(), 'test-token');
$this->expectException(AnalyzerException::class);
$client->analyze('https://www.example.com');
}
}
Run the suite with php bin/phpunit. If your application invokes PHPUnit through another project-standard command, use that wrapper while keeping the test transport unchanged.
Place the audit after the production traffic switch
The analyzer must inspect the newly deployed public release. Run the command after migrations, cache warmup, health checks, and the load balancer or symlink switch—not while the old release still serves the public hostname.
curl --fail --silent --show-error \
--max-time 15 \
'https://www.example.com/health'
php bin/console app:security:audit-public-site \
--env=prod \
--no-debug
Attach these commands to the post-deployment stage used by your hosting platform. A non-zero command result should fail the audit stage and trigger notification, but it should not automatically destroy an otherwise healthy release. An external analyzer outage and a defective application deployment are different failure domains. Retry the audit stage separately or let an operator review its structured failure category.
If findings should eventually block promotion, introduce that policy explicitly. For example, decide which normalized severity groups are blocking and account for approved exceptions. Do not quietly turn a changing third-party score into an undeclared rollback rule.
Security, observability, and common failures
Restrict the token to the service for which it was issued, keep it in a secret manager, and limit who can read production environment values. Redact authorization headers in HTTP tracing and error-reporting integrations. If the token is exposed, regenerate it, remembering that the previous active token is immediately revoked.
Monitor audit completion as well as findings. Useful dimensions include the deployment identifier, target host, score, counts by severity, latency, retry count, HTTP status, and failure category. Never use the token as a log correlation value.
- HTTP 401 or 403: confirm that the active service-scoped token reached the production process. Do not retry blindly; regeneration may have revoked the deployed value.
- HTTP 400 or 422: verify that the request contains the JSON
urland thatPUBLIC_SITE_URLis a valid public HTTPS URL. - HTTP 429: the current plan or request rate may be limiting calls. Retrying every deployment concurrently can worsen the problem; serialize deployment audits where appropriate.
- Transport timeout: distinguish analyzer connectivity from application health. The bounded retries should end predictably and leave a visible failed audit stage.
- Unexpected response shape: treat it as an integration failure. Preserve the typed boundary and update it only after checking the official documentation.
- Results describe the previous release: move the command after DNS, proxy, or release switching and wait only for the normal health check to confirm the new release is publicly served.
Final verification checklist
- The production URL is public, uses HTTPS, and resolves to the newly deployed release.
- The Free, Plus, or Pro plan is active for the analyzer service.
- The service token comes from the documentation page’s Service token panel.
- The token is injected through environment-backed configuration and absent from source control and logs.
- The client sends
POSTto the exact analyzer endpoint with a JSONurl. - Timeouts and retries are bounded, while authentication and validation failures are not retried.
- The score, severity-grouped findings, TLS details, and recommendations are validated at the API boundary.
php bin/phpunitpasses with no live network dependency.- The audit command runs after every production traffic switch and its failure is observable.
- The result is communicated as a public security-posture analysis, never as a penetration test.
A post-deployment audit is most valuable when it behaves like ordinary engineering infrastructure: predictable inputs, bounded execution, typed outputs, explicit failure semantics, and no heroic interpretation required. Once this command is part of the release path, browser and TLS posture stop being something the team remembers to inspect occasionally. They become another property checked every time production changes—which is exactly where security automation earns its place.