Symfony: Неделни известувања за оценка на безбедноста на веб-страницата за мали бизниси
A security score is useful once. A security score with history is operationally useful. For a small business, the practical version is a quiet weekly check that remembers the previous result and emails the owner only when the public website’s posture gets worse.
This tutorial builds that workflow in Symfony on PHP 8.3 or later. A console command calls the Website Security Analyzer, maps its response into a defensive domain object, compares the score with durable local state, and sends an alert through Symfony Mailer. Failures remain visible in logs, credentials stay outside source control, and unsuccessful checks never overwrite the last known score.
The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. Treat its findings as prioritization signals, not as a penetration test, vulnerability certification, or substitute for an authorized security assessment.
Get access and copy the service token
Complete access setup before writing integration code:
- Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
- Open the Website Security Analyzer service page.
- Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official service documentation.
- Find the Service token panel and copy the 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 authentication in the standard authorization header. Regenerating the service token revokes the previously active token, so rotate the application configuration at the same time.
Verify the exact endpoint
The request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website, with a JSON body containing url. Before building the Symfony feature, make one minimal request from a trusted terminal:
curl --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"}'
Use a public HTTPS URL belonging to the business. Do not put the real token in shell history on a shared machine, documentation, screenshots, or committed fixtures.
Create the Symfony application
You need PHP 8.3 or later, Composer, an SMTP-compatible mail transport, and a server capable of running a weekly scheduled command. Symfony Messenger would add another worker and failure surface without helping this low-frequency workflow, so the design uses a synchronous console command protected by a distributed-capable Symfony lock.
composer create-project symfony/skeleton security-watch
cd security-watch
composer require symfony/http-client symfony/mailer symfony/lock
composer require --dev symfony/test-pack
The relevant project structure is deliberately small:
src/SecurityAnalyzer/AnalysisResult.phpholds the validated domain result.src/SecurityAnalyzer/SecurityAnalyzerClient.phpowns the remote API boundary.src/SecurityAnalyzer/ScoreStateStore.phppersists the last successful score.src/Command/CheckWebsiteSecurityCommand.phpcoordinates locking, comparison, email, and state updates.tests/SecurityAnalyzer/SecurityAnalyzerClientTest.phpexercises HTTP behavior without network access.
Configure secrets and deployment-specific values
Put safe placeholders in .env, then provide real production values through your hosting platform’s secret manager or process environment. Symfony’s .env.local is acceptable for an unmanaged single server, provided it is excluded from version control and readable only by the application account.
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_SITE_URL=https://www.example.com
[email protected]
[email protected]
SECURITY_STATE_FILE=%kernel.project_dir%/var/security/score.json
LOCK_DSN=flock
MAILER_DSN=smtp://USERNAME:[email protected]:587
Wire scalar configuration explicitly in config/services.yaml and enable the lock store:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\SecurityAnalyzer\SecurityAnalyzerClient:
arguments:
$token: '%env(SECURITY_ANALYZER_TOKEN)%'
App\SecurityAnalyzer\ScoreStateStore:
arguments:
$filename: '%env(resolve:SECURITY_STATE_FILE)%'
App\Command\CheckWebsiteSecurityCommand:
arguments:
$siteUrl: '%env(SECURITY_SITE_URL)%'
$ownerEmail: '%env(SECURITY_OWNER_EMAIL)%'
$senderEmail: '%env(SECURITY_ALERT_FROM)%'
# config/packages/lock.yaml
framework:
lock: '%env(LOCK_DSN)%'
The filesystem state store suits one small-business deployment node. If several application nodes can execute the schedule, replace both the state file and flock lock with shared storage. Otherwise, two nodes could compare against different baselines.
Map the API response at the boundary
Remote JSON must not leak unchecked into business logic. The supplied contract includes a score, severity-grouped findings, TLS details, and recommendations, but integrations should still reject missing or malformed data rather than guessing defaults.
<?php
// src/SecurityAnalyzer/AnalysisResult.php
namespace App\SecurityAnalyzer;
final readonly class AnalysisResult
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {
}
public static function fromArray(array $data): self
{
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new \UnexpectedValueException('Response score is missing or invalid.');
}
if (!isset($data['findings']) || !is_array($data['findings'])) {
throw new \UnexpectedValueException('Response findings are missing or invalid.');
}
foreach ($data['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new \UnexpectedValueException(
'Findings must be arrays grouped by severity.'
);
}
}
if (!isset($data['tls']) || !is_array($data['tls'])) {
throw new \UnexpectedValueException('Response TLS details are missing or invalid.');
}
if (!isset($data['recommendations']) || !is_array($data['recommendations'])) {
throw new \UnexpectedValueException(
'Response recommendations are missing or invalid.'
);
}
return new self(
(float) $data['score'],
$data['findings'],
$data['tls'],
array_values($data['recommendations']),
);
}
}
Preserving finding and TLS structures as arrays is intentional. It avoids inventing undocumented nested fields while still validating the stable top-level contract. More specific value objects can be introduced after confirming the documented payload used by your activated service version.
Build a bounded, retry-aware HTTP client
The client limits connection and total request time. It retries transport failures, HTTP 429 responses, and server failures with bounded backoff. It does not retry validation or authentication failures: repeating a bad URL or revoked token only consumes time and quota.
<?php
// src/SecurityAnalyzer/SecurityAnalyzerClient.php
namespace App\SecurityAnalyzer;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SecurityAnalyzerClient
{
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): AnalysisResult
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->token,
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 25.0,
]);
$status = $response->getStatusCode();
if (($status === 429 || $status >= 500) && $attempt < 3) {
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$delay = ctype_digit((string) $retryAfter)
? min(10, (int) $retryAfter)
: 2 ** ($attempt - 1);
$this->logger->warning('Security analysis will be retried.', [
'status' => $status,
'attempt' => $attempt,
'delay_seconds' => $delay,
]);
usleep($delay * 1_000_000);
continue;
}
if ($status < 200 || $status >= 300) {
$kind = match (true) {
$status === 429 => 'quota_or_rate_limit',
$status === 401 || $status === 403 => 'authentication',
$status >= 400 && $status < 500 => 'request_validation',
default => 'remote_service',
};
throw new \RuntimeException(
sprintf('Security analyzer failure: %s (HTTP %d).', $kind, $status)
);
}
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new \RuntimeException(
'Security analyzer returned invalid JSON.',
0,
$exception
);
}
if (!is_array($payload)) {
throw new \RuntimeException(
'Security analyzer returned an invalid document.'
);
}
return AnalysisResult::fromArray($payload);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new \RuntimeException(
'Security analyzer was unreachable after three attempts.',
0,
$exception
);
}
$delay = 2 ** ($attempt - 1);
$this->logger->warning('Security analyzer transport retry.', [
'attempt' => $attempt,
'delay_seconds' => $delay,
]);
usleep($delay * 1_000_000);
}
}
throw new \LogicException('Retry loop ended unexpectedly.');
}
}
Notice what is absent from logs: the token, response body, and full TLS or finding payload. Status, attempt number, duration, score, and outcome are generally sufficient operational signals without turning logs into a secondary store for potentially sensitive details.
Persist only successful observations
The state file is written through a temporary file and atomic rename. A malformed existing file fails loudly instead of silently resetting the baseline.
<?php
// src/SecurityAnalyzer/ScoreStateStore.php
namespace App\SecurityAnalyzer;
final class ScoreStateStore
{
public function __construct(private string $filename)
{
}
public function load(): ?float
{
if (!is_file($this->filename)) {
return null;
}
$data = json_decode(
(string) file_get_contents($this->filename),
true,
512,
JSON_THROW_ON_ERROR
);
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new \UnexpectedValueException('Stored security score is invalid.');
}
return (float) $data['score'];
}
public function save(float $score): void
{
$directory = dirname($this->filename);
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create the security state directory.');
}
$temporary = $this->filename . '.' . bin2hex(random_bytes(6)) . '.tmp';
$json = json_encode(
['score' => $score, 'checked_at' => gmdate(DATE_ATOM)],
JSON_THROW_ON_ERROR
);
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write temporary security state.');
}
if (!rename($temporary, $this->filename)) {
@unlink($temporary);
throw new \RuntimeException('Cannot replace security state.');
}
}
}
Coordinate the weekly check and alert
The first successful run establishes a baseline without alarming the owner. Later runs email only when the new score is lower than the immediately preceding successful score. The state advances after a successful email, so a mail failure does not erase the unreported drop.
<?php
// src/Command/CheckWebsiteSecurityCommand.php
namespace App\Command;
use App\SecurityAnalyzer\ScoreStateStore;
use App\SecurityAnalyzer\SecurityAnalyzerClient;
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:check',
description: 'Checks the website security score and alerts on a drop.'
)]
final class CheckWebsiteSecurityCommand extends Command
{
public function __construct(
private SecurityAnalyzerClient $client,
private ScoreStateStore $state,
private MailerInterface $mailer,
private LockFactory $lockFactory,
private LoggerInterface $logger,
private string $siteUrl,
private string $ownerEmail,
private string $senderEmail,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$lock = $this->lockFactory->createLock('weekly-security-check', 900);
if (!$lock->acquire()) {
$this->logger->notice('Security check skipped: another run holds the lock.');
return Command::SUCCESS;
}
try {
$previous = $this->state->load();
$result = $this->client->analyze($this->siteUrl);
if ($previous !== null && $result->score < $previous) {
$findingCounts = [];
foreach ($result->findingsBySeverity as $severity => $findings) {
$findingCounts[] = sprintf('%s: %d', $severity, count($findings));
}
$message = (new Email())
->from($this->senderEmail)
->to($this->ownerEmail)
->subject('Website security score dropped')
->text(sprintf(
"Website: %s\nPrevious score: %s\nCurrent score: %s\n"
. "Findings: %s\nRecommendations available: %d\n\n"
. "Review the analyzer result and verify material issues. "
. "This bounded check is not a penetration test.",
$this->siteUrl,
$previous,
$result->score,
$findingCounts === [] ? 'none grouped' : implode(', ', $findingCounts),
count($result->recommendations)
));
$this->mailer->send($message);
}
$this->state->save($result->score);
$this->logger->info('Weekly security check completed.', [
'site' => $this->siteUrl,
'previous_score' => $previous,
'current_score' => $result->score,
'alert_sent' => $previous !== null && $result->score < $previous,
]);
return Command::SUCCESS;
} catch (\Throwable $exception) {
$this->logger->error('Weekly security check failed.', [
'site' => $this->siteUrl,
'exception_class' => $exception::class,
'message' => $exception->getMessage(),
]);
return Command::FAILURE;
} finally {
$lock->release();
}
}
}
Test without calling the live service
MockHttpClient makes retries and response mapping deterministic. The tests use an obviously fake token and never require network access.
<?php
// tests/SecurityAnalyzer/SecurityAnalyzerClientTest.php
namespace App\Tests\SecurityAnalyzer;
use App\SecurityAnalyzer\SecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class SecurityAnalyzerClientTest extends TestCase
{
public function testMapsAValidResponse(): void
{
$http = new MockHttpClient(new MockResponse(json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported browser policy.'],
], JSON_THROW_ON_ERROR), ['http_code' => 200]));
$result = (new SecurityAnalyzerClient(
$http,
new NullLogger(),
'TEST_TOKEN'
))->analyze('https://www.example.com');
self::assertSame(82.0, $result->score);
self::assertCount(1, $result->findingsBySeverity['medium']);
self::assertCount(1, $result->recommendations);
}
public function testRejectsAnAuthenticationFailureWithoutRetrying(): void
{
$requests = 0;
$http = new MockHttpClient(function () use (&$requests): MockResponse {
$requests++;
return new MockResponse('{"error":"unauthorized"}', ['http_code' => 401]);
});
$client = new SecurityAnalyzerClient($http, new NullLogger(), 'TEST_TOKEN');
try {
$client->analyze('https://www.example.com');
self::fail('An authentication exception was expected.');
} catch (\RuntimeException $exception) {
self::assertStringContainsString('authentication', $exception->getMessage());
self::assertSame(1, $requests);
}
}
public function testRetriesOneServerFailure(): void
{
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse(json_encode([
'score' => 91,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR), ['http_code' => 200]),
]);
$result = (new SecurityAnalyzerClient(
$http,
new NullLogger(),
'TEST_TOKEN'
))->analyze('https://www.example.com');
self::assertSame(91.0, $result->score);
}
}
Run php bin/phpunit, then execute php bin/console app:security:check -vv manually. The first run should create the state file and send no email. To test mail safely, use a staging recipient and controlled fixture or mock-based command test; do not manipulate the live service into producing a lower score.
Schedule and operate it
On a single Linux host, schedule the command for a quiet weekly window. This example runs every Monday at 06:17 UTC:
17 6 * * 1 cd /srv/security-watch && /usr/bin/php bin/console app:security:check >> var/log/security-cron.log 2>&1
Run it as the application user, ensure that user can write var/security, and restrict access to environment files and state. Configure log rotation, monitor non-zero command exits, and alert separately when scheduled execution stops. “No score-drop email” must not be mistaken for proof that the job is healthy.
Common failures are straightforward to classify. HTTP 401 or 403 usually means the token is missing, revoked, or belongs to the wrong service activation. HTTP 429 indicates quota or rate limiting and is retried only briefly. HTTP 400-class validation errors should prompt inspection of the configured public HTTPS URL. Timeouts and 500-class failures preserve the existing baseline. Mail transport failures also preserve it, allowing the drop to remain actionable on the next successful run.
Final verification checklist
- The activated plan and service-scoped token come from the official service and documentation pages.
- The application calls the exact POST endpoint with a JSON
urland Bearer authentication. - No real token appears in source control, tests, command output, or logs.
- Connection and total-duration limits prevent a stalled weekly process.
- Only transport, rate-limit, and server failures receive bounded retries.
- Malformed responses fail before reaching score-comparison logic.
- The first successful run establishes the baseline without emailing.
- A lower score sends one plain-text alert before the new score is saved.
- Failed analysis or email delivery leaves the prior successful score intact.
- The scheduler’s exit status and last successful execution are monitored independently.
The valuable part of this system is not the cron expression or even the email. It is the chain of careful decisions around them: a narrowly scoped credential, a validated boundary, finite retries, truthful failure behavior, and durable comparison state. That turns a periodic API request into a small production control the business can actually trust.