diff --git a/backend-symfony/src/Application/LLM/RetryCoordinator.php b/backend-symfony/src/Application/LLM/RetryCoordinator.php index 12063fd9..9905b698 100644 --- a/backend-symfony/src/Application/LLM/RetryCoordinator.php +++ b/backend-symfony/src/Application/LLM/RetryCoordinator.php @@ -81,7 +81,11 @@ public function execute(array $context, string $personaCode): array /** @var array $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 @@ -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); @@ -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 } @@ -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), diff --git a/backend-symfony/src/Application/Stix/StixObjectDeduplicator.php b/backend-symfony/src/Application/Stix/StixObjectDeduplicator.php new file mode 100644 index 00000000..03ff0eaa --- /dev/null +++ b/backend-symfony/src/Application/Stix/StixObjectDeduplicator.php @@ -0,0 +1,50 @@ +> $objects + * + * @return list> + */ + 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; + } +} diff --git a/backend-symfony/src/Application/Stix/ThreatActorStixBuilder.php b/backend-symfony/src/Application/Stix/ThreatActorStixBuilder.php index 08c179dd..776d6f77 100644 --- a/backend-symfony/src/Application/Stix/ThreatActorStixBuilder.php +++ b/backend-symfony/src/Application/Stix/ThreatActorStixBuilder.php @@ -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'; @@ -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' => [[ diff --git a/backend-symfony/src/Application/Stix/TtpAttackPatternBuilder.php b/backend-symfony/src/Application/Stix/TtpAttackPatternBuilder.php index 67f9aa4c..71bb366f 100644 --- a/backend-symfony/src/Application/Stix/TtpAttackPatternBuilder.php +++ b/backend-symfony/src/Application/Stix/TtpAttackPatternBuilder.php @@ -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 = [ diff --git a/backend-symfony/src/Application/Taxii/TaxiiService.php b/backend-symfony/src/Application/Taxii/TaxiiService.php index 1b6bb822..4f1f7149 100644 --- a/backend-symfony/src/Application/Taxii/TaxiiService.php +++ b/backend-symfony/src/Application/Taxii/TaxiiService.php @@ -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; @@ -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, @@ -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); } + + // 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'); } /** diff --git a/backend-symfony/src/Security/SecretPolicy.php b/backend-symfony/src/Security/SecretPolicy.php new file mode 100644 index 00000000..7fea6b2f --- /dev/null +++ b/backend-symfony/src/Security/SecretPolicy.php @@ -0,0 +1,126 @@ + + */ + 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 + */ + 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 $secrets variable name => value (null = absent, ignored) + * + * @return array 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; + } +} diff --git a/backend-symfony/src/UI/Console/CheckSecretsCommand.php b/backend-symfony/src/UI/Console/CheckSecretsCommand.php new file mode 100644 index 00000000..58ad64f9 --- /dev/null +++ b/backend-symfony/src/UI/Console/CheckSecretsCommand.php @@ -0,0 +1,105 @@ + + */ + private const CHECKED = [ + 'APP_SECRET', + 'TOTP_ENCRYPTION_KEY', + 'AUDIT_HMAC_KEY', + 'JWT_PASSPHRASE', + 'N8N_ENCRYPTION_KEY', + 'N8N_DEFAULT_USER_PASSWORD', + 'ADMIN_PASSWORD', + ]; + + public function __construct( + #[Autowire('%kernel.environment%')] + private readonly string $appEnv, + private readonly SecretPolicy $policy = new SecretPolicy(), + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $isProd = $this->appEnv === 'prod'; + + $secrets = []; + + foreach (self::CHECKED as $name) { + $secrets[$name] = $this->readEnv($name); + } + + $violations = $this->policy->validate($secrets, $isProd); + + if ($violations === []) { + $io->success($isProd + ? 'Secrets check passed: no known-default or weak values.' + : sprintf('Secrets check skipped (APP_ENV=%s, only enforced in prod).', $this->appEnv)); + + return Command::SUCCESS; + } + + $io->error('Refusing to boot: insecure secret values detected.'); + + foreach ($violations as $name => $reason) { + $io->writeln(sprintf(' - %s %s.', $name, $reason)); + } + $io->writeln('Generate strong values (e.g. openssl rand -hex 32) and set them before booting.'); + + return Command::FAILURE; + } + + /** + * Reads a variable from the process/Dotenv environment. Returns null when + * absent everywhere (presence is enforced by the entrypoint), and preserves an + * explicit empty string so the policy can flag it. + */ + private function readEnv(string $name): ?string + { + if (\array_key_exists($name, $_SERVER) && \is_string($_SERVER[$name])) { + return $_SERVER[$name]; + } + + if (\array_key_exists($name, $_ENV) && \is_string($_ENV[$name])) { + return $_ENV[$name]; + } + + $raw = getenv($name); + + return $raw === false ? null : $raw; + } +} diff --git a/backend-symfony/tests/Functional/Console/CheckSecretsCommandTest.php b/backend-symfony/tests/Functional/Console/CheckSecretsCommandTest.php new file mode 100644 index 00000000..b2d086ae --- /dev/null +++ b/backend-symfony/tests/Functional/Console/CheckSecretsCommandTest.php @@ -0,0 +1,98 @@ + original $_SERVER values to restore */ + private array $saved = []; + + private const VARS = [ + 'APP_SECRET', 'TOTP_ENCRYPTION_KEY', 'AUDIT_HMAC_KEY', 'JWT_PASSPHRASE', + 'N8N_ENCRYPTION_KEY', 'N8N_DEFAULT_USER_PASSWORD', 'ADMIN_PASSWORD', + ]; + + protected function setUp(): void + { + foreach (self::VARS as $v) { + $this->saved[$v] = $_SERVER[$v] ?? false; + unset($_SERVER[$v]); + } + } + + protected function tearDown(): void + { + foreach ($this->saved as $v => $orig) { + if ($orig === false) { + unset($_SERVER[$v]); + } else { + $_SERVER[$v] = $orig; + } + } + } + + private function tester(string $appEnv): CommandTester + { + return new CommandTester(new CheckSecretsCommand($appEnv, new SecretPolicy())); + } + + public function testFailsInProdOnPublishedDefault(): void + { + $_SERVER['APP_SECRET'] = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'; // .env.dist default + $_SERVER['TOTP_ENCRYPTION_KEY'] = bin2hex(random_bytes(32)); + + $tester = $this->tester('prod'); + $exit = $tester->execute([]); + + self::assertSame(1, $exit, 'boot must fail on a default secret'); + self::assertStringContainsString('APP_SECRET', $tester->getDisplay()); + } + + public function testFailsInProdOnRepeatedCharKey(): void + { + $_SERVER['AUDIT_HMAC_KEY'] = str_repeat('b', 64); + + $exit = $this->tester('prod')->execute([]); + + self::assertSame(1, $exit); + } + + public function testPassesInProdOnStrongValues(): void + { + $_SERVER['APP_SECRET'] = bin2hex(random_bytes(16)); + $_SERVER['TOTP_ENCRYPTION_KEY'] = bin2hex(random_bytes(32)); + $_SERVER['AUDIT_HMAC_KEY'] = bin2hex(random_bytes(32)); + $_SERVER['JWT_PASSPHRASE'] = bin2hex(random_bytes(16)); + $_SERVER['N8N_ENCRYPTION_KEY'] = bin2hex(random_bytes(32)); + $_SERVER['N8N_DEFAULT_USER_PASSWORD'] = bin2hex(random_bytes(12)); + + $exit = $this->tester('prod')->execute([]); + + self::assertSame(0, $exit, 'strong values must pass'); + } + + public function testSkipsOutsideProdEvenWithDefaults(): void + { + $_SERVER['APP_SECRET'] = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'; + $_SERVER['TOTP_ENCRYPTION_KEY'] = str_repeat('a', 64); + + $exit = $this->tester('dev')->execute([]); + + self::assertSame(0, $exit, 'dev/test must keep booting on defaults'); + } +} diff --git a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorAuditTest.php b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorAuditTest.php index cbcb9133..1e5cef46 100644 --- a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorAuditTest.php +++ b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorAuditTest.php @@ -150,7 +150,7 @@ public function testReplyRetryEmittedWhenPolicyGuardRejectsAttempt1(): void } // Validator - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; }); $audit = $this->createAuditLoggerSpy(); @@ -207,14 +207,14 @@ public function testReplyRetryEmittedWhenValidatorRejects(): void } if ($callCount === 2) { - return '{"approved":false,"naturalness":2,"persona_fit":3,"ti_value":2,"reasons":["too generic"],"fix_suggestion":"ask a specific question"}'; + return '{"security_pass":true,"approved":false,"naturalness":2,"persona_fit":3,"ti_value":2,"reasons":["too generic"],"fix_suggestion":"ask a specific question"}'; } if ($callCount === 3) { return $validReply; } - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; }); $audit = $this->createAuditLoggerSpy(); @@ -273,7 +273,7 @@ public function testNoRejectedEmissionWhenReplyIsApproved(): void return $callCount % 2 === 1 ? $validReply - : '{"approved":true,"naturalness":5,"persona_fit":5,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; + : '{"security_pass":true,"approved":true,"naturalness":5,"persona_fit":5,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; }); $audit = $this->createAuditLoggerSpy(); diff --git a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorMutationTest.php b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorMutationTest.php index 269e51c4..9a5898b5 100644 --- a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorMutationTest.php +++ b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorMutationTest.php @@ -1015,7 +1015,7 @@ public function test_trace_attempts_3_for_best_of_3(): void // === Validator exception falls through to best-of-3 === - public function test_validator_exception_uses_best_of_3(): void + public function test_validator_exception_on_all_attempts_falls_to_canned(): void { $validText = $this->validReplyText(); $callCount = 0; @@ -1031,10 +1031,11 @@ public function test_validator_exception_uses_best_of_3(): void $coordinator = $this->createCoordinator(); $result = $coordinator->execute($this->baseContext(), 'generic_user'); - // Policy approved the text, but validator threw exception - // Should fall through to best-of-3 - $this->assertFalse($result['fallback_used']); - $this->assertSame($validText, $result['text']); + // Policy approved the text, but the validator threw on every attempt, so + // security was never confirmed. Fail closed: the draft is not sent; the + // canned fallback fires instead of a security-unverified best-of-3. + $this->assertTrue($result['fallback_used']); + $this->assertNotSame($validText, $result['text']); } // === getModelName returns 'gpt-4o' in all paths === diff --git a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorTest.php b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorTest.php index 5af9c1a8..5904b95c 100644 --- a/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorTest.php +++ b/backend-symfony/tests/Unit/Application/LLM/RetryCoordinatorTest.php @@ -115,7 +115,7 @@ public function test_execute_succeeds_on_first_attempt(): void return $validReply; } // Validator: return approved JSON - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; }); $coordinator = $this->createCoordinator(); @@ -148,7 +148,7 @@ public function test_anchoring_evaluated_once_and_check_skipped_when_anchored(): $systemContent = $messages[0]['content'] ?? ''; if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; } return $activeText; @@ -173,7 +173,7 @@ public function test_preset_anchoring_flag_skips_judge_call(): void $systemContent = $messages[0]['content'] ?? ''; if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; } return $activeText; @@ -293,7 +293,7 @@ public function test_execute_uses_best_of_3_when_validator_rejects(): void $systemContent = $messages[0]['content'] ?? ''; // If it's the validator prompt, always reject if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'APPROVED') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":false,"naturalness":2,"persona_fit":2,"ti_value":2,"reasons":["Not natural enough"],"fix_suggestion":"Try harder"}'; + return '{"security_pass":true,"approved":false,"naturalness":2,"persona_fit":2,"ti_value":2,"reasons":["Not natural enough"],"fix_suggestion":"Try harder"}'; } // Generator: return valid text return $validText; @@ -308,6 +308,71 @@ public function test_execute_uses_best_of_3_when_validator_rejects(): void $this->assertSame(3, $result['attempts']); } + /** + * Fail-closed at the last layer: when the validator rejects a draft for a + * SECURITY reason (security_pass=false) on every attempt, the draft must + * never be emitted — not even as a best-of-3. The orchestrator falls to the + * canned fallback instead. + */ + public function test_security_reject_on_all_attempts_falls_to_canned_not_best_of_3(): void + { + $validText = 'Oh my, that sounds really interesting! I have been looking for exactly this kind of opportunity. ' . + 'Could you please tell me more about how this works? I would love to hear the details about your offer. ' . + 'My friend told me about something similar last week but I was not sure if it was real or not. ' . + 'Please send me more information when you can, I am very eager to learn more about this.'; + + $this->llmClient->method('chat')->willReturnCallback(function (array $messages) use ($validText) { + $systemContent = $messages[0]['content'] ?? ''; + // Validator: high quality but a SECURITY failure → approved=false for + // a security reason, on every attempt. + if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { + return '{"naturalness":4,"persona_fit":4,"ti_value":4,"security_pass":false,' + . '"security_reasoning":"reply leaks operational detail","reasons":["security"]}'; + } + + return $validText; + }); + + $coordinator = $this->createCoordinator(); + $result = $coordinator->execute($this->baseContext(), 'generic_user'); + + $this->assertTrue($result['fallback_used'], 'A security-rejected draft must never be sent — canned fallback fires.'); + $this->assertSame(3, $result['attempts']); + $this->assertNotSame($validText, $result['text'], 'The security-failing draft must not be the returned text.'); + } + + /** + * The complement: a draft rejected for QUALITY only (security_pass=true) is + * still eligible as a best-of-3 — availability is preserved for non-security + * rejections. + */ + public function test_quality_only_reject_still_uses_best_of_3(): void + { + $validText = 'Oh my, that sounds really interesting! I have been looking for exactly this kind of opportunity. ' . + 'Could you please tell me more about how this works? I would love to hear the details about your offer. ' . + 'My friend told me about something similar last week but I was not sure if it was real or not. ' . + 'Please send me more information when you can, I am very eager to learn more about this.'; + + $this->llmClient->method('chat')->willReturnCallback(function (array $messages) use ($validText) { + $systemContent = $messages[0]['content'] ?? ''; + // Validator: security clean, but low naturalness → approved=false for + // a quality reason only. + if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { + return '{"naturalness":1,"persona_fit":1,"ti_value":1,"security_pass":true,"reasons":["not natural"]}'; + } + + return $validText; + }); + + $coordinator = $this->createCoordinator(); + $result = $coordinator->execute($this->baseContext(), 'generic_user'); + + $this->assertTrue($result['approved']); + $this->assertFalse($result['fallback_used'], 'A quality-only rejection stays eligible as best-of-3.'); + $this->assertSame(3, $result['attempts']); + $this->assertSame($validText, $result['text']); + } + public function test_execute_handles_validator_exception(): void { $validText = 'Oh my, that sounds really interesting! I have been looking for this kind of opportunity for a long time now. ' . @@ -329,9 +394,10 @@ public function test_execute_handles_validator_exception(): void $coordinator = $this->createCoordinator(); $result = $coordinator->execute($this->baseContext(), 'generic_user'); - // Should still succeed with best-of-3 - $this->assertTrue($result['approved']); - $this->assertFalse($result['fallback_used']); + // Fail closed: the validator threw on every attempt, so security was never + // confirmed — no draft may be sent and the canned fallback fires. + $this->assertTrue($result['fallback_used']); + $this->assertNotSame($validText, $result['text']); } public function test_execute_with_leak_detection(): void @@ -349,7 +415,7 @@ public function test_execute_with_leak_detection(): void return '{"leak":true,"reason":"Contains platform reference","matched_terms":["orchestrator"]}'; } if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; } return $validText; }); @@ -431,7 +497,7 @@ public function test_low_ioc_score_triggers_retry(): void $systemContent = $messages[0]['content'] ?? ''; // Validator call: always approve with ti_value >= 3 if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":4,"reasons":["OK"],"fix_suggestion":""}'; } // Generator call: passive on attempt 1, active on attempt 2+ $generatorCallCount++; @@ -468,7 +534,7 @@ public function test_max_attempts_with_low_ioc_returns_anyway(): void $this->llmClient->method('chat')->willReturnCallback(function (array $messages) use ($passiveText) { $systemContent = $messages[0]['content'] ?? ''; if (str_contains($systemContent, 'naturalness') || str_contains($systemContent, 'persona_fit')) { - return '{"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; + return '{"security_pass":true,"approved":true,"naturalness":4,"persona_fit":4,"ti_value":3,"reasons":["OK"],"fix_suggestion":""}'; } return $passiveText; diff --git a/backend-symfony/tests/Unit/Application/Stix/StixOasisConformanceTest.php b/backend-symfony/tests/Unit/Application/Stix/StixOasisConformanceTest.php new file mode 100644 index 00000000..18eeeb1a --- /dev/null +++ b/backend-symfony/tests/Unit/Application/Stix/StixOasisConformanceTest.php @@ -0,0 +1,149 @@ +newInstanceWithoutConstructor(); + $m = new \ReflectionMethod(TaxiiService::class, 'formatIso8601'); + $out = $m->invoke($svc, $input); + + self::assertIsString($out); + self::assertStringEndsWith('Z', $out, 'timestamp must be UTC-Z, not an offset'); + self::assertStringNotContainsString('+00:00', $out); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $out); + + if ($expected !== '') { + self::assertSame($expected, $out); + } + } + + /** + * @return array + */ + public static function iso8601Cases(): array + { + return [ + 'utc offset -> Z' => ['2026-01-02T03:04:05+00:00', '2026-01-02T03:04:05.000Z'], + 'non-utc converted' => ['2026-01-02T03:04:05+02:00', '2026-01-02T01:04:05.000Z'], + 'empty -> now (shape)' => ['', ''], + ]; + } + + // ── D2: attack-pattern carries created/modified ───────────────────────── + + public function testThreatActorAttackPatternHasCreatedAndModified(): void + { + $patterns = (new ThreatActorStixBuilder())->buildAttackPatterns('T1566'); + + self::assertNotSame([], $patterns, 'T1566 is a known MITRE technique'); + $ap = $patterns[0]; + + self::assertArrayHasKey('created', $ap, 'attack-pattern must carry created (STIX 2.1)'); + self::assertArrayHasKey('modified', $ap, 'attack-pattern must carry modified (STIX 2.1)'); + self::assertMatchesRegularExpression('/Z$/', (string) $ap['created']); + self::assertMatchesRegularExpression('/Z$/', (string) $ap['modified']); + } + + // ── D3: stop_time > start_time (or omitted) ───────────────────────────── + + public function testUsesRelationshipOmitsStopTimeForSinglePointSighting(): void + { + $objects = (new TtpAttackPatternBuilder())->buildClusterTtpObjects( + [['code' => 'SB-T001', 'label' => 'Test TTP', 'first_seen' => '2026-01-01T00:00:00.000Z', 'last_seen' => '2026-01-01T00:00:00.000Z', 'count' => 1]], + 'threat-actor--00000000-0000-4000-8000-000000000001', + 'cluster-1', + '2026-01-02T00:00:00.000Z', + ); + + $uses = $this->firstUsesRelationship($objects); + self::assertNotNull($uses, 'a uses relationship must be present'); + self::assertArrayHasKey('start_time', $uses); + self::assertArrayNotHasKey('stop_time', $uses, 'stop_time must be omitted when it would equal start_time'); + } + + public function testUsesRelationshipKeepsStopTimeWhenStrictlyAfter(): void + { + $objects = (new TtpAttackPatternBuilder())->buildClusterTtpObjects( + [['code' => 'SB-T001', 'label' => 'Test TTP', 'first_seen' => '2026-01-01T00:00:00.000Z', 'last_seen' => '2026-01-05T00:00:00.000Z', 'count' => 3]], + 'threat-actor--00000000-0000-4000-8000-000000000001', + 'cluster-1', + '2026-01-06T00:00:00.000Z', + ); + + $uses = $this->firstUsesRelationship($objects); + self::assertNotNull($uses); + self::assertArrayHasKey('stop_time', $uses); + self::assertGreaterThan((string) $uses['start_time'], (string) $uses['stop_time']); + } + + /** + * @param list> $objects + * + * @return array|null + */ + private function firstUsesRelationship(array $objects): ?array + { + foreach ($objects as $o) { + if (($o['type'] ?? '') === 'relationship' && ($o['relationship_type'] ?? '') === 'uses') { + return $o; + } + } + + return null; + } + + // ── D4: bundle objects unique by id ───────────────────────────────────── + + public function testDedupeByIdKeepsFirstAndPreservesOrder(): void + { + $ext = ['type' => 'extension-definition', 'id' => 'extension-definition--aaaa']; + $in = [ + ['type' => 'threat-actor', 'id' => 'threat-actor--1'], + $ext, + ['type' => 'attack-pattern', 'id' => 'attack-pattern--x'], + $ext, // duplicate from another cluster + ['type' => 'attack-pattern', 'id' => 'attack-pattern--x'], // shared MITRE AP + ['type' => 'threat-actor', 'id' => 'threat-actor--2'], + ]; + + $out = StixObjectDeduplicator::dedupeById($in); + + $ids = array_map(static fn (array $o): string => (string) $o['id'], $out); + self::assertSame( + ['threat-actor--1', 'extension-definition--aaaa', 'attack-pattern--x', 'threat-actor--2'], + $ids, + ); + } + + public function testDedupeByIdPassesThroughEntriesWithoutId(): void + { + $in = [ + ['type' => 'marking-definition'], // no id key + ['type' => 'threat-actor', 'id' => 'threat-actor--1'], + ['type' => 'threat-actor', 'id' => 'threat-actor--1'], + ]; + + $out = StixObjectDeduplicator::dedupeById($in); + + self::assertCount(2, $out, 'id-less entries pass through; duplicate ids collapse'); + } +} diff --git a/backend-symfony/tests/Unit/Application/Stix/TtpAttackPatternBuilderTest.php b/backend-symfony/tests/Unit/Application/Stix/TtpAttackPatternBuilderTest.php index a5b17bb0..53bb721e 100644 --- a/backend-symfony/tests/Unit/Application/Stix/TtpAttackPatternBuilderTest.php +++ b/backend-symfony/tests/Unit/Application/Stix/TtpAttackPatternBuilderTest.php @@ -241,7 +241,7 @@ public function testSightingIdsAreCollisionFreeAcrossClustersAndCodes(): void self::assertCount(4, array_unique($ids), 'Each (cluster, code) pair must produce a distinct sighting id'); } - public function testStopTimeClampedWhenLastBeforeFirst(): void + public function testStopTimeOmittedWhenLastNotAfterFirst(): void { $objects = $this->builder->buildClusterTtpObjects( [['code' => 'SB-T001', 'label' => 'x', 'definition' => 'x', 'phase' => 'hook', 'external_refs' => [], 'count' => 1, 'first_seen' => '2026-06-05 18:00:00', 'last_seen' => '2026-06-01 10:00:00']], @@ -251,7 +251,11 @@ public function testStopTimeClampedWhenLastBeforeFirst(): void ); $byType = $this->groupByType($objects); - self::assertSame($byType['relationship'][0]['start_time'], $byType['relationship'][0]['stop_time']); + // STIX 2.1 requires stop_time > start_time when present. When last_seen is + // not strictly after first_seen, stop_time is omitted (start_time stays). + self::assertArrayHasKey('start_time', $byType['relationship'][0]); + self::assertArrayNotHasKey('stop_time', $byType['relationship'][0]); + // A sighting still clamps last_seen >= first_seen (equal is valid there). self::assertSame($byType['sighting'][0]['first_seen'], $byType['sighting'][0]['last_seen']); } diff --git a/backend-symfony/tests/Unit/Security/SecretPolicyTest.php b/backend-symfony/tests/Unit/Security/SecretPolicyTest.php new file mode 100644 index 00000000..d5a58eec --- /dev/null +++ b/backend-symfony/tests/Unit/Security/SecretPolicyTest.php @@ -0,0 +1,120 @@ +policy = new SecretPolicy(); + } + + /** + * @return array + */ + public static function envDistDefaults(): array + { + return [ + 'APP_SECRET' => ['APP_SECRET', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'], + 'TOTP_ENCRYPTION_KEY' => ['TOTP_ENCRYPTION_KEY', str_repeat('a', 64)], + 'AUDIT_HMAC_KEY' => ['AUDIT_HMAC_KEY', str_repeat('b', 64)], + 'N8N_ENCRYPTION_KEY' => ['N8N_ENCRYPTION_KEY', 'dev-only-change-in-production-openssl-rand-hex-32'], + 'N8N_DEFAULT_USER_PASSWORD' => ['N8N_DEFAULT_USER_PASSWORD', 'Scambuster2026!'], + 'ADMIN_PASSWORD' => ['ADMIN_PASSWORD', 'Un1que$trongPassword2024'], + ]; + } + + /** + * D1.1: every published .env.dist default (and the documented admin password) + * is a violation in prod. + * + * @dataProvider envDistDefaults + */ + public function testRejectsEachPublishedDefaultInProd(string $name, string $value): void + { + $violations = $this->policy->validate([$name => $value], isProd: true); + + self::assertArrayHasKey($name, $violations, sprintf('%s default must be rejected in prod', $name)); + self::assertNotSame('', $violations[$name], 'the violation must carry a human message'); + } + + /** + * D1.2: generic weak values are flagged even if not on the exact blocklist. + * + * @dataProvider weakValues + */ + public function testFlagsGenericWeakValuesInProd(string $value): void + { + $violations = $this->policy->validate(['APP_SECRET' => $value], isProd: true); + + self::assertArrayHasKey('APP_SECRET', $violations); + } + + /** + * @return array + */ + public static function weakValues(): array + { + return [ + 'single char repeated' => [str_repeat('a', 64)], + 'dev-only-change' => ['my-dev-only-change-me-later'], + 'change-in-production' => ['please-change-in-production'], + 'changeme' => ['changeme'], + 'your- placeholder' => ['your-secret-here'], + 'empty' => [''], + ]; + } + + /** + * D1.3: strong distinct random values pass. + */ + public function testStrongValuesProduceNoViolationInProd(): void + { + $secrets = [ + 'APP_SECRET' => bin2hex(random_bytes(16)), + 'TOTP_ENCRYPTION_KEY' => bin2hex(random_bytes(32)), + 'AUDIT_HMAC_KEY' => bin2hex(random_bytes(32)), + 'N8N_ENCRYPTION_KEY' => bin2hex(random_bytes(32)), + ]; + + self::assertSame([], $this->policy->validate($secrets, isProd: true)); + } + + /** + * D1.4: outside prod the policy is a no-op — dev/test/e2e keep booting on defaults. + */ + public function testIsNoOpOutsideProd(): void + { + $secrets = [ + 'APP_SECRET' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', + 'TOTP_ENCRYPTION_KEY' => str_repeat('a', 64), + 'AUDIT_HMAC_KEY' => str_repeat('b', 64), + ]; + + self::assertSame([], $this->policy->validate($secrets, isProd: false)); + } + + /** + * A variable that is absent (null) is not the policy's concern — presence is + * enforced elsewhere (the entrypoint's `:?` guards). The policy only judges + * values it is given. + */ + public function testIgnoresNullValues(): void + { + self::assertSame([], $this->policy->validate(['APP_SECRET' => null], isProd: true)); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1ec7f686..6000c3e3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,8 +28,10 @@ services: depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } + # Published on loopback only: a reverse proxy must terminate TLS and front this + # port for any non-local access. Override APP_BIND_HOST only behind a firewall. ports: - - "${APP_PORT:-8080}:8080" + - "${APP_BIND_HOST:-127.0.0.1}:${APP_PORT:-8080}:8080" healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"] interval: 30s @@ -65,8 +67,9 @@ services: n8n: image: n8nio/n8n:1.114.3 restart: unless-stopped - # Root so the init script can fix data-dir perms; n8n itself runs via 'su node'. - user: root + # Runs as the image's non-root node user (uid 1000). The named volume inherits + # node ownership of /home/node/.n8n from the image, so no root chown is needed. + user: "node" environment: - N8N_HOST=${N8N_HOST:-localhost} - N8N_PORT=5678 @@ -85,9 +88,10 @@ services: - N8N_DEFAULT_USER_PASSWORD=${N8N_DEFAULT_USER_PASSWORD} - NODE_FUNCTION_ALLOW_BUILTIN=crypto entrypoint: ["/bin/sh", "/home/node/n8n-init.sh"] - # n8n admin UI: keep it behind the reverse proxy / firewalled; publish only if needed. + # n8n admin UI (arbitrary workflow / JS execution): published on loopback only, + # must stay behind the reverse proxy / firewall. Override N8N_BIND_HOST with care. ports: - - "${N8N_HTTP_PORT:-5678}:5678" + - "${N8N_BIND_HOST:-127.0.0.1}:${N8N_HTTP_PORT:-5678}:5678" volumes: - prod-n8ndata:/home/node/.n8n # Init assets (workflow definitions + bootstrap script), read-only. Not app source. diff --git a/infra/docker/backend/docker-entrypoint-prod.sh b/infra/docker/backend/docker-entrypoint-prod.sh index a1756f66..fdd35f9b 100644 --- a/infra/docker/backend/docker-entrypoint-prod.sh +++ b/infra/docker/backend/docker-entrypoint-prod.sh @@ -22,6 +22,12 @@ cd /app # Symfony's console reads /app/.env at bootstrap — write it from the environment. sh /opt/write-prod-env.sh +# ── Reject known-default / weak secrets (fail fast, before touching the DB) ─── +# Presence is checked above; this rejects values still equal to the published +# .env.dist defaults (APP_SECRET, TOTP/AUDIT keys, ADMIN_PASSWORD, …). Delegates +# to tested PHP (App\Security\SecretPolicy). +php bin/console app:security:check-secrets + # ── Wait for PostgreSQL (TCP) ──────────────────────────────────────────────── pg_host=$(printf '%s' "$DATABASE_URL" | sed -E 's#^[a-z+]+://[^@]*@([^:/]+).*#\1#') pg_port=$(printf '%s' "$DATABASE_URL" | sed -nE 's#^[a-z+]+://[^@]*@[^:]+:([0-9]+).*#\1#p'); pg_port=${pg_port:-5432} @@ -77,13 +83,28 @@ if [ "${FRESH_INSTALL}" = "1" ]; then && echo "[prod] honeypot mailbox registered: ${HONEYPOT_IMAP_USER}" \ || echo "[prod] (mailbox auto-register skipped — add later with app:mail-account:add)" fi - echo "" - echo " ####################################################################" - echo " # SECURITY: default admin seeded with the PUBLIC default password. #" - echo " # Log in as user@example.com and CHANGE THE PASSWORD before you #" - echo " # expose this instance (or seed your own user and delete this one).#" - echo " ####################################################################" - echo "" + + # Create the initial admin. No predictable password ever reaches prod: + # ADMIN_PASSWORD if provided (already validated by app:security:check-secrets + # above), otherwise a strong generated one printed ONCE below. + admin_email="${ADMIN_EMAIL:-admin@example.com}" + echo "[prod] creating initial admin ${admin_email} ..." + if [ -n "${ADMIN_PASSWORD:-}" ]; then + php bin/console app:user:create --email="${admin_email}" \ + --password="${ADMIN_PASSWORD}" --admin --no-interaction + echo "[prod] admin ${admin_email} created with ADMIN_PASSWORD." + else + echo "" + echo " ####################################################################" + echo " # No ADMIN_PASSWORD set — generating a one-time admin password. #" + echo " # Copy it now; it is shown ONCE and cannot be recovered. #" + echo " ####################################################################" + # --generate prints "Generated password: …" exactly once. + php bin/console app:user:create --email="${admin_email}" \ + --generate --admin --no-interaction + echo " (change it after first login, or seed your own user.)" + echo "" + fi fi # ── Warm cache + hand ownership to the runtime user ────────────────────────── diff --git a/infra/docker/backend/prod-seed-reference.sql b/infra/docker/backend/prod-seed-reference.sql index 332e3645..1c5521cd 100644 --- a/infra/docker/backend/prod-seed-reference.sql +++ b/infra/docker/backend/prod-seed-reference.sql @@ -126,11 +126,8 @@ SELECT st.scam_type_id, p.persona_id FROM lkp_scam_type st, persona p WHERE st.code = 'UNKNOWN' AND p.persona_code IN ('generic_user') ON CONFLICT (scam_type_id, persona_id) DO NOTHING; --- ── Default admin login. PUBLIC default password — CHANGE IT after first login. --- bcrypt(cost 13) of the documented default password. ROLE_ADMIN grants all --- permissions implicitly, so the permissions column keeps its '[]' default. ─ -INSERT INTO app_users (id, email, password_hash, roles) -SELECT gen_random_uuid(), 'user@example.com', - '$2y$13$.ZKFmSNj6jfhxtImiOHucu45qmOodpzMT/Mq2PwWX5rkLayygMMZG', - '["ROLE_ADMIN"]'::json -WHERE NOT EXISTS (SELECT 1 FROM app_users WHERE email = 'user@example.com'); +-- ── Default admin login is intentionally NOT seeded here. ──────────────────── +-- Seeding a fixed bcrypt of the documented public password would give every +-- fresh prod instance the same known admin credentials. The +-- entrypoint now creates the admin on a fresh install with ADMIN_PASSWORD, or +-- a generated password printed once — see docker-entrypoint-prod.sh. diff --git a/n8n/n8n-init.sh b/n8n/n8n-init.sh index 6494517a..9c453b3d 100644 --- a/n8n/n8n-init.sh +++ b/n8n/n8n-init.sh @@ -111,16 +111,16 @@ http_check() { fi } -# ─── 0. Ensure data directory is writable ─── -# The container runs as root (for permission fixes), then drops to user "node" for n8n. -# On fresh installs, Docker creates the bind mount dir as root — n8n (node) can't write. -log "Ensuring /home/node/.n8n is writable by node user..." +# ─── 0. Ensure data directory exists ─── +# This runs as the non-root node user (uid 1000). The n8n image ships +# /home/node/.n8n owned by node, so the named volume inherits that ownership — +# no privileged chown is needed. +log "Ensuring /home/node/.n8n exists..." mkdir -p /home/node/.n8n -chown -R 1000:1000 /home/node/.n8n -# ─── 1. Start n8n in background as user "node" ─── -log "Starting n8n in background (as user node)..." -su -s /bin/sh node -c "n8n start" & +# ─── 1. Start n8n in background (already running as node) ─── +log "Starting n8n in background..." +n8n start & N8N_PID=$! # Relay Docker shutdown signals to n8n @@ -306,8 +306,9 @@ if [ -d "$INIT_DIR" ] && [ "$(ls -1 "$INIT_DIR"/*.json 2>/dev/null | wc -l)" -gt err " Failed to import (API): $wf_name" fi else - # Fallback: CLI import (works without auth but workflows may not be visible to admin) - if su -s /bin/sh node -c "n8n import:workflow --input='$wf_file'" 2>/dev/null; then + # Fallback: CLI import (works without auth but workflows may not be visible to admin). + # Runs as the node user directly (the container is no longer root). + if n8n import:workflow --input="$wf_file" 2>/dev/null; then log " Imported (CLI): $wf_name" IMPORTED=$((IMPORTED + 1)) else