Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions backend-symfony/src/Application/LLM/RetryCoordinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ public function execute(array $context, string $personaCode): array
/** @var array<int, mixed> $lastMsgs */
$lastMsgs = $context['last_messages'] ?? [];
$messageCount = count($lastMsgs);
$bestPolicyApprovedText = null;
// The only draft eligible for a last-resort "best-of-3" is one the
// validator confirmed security-clean (security_pass=true) but rejected on
// quality. A draft rejected for a security reason — or never security-
// checked at all — is never sent (fail closed at the last layer).
$bestSecurityPassedText = null;
$fallbackProvider = $this->getFallbackProvider();

// Evaluate payment anchoring ONCE per generation and share it
Expand Down Expand Up @@ -229,8 +233,6 @@ public function execute(array $context, string $personaCode): array
continue;
}

$bestPolicyApprovedText = $generatedText;

// --- Stage 3: Operational leakage detection ---
if ($this->leakDetector instanceof \App\Application\LLM\OperationalLeakageDetector) {
$leakResult = $this->leakDetector->check($generatedText, $personaCode);
Expand Down Expand Up @@ -290,7 +292,10 @@ public function execute(array $context, string $personaCode): array
]);
$dialogue[] = ['role' => 'validator', 'attempt' => $attempt, 'approved' => false, 'reasons' => [$e->getMessage()]];

if ($attempt === self::MAX_ATTEMPTS && $bestPolicyApprovedText !== null) { // @phpstan-ignore-line
// A validator exception leaves security unverified for this
// attempt; fall back to a best-of-3 only if an EARLIER attempt was
// confirmed security-clean, else the canned reply (fail closed).
if ($attempt === self::MAX_ATTEMPTS && $bestSecurityPassedText !== null) { // @phpstan-ignore-line
break; // Use best-of-3
}

Expand Down Expand Up @@ -382,24 +387,30 @@ public function execute(array $context, string $personaCode): array
];
}

// Validator rejected — retry
if ($attempt === self::MAX_ATTEMPTS && $bestPolicyApprovedText !== null) { // @phpstan-ignore-line
// Validator rejected. Only a security-clean draft (rejected on
// quality) may serve as a best-of-3; a security_pass=false rejection
// is never eligible.
if ($validatorResult['security_pass'] === true) {
$bestSecurityPassedText = $generatedText;
}

if ($attempt === self::MAX_ATTEMPTS && $bestSecurityPassedText !== null) { // @phpstan-ignore-line
break; // Use best-of-3
}

$this->emitReplyRetry($convId, 'validator', $attempt, $personaCode, ['reasons' => $validatorResult['reasons']]);
}

// --- Fallback: best-of-3 or canned ---
if ($bestPolicyApprovedText !== null) {
// --- Fallback: best-of-3 (security-clean only) or canned ---
if ($bestSecurityPassedText !== null) {
$trace->attempts = self::MAX_ATTEMPTS;

return [
'text' => $bestPolicyApprovedText,
'text' => $bestSecurityPassedText,
'approved' => true,
'fallback_used' => false,
'policy_flags' => [],
'validation_reasons' => ['Best-of-3: PolicyGuard-approved, validator rejected'],
'validation_reasons' => ['Best-of-3: security-clean, validator rejected on quality'],
'model' => $this->getModelName(),
'persona' => $personaCode,
'cost_estimate' => $this->estimateTotalCost($dialogue, $messageCount),
Expand Down
50 changes: 50 additions & 0 deletions backend-symfony/src/Application/Stix/StixObjectDeduplicator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace App\Application\Stix;

/**
* Collapses STIX objects that share the same `id`.
*
* STIX object `id`s are globally unique, so a bundle/envelope must never carry two
* objects with the same id. When a multi-cluster feed is assembled by concatenating
* per-cluster bundles, shared SDOs — the `extension-definition`s, and MITRE
* attack-patterns reused across clusters (same deterministic id) — would otherwise
* appear once per cluster.
*/
final class StixObjectDeduplicator
{
/**
* Keep the first object seen for each `id`, preserving order. Entries without a
* string `id` are passed through unchanged (never merged).
*
* @param list<array<string, mixed>> $objects
*
* @return list<array<string, mixed>>
*/
public static function dedupeById(array $objects): array
{
$seen = [];
$out = [];

foreach ($objects as $object) {
$id = $object['id'] ?? null;

if (!\is_string($id) || $id === '') {
$out[] = $object;

continue;
}

if (isset($seen[$id])) {
continue;
}

$seen[$id] = true;
$out[] = $object;
}

return $out;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ final class ThreatActorStixBuilder
// ScamBuster identity (same as StixBundleBuilder)
private const IDENTITY_ID = 'identity--f431f809-377b-45e0-aa1c-6a4751cae5ff';

// MITRE ATT&CK is a static catalog, so attack-patterns get a fixed
// deterministic created/modified (required by STIX 2.1) rather than "now",
// keeping the feed stable across polls.
private const MITRE_CATALOG_TIMESTAMP = '2026-07-30T00:00:00.000Z';

// TLP marking definitions (OpenCTI standard UUIDs)
private const TLP_AMBER = 'marking-definition--f88d31f6-486f-44da-b317-01333bde0b82';
private const TLP_WHITE = 'marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9';
Expand Down Expand Up @@ -217,6 +222,8 @@ public function buildAttackPatterns(?string $attckTechnique): array
'type' => 'attack-pattern',
'spec_version' => '2.1',
'id' => 'attack-pattern--' . $this->deterministicUuid('mitre-attack-' . $attckTechnique),
'created' => self::MITRE_CATALOG_TIMESTAMP,
'modified' => self::MITRE_CATALOG_TIMESTAMP,
'created_by_ref' => self::IDENTITY_ID,
'name' => $technique['name'],
'external_references' => [[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,11 @@ private function buildUsesRelationship(string $actorId, string $apId, array $ttp
$firstSeen = $this->parseTimestamp($ttp['first_seen'] ?? null);
$lastSeen = $this->parseTimestamp($ttp['last_seen'] ?? null);

if ($firstSeen !== null && $lastSeen !== null && $lastSeen < $firstSeen) {
$lastSeen = $firstSeen;
// STIX 2.1 requires stop_time > start_time when present. For a single-point
// sighting (last_seen == first_seen, or earlier) omit stop_time rather than
// emit an equal/invalid value; start_time alone is valid.
if ($firstSeen !== null && $lastSeen !== null && $lastSeen <= $firstSeen) {
$lastSeen = null;
}

$relationship = [
Expand Down
17 changes: 11 additions & 6 deletions backend-symfony/src/Application/Taxii/TaxiiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Application\Stix\CognitiveMirrorNoteBuilder;
use App\Application\Stix\IocContextStixExtensionBuilder;
use App\Application\Stix\IocInteroperableFieldsBuilder;
use App\Application\Stix\StixObjectDeduplicator;
use App\Application\Stix\StixProvenance;
use App\Application\Stix\ThreatActorStixBuilder;
use App\Application\ThreatActor\ThreatActorPsychProfileReaderInterface;
Expand Down Expand Up @@ -721,7 +722,9 @@ private function getClusterObjects(?\DateTimeImmutable $addedAfter, int $limit,
return [
'envelope' => [
'more' => $more,
'objects' => $objects,
// Shared SDOs (extension-definitions, MITRE attack-patterns reused
// across clusters) would otherwise repeat once per cluster.
'objects' => StixObjectDeduplicator::dedupeById($objects),
],
'firstAdded' => $firstAdded !== null ? $this->formatIso8601($firstAdded) : null,
'lastAdded' => $lastAdded !== null ? $this->formatIso8601($lastAdded) : null,
Expand Down Expand Up @@ -874,15 +877,17 @@ private function computeConfidence(array $row): int

private function formatIso8601(string $value): string
{
if ($value === '') {
return (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
}
$utc = new \DateTimeZone('UTC');

try {
return (new \DateTimeImmutable($value))->format(\DateTimeInterface::ATOM);
$dt = $value === '' ? new \DateTimeImmutable('now', $utc) : new \DateTimeImmutable($value);
} catch (\Exception) {
return (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
$dt = new \DateTimeImmutable('now', $utc);
}
Comment on lines 882 to 886

// STIX 2.1 requires RFC3339 UTC with a trailing "Z" (never a "+00:00"
// offset), with millisecond precision to match the STIX builders.
return $dt->setTimezone($utc)->format('Y-m-d\TH:i:s.v\Z');
}

/**
Expand Down
126 changes: 126 additions & 0 deletions backend-symfony/src/Security/SecretPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace App\Security;

/**
* Rejects known-default and obviously-weak secret values in production.
*
* `.env.dist` ships valid-but-globally-known keys so the stack boots out of the box.
* A production instance must never run on them: APP_SECRET signs cookies/CSRF,
* AUDIT_HMAC_KEY signs the tamper-evidence chain, TOTP_ENCRYPTION_KEY protects 2FA
* seeds — all forgeable by anyone who read the public repo.
*
* This is the single source of truth; the prod entrypoint enforces it by running
* `app:security:check-secrets`, which delegates here. It only *strengthens* posture
* and never enforces outside production, so dev/test/e2e keep booting on defaults.
*/
final class SecretPolicy
{
/**
* Exact published `.env.dist` defaults (and the documented admin password),
* keyed by the variable they ship under. A value equal to its own default —
* or to any other listed default — is rejected.
*
* @var array<string, string>
*/
private const PUBLISHED_DEFAULTS = [
'APP_SECRET' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
'TOTP_ENCRYPTION_KEY' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'AUDIT_HMAC_KEY' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'N8N_ENCRYPTION_KEY' => 'dev-only-change-in-production-openssl-rand-hex-32',
'N8N_DEFAULT_USER_PASSWORD' => 'Scambuster2026!',
'ADMIN_PASSWORD' => 'Un1que$trongPassword2024',
];

/**
* Substrings that mark a placeholder rather than a real secret. Matched
* case-insensitively anywhere in the value.
*
* @var list<string>
*/
private const PLACEHOLDER_MARKERS = [
'dev-only-change',
'change-in-production',
'changeme',
'change-me',
'changthis',
'change-this',
'placeholder',
'example',
'insecure',
];

/**
* Evaluate a set of secret values.
*
* @param array<string, string|null> $secrets variable name => value (null = absent, ignored)
*
* @return array<string, string> variable name => human-readable reason, for each violation
* (empty when everything is acceptable, or when not in prod)
*/
public function validate(array $secrets, bool $isProd): array
{
// Never enforced outside production: dev/test/e2e must keep booting on the
// documented .env.dist defaults.
if (!$isProd) {
return [];
}

$violations = [];

foreach ($secrets as $name => $value) {
// Presence is enforced elsewhere (the entrypoint's `:?` guards); the
// policy only judges values it is actually given.
if ($value === null) {
continue;
}

$reason = $this->reasonFor($value);

if ($reason !== null) {
$violations[$name] = $reason;
}
}

return $violations;
}

/**
* @return string|null the reason this value is unacceptable in prod, or null if it is fine
*/
private function reasonFor(string $value): ?string
{
if ($value === '') {
return 'is empty';
}

// Exact match against any published default (a value shipped for one
// variable is just as public when reused for another).
foreach (self::PUBLISHED_DEFAULTS as $default) {
if (hash_equals($default, $value)) {
return 'equals a published .env.dist default value';
}
}

// A single character repeated (e.g. 64 × "a") carries no entropy.
if (strspn($value, $value[0]) === strlen($value)) {
return 'is a single repeated character (no entropy)';
}

$needle = strtolower($value);

foreach (self::PLACEHOLDER_MARKERS as $marker) {
if (str_contains($needle, $marker)) {
return sprintf('looks like a placeholder (contains "%s")', $marker);
}
}

if (str_starts_with($needle, 'your-') || str_starts_with($needle, 'your_')) {
return 'looks like a placeholder (starts with "your-")';
}

return null;
}
}
Loading
Loading