Обезбедете ги вашите Laravel распоредувања: Автоматизирајте ги безбедносните скенирања на веб-страниците по пуштањето во употреба
A deployment is not complete merely because the new release answers a health check. Configuration changes, proxy rules, certificate renewals, and framework upgrades can quietly alter the security posture of an otherwise healthy site.
This tutorial adds a bounded, non-invasive website security analysis to a Laravel deployment pipeline. After each production release, an Artisan command checks the public HTTPS endpoint, maps the result into a typed domain object, stores the latest report, and returns a meaningful exit code. The scan evaluates browser-facing and TLS posture; it must not be represented as a penetration test or a substitute for authenticated security testing.
Get access to the Website Security Analyzer
Start by registering an account, or use the sign-in page if you already have one.
- 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 its service-scoped token.
- Store that token in environment-backed project configuration.
Regenerating the service token revokes the previously active token. Coordinate rotation with deployment configuration so production never depends on a token that has already been invalidated.
The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses Bearer authentication. Headers are preferable to query parameters because URLs are more likely to appear in access logs, monitoring tools, browser history, and diagnostic output.
Confirm the exact endpoint
The integration sends POST requests to https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. The JSON body contains one required value: url.
Before writing Laravel code, make one minimal request from a trusted terminal. Replace both placeholders and avoid saving the command in shared shell scripts or documentation:
curl --request POST \
--url 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://www.example.com"}'
Put the credential and deployment target in local .env configuration during development. In production, inject equivalent values through the deployment platform or secret manager. Never commit a populated environment file.
WEBSITE_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_ANALYZER_URL=https://www.example.com
WEBSITE_ANALYZER_CONNECT_TIMEOUT=3
WEBSITE_ANALYZER_TIMEOUT=15
Choose a deployment-friendly architecture
The project needs only four application components: environment-backed configuration, an API client, a domain result, and an Artisan command. The command is deliberately synchronous. A queued job would let deployment finish sooner, but it would also separate the scan result from the release that triggered it. A synchronous command gives the deployment system an immediate success or failure exit code.
The scan should run after the new release is live and its public health check succeeds. Running it before traffic reaches the release would inspect the old deployment or an internal hostname rather than the real browser-facing path.
The relevant project structure is:
app/
Console/Commands/AnalyzeProductionWebsite.php
Domain/Security/WebsiteAnalysis.php
Services/WebsiteAnalyzerClient.php
config/
services.php
tests/
Feature/AnalyzeProductionWebsiteTest.php
Connect Laravel configuration to the environment
Add a dedicated entry to config/services.php. Configuration files may read environment variables; application classes should read Laravel configuration instead. That distinction keeps config:cache reliable.
<?php
return [
// Existing service configuration...
'website_analyzer' => [
'token' => env('WEBSITE_ANALYZER_TOKEN'),
'url' => env('WEBSITE_ANALYZER_URL'),
'connect_timeout' => (int) env('WEBSITE_ANALYZER_CONNECT_TIMEOUT', 3),
'timeout' => (int) env('WEBSITE_ANALYZER_TIMEOUT', 15),
],
];
Map the response at the application boundary
Remote JSON should not flow unvalidated through the application. The analyzer result contains a score, severity-grouped findings, TLS details, and recommendations. A small immutable object verifies those shapes before deployment code relies on them.
<?php
namespace App\Domain\Security;
use UnexpectedValueException;
final readonly class WebsiteAnalysis
{
public function __construct(
public int|float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $payload): self
{
$score = $payload['score'] ?? null;
$findings = $payload['findings'] ?? null;
$tls = $payload['tls'] ?? null;
$recommendations = $payload['recommendations'] ?? null;
if (! is_int($score) && ! is_float($score)) {
throw new UnexpectedValueException('Analyzer score is missing or invalid.');
}
if (! is_array($findings) || ! is_array($tls) || ! is_array($recommendations)) {
throw new UnexpectedValueException('Analyzer result sections are missing or invalid.');
}
foreach ($findings as $severity => $items) {
if (! is_string($severity) || ! is_array($items)) {
throw new UnexpectedValueException('Findings are not grouped by severity.');
}
}
foreach ($recommendations as $recommendation) {
if (! is_string($recommendation)) {
throw new UnexpectedValueException('Analyzer recommendations are invalid.');
}
}
return new self($score, $findings, $tls, array_values($recommendations));
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
This mapper intentionally avoids assuming undocumented score thresholds, finding names, or TLS subfields. If the service contract evolves, the client will produce a visible protocol failure instead of silently saving misleading partial data.
Build a bounded, retry-aware API client
Laravel’s built-in HTTP client supplies JSON encoding, Bearer authentication, timeouts, and deterministic test fakes. The client below retries connection failures, HTTP 429, and selected transient server responses. It does not retry authentication or validation failures, because repeated identical requests cannot repair a bad token or URL.
<?php
namespace App\Services;
use App\Domain\Security\WebsiteAnalysis;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
use UnexpectedValueException;
final class WebsiteAnalyzerClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function __construct(private LoggerInterface $logger) {}
public function analyze(string $url): WebsiteAnalysis
{
$token = config('services.website_analyzer.token');
if (! is_string($token) || $token === '') {
throw new RuntimeException('Website analyzer token is not configured.');
}
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https'
|| ! is_string(parse_url($url, PHP_URL_HOST))) {
throw new RuntimeException('The analyzer target must be a valid HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->acceptJson()
->asJson()
->connectTimeout((int) config(
'services.website_analyzer.connect_timeout',
3
))
->timeout((int) config('services.website_analyzer.timeout', 15))
->post(self::ENDPOINT, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new RuntimeException(
'Could not connect to the website analyzer.',
previous: $exception
);
}
$this->pauseBeforeRetry($attempt, null);
continue;
}
if ($response->successful()) {
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('Analyzer returned malformed JSON.');
}
try {
return WebsiteAnalysis::fromArray($payload);
} catch (UnexpectedValueException $exception) {
throw new RuntimeException(
'Analyzer returned an unexpected response contract.',
previous: $exception
);
}
}
if (in_array($response->status(), [401, 403], true)) {
throw new RuntimeException('Analyzer authentication was rejected.');
}
if ($response->status() === 422) {
throw new RuntimeException('Analyzer rejected the target URL.');
}
$retryable = $response->status() === 429
|| in_array($response->status(), [500, 502, 503, 504], true);
if (! $retryable || $attempt === 3) {
throw new RuntimeException(
'Analyzer request failed with HTTP '.$response->status().'.'
);
}
$retryAfter = $response->header('Retry-After');
$this->pauseBeforeRetry(
$attempt,
is_string($retryAfter) && ctype_digit($retryAfter)
? (int) $retryAfter
: null
);
}
throw new RuntimeException('Analyzer request ended unexpectedly.');
}
private function pauseBeforeRetry(int $attempt, ?int $retryAfterSeconds): void
{
$milliseconds = $retryAfterSeconds === null
? 250 * (2 ** ($attempt - 1))
: $retryAfterSeconds * 1000;
$milliseconds = min(2000, max(250, $milliseconds));
$this->logger->warning('Retrying website security analysis.', [
'attempt' => $attempt + 1,
'delay_ms' => $milliseconds,
]);
usleep($milliseconds * 1000);
}
}
The two-second backoff cap keeps deployment time predictable. A final 429 remains a failure rather than making the release process wait indefinitely. If the selected plan regularly reaches its quota, scheduling fewer deployments or changing the plan is more honest than hiding the condition behind aggressive retries.
Create the post-deployment command
The command retrieves its configured target, invokes the client, writes a machine-readable report to Laravel’s local disk, and emits a compact log event. It does not log the token, complete response, or potentially sensitive finding details.
<?php
namespace App\Console\Commands;
use App\Services\WebsiteAnalyzerClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class AnalyzeProductionWebsite extends Command
{
protected $signature = 'security:analyze-production';
protected $description = 'Analyze the public production website security posture';
public function handle(WebsiteAnalyzerClient $client): int
{
$url = config('services.website_analyzer.url');
if (! is_string($url) || $url === '') {
$this->error('WEBSITE_ANALYZER_URL is not configured.');
return self::FAILURE;
}
try {
$analysis = $client->analyze($url);
$report = [
'scanned_at' => now()->toIso8601String(),
'url' => $url,
...$analysis->toArray(),
];
Storage::disk('local')->put(
'security/last-website-scan.json',
json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
);
Log::info('Production website security analysis completed.', [
'url' => $url,
'score' => $analysis->score,
'finding_groups' => array_map('count', $analysis->findings),
]);
$this->info('Website analysis completed. Score: '.$analysis->score);
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error('Website analysis failed: '.$exception->getMessage());
return self::FAILURE;
}
}
}
Test success, persistence, and retries
Http::fake() prevents tests from contacting the real service. The first test verifies response mapping and report persistence. The second proves that a transient failure is retried and eventually succeeds.
<?php
namespace Tests\Feature;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class AnalyzeProductionWebsiteTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.website_analyzer.token', 'test-token');
config()->set('services.website_analyzer.url', 'https://www.example.com');
Storage::fake('local');
}
public function test_it_analyzes_and_stores_the_production_report(): void
{
Http::fake([
'*' => Http::response([
'score' => 88,
'findings' => [
'high' => [],
'medium' => [['summary' => 'Example finding']],
],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported browser policy.'],
], 200),
]);
$this->artisan('security:analyze-production')->assertExitCode(0);
Storage::disk('local')
->assertExists('security/last-website-scan.json');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url()
=== 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
&& $request['url'] === 'https://www.example.com'
&& $request->hasHeader('Authorization', 'Bearer test-token');
});
}
public function test_it_retries_a_transient_server_failure(): void
{
Http::fakeSequence()
->push([], 503)
->push([
'score' => 95,
'findings' => ['high' => []],
'tls' => ['enabled' => true],
'recommendations' => [],
], 200);
$this->artisan('security:analyze-production')->assertExitCode(0);
Http::assertSentCount(2);
}
}
Add further tests for 401, 422, repeated 429 responses, connection exceptions, malformed JSON, and missing response sections. Those are not incidental edge cases: they define whether deployment failures are understandable at the moment someone must respond to them.
Place the scan in the production deployment
Run tests and cache configuration before switching the release. Once the new version is active, wait for its public health check and execute the command:
php artisan test
php artisan config:cache
# Activate the release and complete the public health check here.
php artisan security:analyze-production
Keep the command’s nonzero exit status visible to the deployment system. Whether it should roll back a release is a business decision. API unavailability usually warrants an alert and manual review, while a severe new finding may justify blocking promotion. Do not derive either policy from an undocumented score threshold.
Security, observability, and common failures
- Protect the credential: keep it in a secret manager or protected environment setting, restrict access to deployment operators, and rotate it deliberately.
- Control the target: accept the URL only from trusted deployment configuration. Do not expose this client through a public controller that accepts arbitrary URLs.
- Preserve useful evidence: retain the generated report as a protected deployment artifact if local release storage is ephemeral.
- Alert on structured outcomes: monitor command exit codes, HTTP status categories, retry counts, latency, and score changes without recording authorization headers or full response bodies.
- Interpret scope correctly: the result describes bounded, non-invasive public HTTPS and browser security posture. It is not proof that the application is free of vulnerabilities.
A 401 or 403 normally means the token is absent, malformed, revoked, or belongs to the wrong service activation. A 422 points to the submitted URL rather than transport reliability. Repeated 429 responses indicate quota or rate-limit pressure. TLS findings that seem to contradict server configuration often originate at the public CDN, load balancer, or reverse proxy—the layer the browser actually reaches.
Final verification checklist
- The Free, Plus, or Pro plan is active and the service-scoped token is current.
- The production secret store contains the token without exposing it in source control.
- The configured target is the canonical public HTTPS URL.
- The request uses
POST, Bearer authentication, and a JSONurl. - Timeouts, bounded retries, and non-retryable authentication and validation failures behave as intended.
Http::fake()tests pass without external network access.- The command runs after release activation and the public health check.
- The report is stored somewhere durable and access-controlled.
- Deployment operators can see failures and know whether to alert, investigate, or roll back.
The valuable habit is not collecting one more security score. It is making the public edge of every release observable at the moment change is introduced. A small, disciplined post-deployment check turns certificate mistakes, weakened headers, and browser-policy regressions from quiet surprises into actionable release signals.