From 357f4ffaf263cfbcef5c31152024a00facba0b59 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 22:30:07 +0000
Subject: [PATCH 1/5] =?UTF-8?q?feat:=20cloud=20integration=20=E2=80=94=20W?=
=?UTF-8?q?AF=20v2,=20Secrets=20Manager,=20CloudTrail,=20DynamoDB=20PITR,?=
=?UTF-8?q?=20SQS=20DLQ=20alarms?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/Panacota96/master-Project-Phishing/sessions/3af16f29-8959-43b5-a275-793626e09aef
Co-authored-by: Panacota96 <118935424+Panacota96@users.noreply.github.com>
---
CHANGELOG.md | 21 ++++
VERSION | 2 +-
config.py | 59 ++++++++-
documentation/ARCHITECTURE.md | 44 +++++--
.../terraform/cloudfront.tf | 1 +
.../terraform/cloudtrail.tf | 116 ++++++++++++++++++
.../terraform/cloudwatch_monitoring.tf | 34 +++++
phishing-platform-infra/terraform/dynamodb.tf | 48 ++++++++
phishing-platform-infra/terraform/iam.tf | 17 +++
phishing-platform-infra/terraform/lambda.tf | 2 +-
phishing-platform-infra/terraform/outputs.tf | 10 ++
.../terraform/secrets_manager.tf | 30 +++++
.../terraform/terraform.tfvars.example | 4 +
.../terraform/variables.tf | 12 ++
phishing-platform-infra/terraform/waf.tf | 101 +++++++++++++++
15 files changed, 489 insertions(+), 12 deletions(-)
create mode 100644 phishing-platform-infra/terraform/cloudtrail.tf
create mode 100644 phishing-platform-infra/terraform/secrets_manager.tf
create mode 100644 phishing-platform-infra/terraform/waf.tf
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32d3d3e..c3717e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## [1.3.0] - 2026-04-04
+
+### Added
+- **AWS WAF v2** (`waf.tf`): CloudFront-scoped Web ACL (us-east-1) with `AWSManagedRulesCommonRuleSet`, `AWSManagedRulesKnownBadInputsRuleSet`, and a per-IP rate-based rule (300 requests / 5 minutes). Controlled by `enable_waf` variable (default `true`).
+- **AWS Secrets Manager** (`secrets_manager.tf`): Flask `SECRET_KEY` and `MSAL_CLIENT_SECRET` are now stored in a Secrets Manager secret (`{prefix}/app-secrets`). The Lambda receives `SECRET_ARN` instead of the plaintext values, preventing credentials from appearing in the AWS console environment variables view.
+- **AWS CloudTrail** (`cloudtrail.tf`): Multi-region trail writing to a dedicated AES256-encrypted S3 bucket with log-file validation enabled and S3 data-event logging for the application bucket. Controlled by `enable_cloudtrail` variable (default `true`).
+- **DynamoDB PITR**: Point-in-Time Recovery enabled on all 11 DynamoDB tables for a 35-day recovery window.
+- **SQS DLQ alarms**: Two new CloudWatch alarms (`{prefix}-registration-dlq-depth` and `{prefix}-campaign-dlq-depth`) fire on the SNS alerts topic when either dead-letter queue accumulates ≥ 1 message.
+- **`config.py` Secrets Manager resolver**: `_resolve_secret_key()` and `_resolve_msal_client_secret()` fetch credentials from Secrets Manager when `SECRET_ARN` is set, falling back to the `SECRET_KEY` / `MSAL_CLIENT_SECRET` environment variables for local development and unit tests.
+- **Terraform variables**: `enable_waf` (bool, default `true`) and `enable_cloudtrail` (bool, default `true`) added to `variables.tf`.
+- **Terraform outputs**: `waf_arn`, `app_secrets_arn`, `secrets_manager_arn`, and `cloudtrail_bucket` added to `outputs.tf`.
+
+### Changed
+- `lambda.tf`: Lambda env var `SECRET_KEY` replaced with `SECRET_ARN`; `MSAL_CLIENT_SECRET` removed from plaintext env vars (now fetched at runtime from Secrets Manager).
+- `iam.tf`: Lambda execution role now includes `secretsmanager:GetSecretValue` on the application secrets ARN.
+- `cloudfront.tf`: `web_acl_id` now references the WAF Web ACL ARN when `enable_waf = true`.
+- `terraform.tfvars.example`: Documents `enable_waf` and `enable_cloudtrail` optional overrides.
+- `documentation/ARCHITECTURE.md`: Updated System Overview and AWS Infrastructure diagrams to show WAF, Secrets Manager, CloudTrail, SQS DLQs for both queues, 11 DynamoDB tables, and 8 CloudWatch alarms.
+
+---
+
## [1.2.5] - 2026-04-04
### Added
diff --git a/VERSION b/VERSION
index 0495c4a..f0bb29e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.2.3
+1.3.0
diff --git a/config.py b/config.py
index d8876c3..96d9993 100644
--- a/config.py
+++ b/config.py
@@ -1,8 +1,63 @@
+import json
+import logging
import os
+logger = logging.getLogger(__name__)
+
+
+def _resolve_secret_key() -> str:
+ """Return the Flask SECRET_KEY.
+
+ When running on AWS Lambda the ``SECRET_ARN`` environment variable points
+ to a Secrets Manager secret that holds the key as a JSON object
+ ``{"SECRET_KEY": "..."}`` alongside other credentials. We fetch the value
+ at startup so the plaintext key is never stored as a Lambda environment
+ variable (where it is visible in the AWS console).
+
+ Falls back to the ``SECRET_KEY`` environment variable (or the insecure
+ development default) when ``SECRET_ARN`` is absent — this preserves
+ backward-compatibility for local development and unit tests.
+ """
+ secret_arn = os.environ.get("SECRET_ARN", "")
+ if secret_arn:
+ try:
+ import boto3
+ from botocore.exceptions import BotoCoreError, ClientError
+
+ region = os.environ.get("AWS_REGION_NAME", "eu-west-3")
+ client = boto3.client("secretsmanager", region_name=region)
+ resp = client.get_secret_value(SecretId=secret_arn)
+ data = json.loads(resp["SecretString"])
+ return data.get("SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production"))
+ except (ClientError, BotoCoreError) as exc:
+ logger.warning("Secrets Manager fetch failed (%s); falling back to SECRET_KEY env var", exc)
+ except (json.JSONDecodeError, KeyError) as exc:
+ logger.warning("Secrets Manager secret parse error (%s); falling back to SECRET_KEY env var", exc)
+ return os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")
+
+
+def _resolve_msal_client_secret() -> str:
+ """Return the MSAL client secret from Secrets Manager or env var."""
+ secret_arn = os.environ.get("SECRET_ARN", "")
+ if secret_arn:
+ try:
+ import boto3
+ from botocore.exceptions import BotoCoreError, ClientError
+
+ region = os.environ.get("AWS_REGION_NAME", "eu-west-3")
+ client = boto3.client("secretsmanager", region_name=region)
+ resp = client.get_secret_value(SecretId=secret_arn)
+ data = json.loads(resp["SecretString"])
+ return data.get("MSAL_CLIENT_SECRET", os.environ.get("MSAL_CLIENT_SECRET", ""))
+ except (ClientError, BotoCoreError) as exc:
+ logger.warning("Secrets Manager fetch failed (%s); falling back to MSAL_CLIENT_SECRET env var", exc)
+ except (json.JSONDecodeError, KeyError) as exc:
+ logger.warning("Secrets Manager secret parse error (%s); falling back to MSAL_CLIENT_SECRET env var", exc)
+ return os.environ.get("MSAL_CLIENT_SECRET", "")
+
class Config:
- SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
+ SECRET_KEY = _resolve_secret_key()
# Flask-WTF CSRF — allow requests through CloudFront where the Referer
# header is the CloudFront domain, not the API Gateway origin.
@@ -54,7 +109,7 @@ class Config:
# Set all three variables to enable the "Sign in with Microsoft" button.
# Leave them empty (default) to disable SSO entirely.
MSAL_CLIENT_ID = os.environ.get('MSAL_CLIENT_ID', '')
- MSAL_CLIENT_SECRET = os.environ.get('MSAL_CLIENT_SECRET', '')
+ MSAL_CLIENT_SECRET = _resolve_msal_client_secret()
# e.g. "https://login.microsoftonline.com//v2.0" or
# "https://login.microsoftonline.com/common/v2.0" for multi-tenant.
MSAL_AUTHORITY = os.environ.get(
diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md
index b4461a4..327e3ce 100644
--- a/documentation/ARCHITECTURE.md
+++ b/documentation/ARCHITECTURE.md
@@ -31,31 +31,37 @@ graph TB
end
subgraph AWS["AWS — eu-west-3"]
+ WAF["WAF v2\nOWASP rules + rate limiting"]
CF["CloudFront\nCDN + HTTPS"]
APIGW["API Gateway v2\nHTTP API"]
LambdaApp["Lambda Flask App\nPython 3.12 · 512 MB"]
LambdaWorker["Lambda Registration Worker\nPython 3.12 · 256 MB"]
- DDB[("DynamoDB\n9 Tables")]
+ DDB[("DynamoDB\n11 Tables + PITR")]
S3[("S3 Bucket\nEML + Videos + Reports")]
SQS["SQS Queue\nRegistration"]
SES["SES\nEmail"]
SNS["SNS\nAlerts + Events"]
CW["CloudWatch\nAlarms + Dashboard + X-Ray"]
+ SM["Secrets Manager\nSECRET_KEY + MSAL"]
+ CT["CloudTrail\nAudit Log"]
end
- Student -->|HTTPS| CF
- Admin -->|HTTPS| CF
+ Student -->|HTTPS| WAF
+ Admin -->|HTTPS| WAF
+ WAF --> CF
CF --> APIGW
APIGW --> LambdaApp
LambdaApp --> DDB
LambdaApp --> S3
LambdaApp --> SQS
+ LambdaApp --> SM
SQS --> LambdaWorker
LambdaWorker --> DDB
LambdaWorker --> SES
LambdaWorker --> SNS
LambdaApp --> CW
SNS --> CW
+ CT --> S3
GHA -->|Terraform apply| LambdaApp
GHA -->|S3 sync| S3
GHA -->|seed_dynamodb.py| DDB
@@ -73,6 +79,9 @@ graph TB



+
+
+
All resources grouped by AWS service with connection direction.
@@ -83,8 +92,12 @@ graph LR
ACM["ACM Cert\nus-east-1"]
end
+ subgraph Security["WAF v2 (us-east-1)"]
+ WAF["Web ACL\nAWSManagedRulesCommonRuleSet\nAWSManagedRulesKnownBadInputsRuleSet\nRate limit: 300 req/5 min per IP"]
+ end
+
subgraph CDN["CloudFront"]
- CF["Distribution\nTTL=0 · Compress\nredirect-to-https"]
+ CF["Distribution\nTTL=0 · Compress\nredirect-to-https · WAF attached"]
end
subgraph APIGW_["API Gateway v2"]
@@ -98,9 +111,10 @@ graph LR
subgraph Storage["S3"]
S3["phishing-app-{env}-eu-west-3\nVersioned · AES256\nPublic read: videos/* (dev only)\nPrivate: eml-samples/ reports/"]
+ S3Trail["phishing-app-{env}-cloudtrail-{acct}\nAES256 · Public access blocked"]
end
- subgraph DB["DynamoDB — 9 tables (PAY_PER_REQUEST)"]
+ subgraph DB["DynamoDB — 11 tables (PAY_PER_REQUEST + PITR)"]
TUsers["users\nPK: username\nGSI: email-index, group-index"]
TQuizzes["quizzes\nPK: quiz_id"]
TAttempts["attempts\nPK: username+quiz_id\nGSI: quiz-index, group-index"]
@@ -110,11 +124,15 @@ graph LR
TBugs["bugs\nPK: bug_id"]
TAnswerKey["answer-key-overrides\nPK: email_file"]
TCohort["cohort-tokens\nPK: token · TTL: expires_at (90 days)"]
+ TThreatCache["threat-cache\nPK: cache_key · TTL: ttl"]
+ TCampaigns["campaigns\nPK: campaign_id · GSI: cohort-index"]
end
subgraph Async["SQS + SES + SNS"]
SQS["Registration Queue\nDLQ · SSE · 60s visibility · 1-day retention"]
DLQQ["Registration DLQ\n14-day retention · maxReceiveCount=4"]
+ CampaignQ["Campaign Queue\nDLQ · SSE · 300s visibility · 7-day retention"]
+ CampaignDLQ["Campaign DLQ\n14-day retention"]
SES["SES Email Identity\nno-reply@..."]
SNSReg["SNS Registration Topic\nFuture fan-out"]
SNSAlerts["SNS Alerts Topic\n+ Email subscription (optional)"]
@@ -122,12 +140,17 @@ graph LR
subgraph Observe["CloudWatch"]
CWLogs["Log Groups\n/aws/lambda/{app,worker} · /aws/apigateway/*\n14-day retention"]
- CWAlarms["6 Alarms\nLambda errors(>=5)/duration-p95(>=25s)/throttles(>=1)\nAPI GW 4xx(>=50)/5xx(>=3) · DynamoDB SystemErrors(>=1)"]
+ CWAlarms["8 Alarms\nLambda errors/duration-p95/throttles\nAPI GW 4xx/5xx · DynamoDB SystemErrors\nRegistration DLQ depth · Campaign DLQ depth"]
CWDash["Dashboard\nphishing-app-{env}-overview\n3 rows: Lambda · API GW · DynamoDB"]
end
+ subgraph SecretsAudit["Secrets + Audit"]
+ SM["Secrets Manager\n{prefix}/app-secrets\nSECRET_KEY + MSAL_CLIENT_SECRET"]
+ CT["CloudTrail\n{prefix}-trail · multi-region\nS3 data events for app bucket"]
+ end
+
subgraph IAM_["IAM"]
- RoleLambda["phishing-app-{env}-lambda-role\nDynamoDB (9 tables+GSIs) · S3 · SQS:SendMessage · X-Ray"]
+ RoleLambda["phishing-app-{env}-lambda-role\nDynamoDB (11 tables+GSIs) · S3 · SQS:SendMessage\nX-Ray · secretsmanager:GetSecretValue"]
RoleWorker["phishing-app-{env}-registration-worker-role\nDynamoDB:users · SES:SendEmail · SQS:Receive+Delete · SNS:Publish"]
RoleGHA["phishing-app-{env}-github-actions-deploy\nOIDC · Lambda · IAM · DynamoDB · S3 · API GW\nCloudFront · CloudWatch · SNS · SQS · SES · X-Ray · ACM · Route53"]
OIDC["OIDC Provider\ntoken.actions.githubusercontent.com\nrepo: Panacota96/master-Project-Phishing:*"]
@@ -135,13 +158,17 @@ graph LR
R53 --> CF
ACM --> CF
+ WAF --> CF
CF --> APIGW
APIGW --> LApp
- LApp --> TUsers & TQuizzes & TAttempts & TResponses & TInspector & TInspectorAnon & TBugs & TAnswerKey & TCohort
+ LApp --> TUsers & TQuizzes & TAttempts & TResponses & TInspector & TInspectorAnon & TBugs & TAnswerKey & TCohort & TThreatCache & TCampaigns
LApp --> S3
LApp --> SQS
+ LApp --> CampaignQ
+ LApp --> SM
SQS --> LWorker
SQS --> DLQQ
+ CampaignQ --> CampaignDLQ
LWorker --> TUsers
LWorker --> SES
LWorker --> SNSReg
@@ -149,6 +176,7 @@ graph LR
LApp --> CWLogs
LWorker --> CWLogs
APIGW --> CWLogs
+ CT --> S3Trail
RoleLambda --> LApp
RoleWorker --> LWorker
OIDC --> RoleGHA
diff --git a/phishing-platform-infra/terraform/cloudfront.tf b/phishing-platform-infra/terraform/cloudfront.tf
index c9a8220..c9a201e 100644
--- a/phishing-platform-infra/terraform/cloudfront.tf
+++ b/phishing-platform-infra/terraform/cloudfront.tf
@@ -7,6 +7,7 @@ resource "aws_cloudfront_distribution" "app" {
comment = "${local.prefix} – stable login URL"
default_root_object = ""
aliases = var.domain_name != "" ? [var.domain_name] : []
+ web_acl_id = var.enable_waf ? aws_wafv2_web_acl.app[0].arn : null
origin {
domain_name = replace(aws_apigatewayv2_api.app.api_endpoint, "https://", "")
diff --git a/phishing-platform-infra/terraform/cloudtrail.tf b/phishing-platform-infra/terraform/cloudtrail.tf
new file mode 100644
index 0000000..c1c800a
--- /dev/null
+++ b/phishing-platform-infra/terraform/cloudtrail.tf
@@ -0,0 +1,116 @@
+# ─── CloudTrail — Audit Logging ──────────────────────────────────────────────
+# Multi-region trail capturing all management events and S3 data events
+# for the application bucket. Logs go to a dedicated encrypted S3 bucket.
+
+data "aws_caller_identity" "current" {}
+
+# ─── Dedicated S3 Bucket for Trail Logs ──────────────────────────────────────
+
+resource "aws_s3_bucket" "cloudtrail" {
+ count = var.enable_cloudtrail ? 1 : 0
+ bucket = "${local.prefix}-cloudtrail-${data.aws_caller_identity.current.account_id}"
+ force_destroy = true
+
+ tags = {
+ Name = "${local.prefix}-cloudtrail"
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" {
+ count = var.enable_cloudtrail ? 1 : 0
+ bucket = aws_s3_bucket.cloudtrail[0].id
+
+ rule {
+ apply_server_side_encryption_by_default {
+ sse_algorithm = "AES256"
+ }
+ }
+}
+
+resource "aws_s3_bucket_public_access_block" "cloudtrail" {
+ count = var.enable_cloudtrail ? 1 : 0
+ bucket = aws_s3_bucket.cloudtrail[0].id
+
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_policy" "cloudtrail" {
+ count = var.enable_cloudtrail ? 1 : 0
+ bucket = aws_s3_bucket.cloudtrail[0].id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "AWSCloudTrailAclCheck"
+ Effect = "Allow"
+ Principal = {
+ Service = "cloudtrail.amazonaws.com"
+ }
+ Action = "s3:GetBucketAcl"
+ Resource = aws_s3_bucket.cloudtrail[0].arn
+ Condition = {
+ StringEquals = {
+ "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.prefix}-trail"
+ }
+ }
+ },
+ {
+ Sid = "AWSCloudTrailWrite"
+ Effect = "Allow"
+ Principal = {
+ Service = "cloudtrail.amazonaws.com"
+ }
+ Action = "s3:PutObject"
+ Resource = "${aws_s3_bucket.cloudtrail[0].arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
+ Condition = {
+ StringEquals = {
+ "s3:x-amz-acl" = "bucket-owner-full-control"
+ "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.prefix}-trail"
+ }
+ }
+ }
+ ]
+ })
+
+ depends_on = [aws_s3_bucket_public_access_block.cloudtrail]
+}
+
+# ─── CloudTrail Trail ─────────────────────────────────────────────────────────
+
+resource "aws_cloudtrail" "app" {
+ count = var.enable_cloudtrail ? 1 : 0
+ name = "${local.prefix}-trail"
+
+ s3_bucket_name = aws_s3_bucket.cloudtrail[0].id
+ include_global_service_events = true
+ is_multi_region_trail = true
+ enable_log_file_validation = true
+
+ # Capture all S3 object-level events on the application bucket
+ event_selector {
+ read_write_type = "All"
+ include_management_events = true
+
+ data_resource {
+ type = "AWS::S3::Object"
+ values = ["${aws_s3_bucket.app.arn}/"]
+ }
+ }
+
+ tags = {
+ Name = "${local.prefix}-trail"
+ }
+
+ depends_on = [aws_s3_bucket_policy.cloudtrail]
+}
+
+# ─── Outputs ──────────────────────────────────────────────────────────────────
+
+output "cloudtrail_bucket" {
+ description = "S3 bucket name for CloudTrail logs (empty if CloudTrail disabled)"
+ value = var.enable_cloudtrail ? aws_s3_bucket.cloudtrail[0].id : ""
+}
diff --git a/phishing-platform-infra/terraform/cloudwatch_monitoring.tf b/phishing-platform-infra/terraform/cloudwatch_monitoring.tf
index 243c72c..af69633 100644
--- a/phishing-platform-infra/terraform/cloudwatch_monitoring.tf
+++ b/phishing-platform-infra/terraform/cloudwatch_monitoring.tf
@@ -112,6 +112,40 @@ resource "aws_cloudwatch_metric_alarm" "dynamodb_system_errors" {
ok_actions = [aws_sns_topic.alerts.arn]
}
+# ─── SQS Dead-Letter Queue Alarms ─────────────────────────────────────────────
+
+resource "aws_cloudwatch_metric_alarm" "registration_dlq_depth" {
+ alarm_name = "${local.prefix}-registration-dlq-depth"
+ alarm_description = "Messages in registration DLQ >= 1 — review failed registration events"
+ namespace = "AWS/SQS"
+ metric_name = "ApproximateNumberOfMessagesVisible"
+ dimensions = { QueueName = aws_sqs_queue.registration_dlq.name }
+ statistic = "Sum"
+ period = 300
+ evaluation_periods = 1
+ threshold = 1
+ comparison_operator = "GreaterThanOrEqualToThreshold"
+ treat_missing_data = "notBreaching"
+ alarm_actions = [aws_sns_topic.alerts.arn]
+ ok_actions = [aws_sns_topic.alerts.arn]
+}
+
+resource "aws_cloudwatch_metric_alarm" "campaign_dlq_depth" {
+ alarm_name = "${local.prefix}-campaign-dlq-depth"
+ alarm_description = "Messages in campaign DLQ >= 1 — review failed campaign-mailer events"
+ namespace = "AWS/SQS"
+ metric_name = "ApproximateNumberOfMessagesVisible"
+ dimensions = { QueueName = aws_sqs_queue.campaign_dlq.name }
+ statistic = "Sum"
+ period = 300
+ evaluation_periods = 1
+ threshold = 1
+ comparison_operator = "GreaterThanOrEqualToThreshold"
+ treat_missing_data = "notBreaching"
+ alarm_actions = [aws_sns_topic.alerts.arn]
+ ok_actions = [aws_sns_topic.alerts.arn]
+}
+
# ─── CloudWatch Dashboard ─────────────────────────────────────────────────────
resource "aws_cloudwatch_dashboard" "overview" {
diff --git a/phishing-platform-infra/terraform/dynamodb.tf b/phishing-platform-infra/terraform/dynamodb.tf
index 76825f1..00fbcb6 100644
--- a/phishing-platform-infra/terraform/dynamodb.tf
+++ b/phishing-platform-infra/terraform/dynamodb.tf
@@ -32,6 +32,10 @@ resource "aws_dynamodb_table" "users" {
range_key = "username"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Quizzes Table ────────────────────────────────────────────────────────────
@@ -45,6 +49,10 @@ resource "aws_dynamodb_table" "quizzes" {
name = "quiz_id"
type = "S"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Attempts Table ───────────────────────────────────────────────────────────
@@ -88,6 +96,10 @@ resource "aws_dynamodb_table" "attempts" {
range_key = "completed_at"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Responses Table ──────────────────────────────────────────────────────────
@@ -124,6 +136,10 @@ resource "aws_dynamodb_table" "responses" {
range_key = "username"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Inspector Attempts Table ────────────────────────────────────────────────
@@ -167,6 +183,10 @@ resource "aws_dynamodb_table" "inspector_attempts" {
range_key = "submitted_at"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Inspector Attempts Table (Anonymous) ────────────────────────────────────
@@ -186,6 +206,10 @@ resource "aws_dynamodb_table" "inspector_attempts_anon" {
name = "submitted_at"
type = "S"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Bugs Table ────────────────────────────────────────────────────────────────
@@ -199,6 +223,10 @@ resource "aws_dynamodb_table" "bugs" {
name = "bug_id"
type = "S"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Answer Key Overrides Table ───────────────────────────────────────────────
@@ -212,6 +240,10 @@ resource "aws_dynamodb_table" "answer_key_overrides" {
name = "email_file"
type = "S"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Cohort Tokens Table ───────────────────────────────────────────────────────
@@ -230,6 +262,10 @@ resource "aws_dynamodb_table" "cohort_tokens" {
attribute_name = "expires_at"
enabled = true
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Threat Cache (OpenPhish) ─────────────────────────────────────────────────
@@ -248,6 +284,10 @@ resource "aws_dynamodb_table" "threat_cache" {
attribute_name = "ttl"
enabled = true
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Campaigns ────────────────────────────────────────────────────────────────
@@ -272,6 +312,10 @@ resource "aws_dynamodb_table" "campaigns" {
hash_key = "cohort"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
# ─── Campaign Events ─────────────────────────────────────────────────────────
@@ -303,4 +347,8 @@ resource "aws_dynamodb_table" "campaign_events" {
range_key = "event_id"
projection_type = "ALL"
}
+
+ point_in_time_recovery {
+ enabled = true
+ }
}
diff --git a/phishing-platform-infra/terraform/iam.tf b/phishing-platform-infra/terraform/iam.tf
index 08810fc..5afe7c6 100644
--- a/phishing-platform-infra/terraform/iam.tf
+++ b/phishing-platform-infra/terraform/iam.tf
@@ -142,3 +142,20 @@ resource "aws_iam_role_policy" "lambda_s3" {
]
})
}
+
+# Secrets Manager access (read SECRET_KEY and MSAL credentials at runtime)
+resource "aws_iam_role_policy" "lambda_secrets" {
+ name = "${local.prefix}-lambda-secrets"
+ role = aws_iam_role.lambda.id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["secretsmanager:GetSecretValue"]
+ Resource = aws_secretsmanager_secret.app_secrets.arn
+ }
+ ]
+ })
+}
diff --git a/phishing-platform-infra/terraform/lambda.tf b/phishing-platform-infra/terraform/lambda.tf
index fc19bf2..ab2ba14 100644
--- a/phishing-platform-infra/terraform/lambda.tf
+++ b/phishing-platform-infra/terraform/lambda.tf
@@ -14,7 +14,7 @@ resource "aws_lambda_function" "app" {
environment {
variables = {
FLASK_ENV = var.environment
- SECRET_KEY = var.secret_key
+ SECRET_ARN = aws_secretsmanager_secret.app_secrets.arn
AWS_REGION_NAME = var.aws_region
DYNAMODB_USERS = aws_dynamodb_table.users.name
DYNAMODB_QUIZZES = aws_dynamodb_table.quizzes.name
diff --git a/phishing-platform-infra/terraform/outputs.tf b/phishing-platform-infra/terraform/outputs.tf
index 63e973f..15642fd 100644
--- a/phishing-platform-infra/terraform/outputs.tf
+++ b/phishing-platform-infra/terraform/outputs.tf
@@ -87,3 +87,13 @@ output "custom_domain_url" {
description = "Custom domain URL (empty if not configured)"
value = var.domain_name != "" ? "https://${var.domain_name}" : ""
}
+
+output "secrets_manager_arn" {
+ description = "ARN of the Secrets Manager secret holding Flask SECRET_KEY and MSAL credentials"
+ value = aws_secretsmanager_secret.app_secrets.arn
+}
+
+output "waf_arn" {
+ description = "ARN of the WAF v2 Web ACL attached to CloudFront (empty if WAF disabled)"
+ value = var.enable_waf ? aws_wafv2_web_acl.app[0].arn : ""
+}
diff --git a/phishing-platform-infra/terraform/secrets_manager.tf b/phishing-platform-infra/terraform/secrets_manager.tf
new file mode 100644
index 0000000..83007d1
--- /dev/null
+++ b/phishing-platform-infra/terraform/secrets_manager.tf
@@ -0,0 +1,30 @@
+# ─── AWS Secrets Manager — Application Secrets ───────────────────────────────
+# Stores Flask SECRET_KEY and MSAL_CLIENT_SECRET so they are never exposed
+# as plaintext Lambda environment variables (visible in the AWS console).
+# The Lambda receives SECRET_ARN; config.py fetches the value at startup.
+
+resource "aws_secretsmanager_secret" "app_secrets" {
+ name = "${local.prefix}/app-secrets"
+ description = "Flask SECRET_KEY and MSAL credentials for ${local.prefix}"
+ recovery_window_in_days = 7
+
+ tags = {
+ Name = "${local.prefix}-app-secrets"
+ }
+}
+
+resource "aws_secretsmanager_secret_version" "app_secrets" {
+ secret_id = aws_secretsmanager_secret.app_secrets.id
+
+ secret_string = jsonencode({
+ SECRET_KEY = var.secret_key
+ MSAL_CLIENT_SECRET = var.msal_client_secret
+ })
+}
+
+# ─── Outputs ──────────────────────────────────────────────────────────────────
+
+output "app_secrets_arn" {
+ description = "ARN of the Secrets Manager secret holding Flask SECRET_KEY and MSAL credentials"
+ value = aws_secretsmanager_secret.app_secrets.arn
+}
diff --git a/phishing-platform-infra/terraform/terraform.tfvars.example b/phishing-platform-infra/terraform/terraform.tfvars.example
index 671e98d..46fa09b 100644
--- a/phishing-platform-infra/terraform/terraform.tfvars.example
+++ b/phishing-platform-infra/terraform/terraform.tfvars.example
@@ -19,3 +19,7 @@ secret_key = "CHANGE-ME-to-a-long-random-string"
# redis_node_type = "cache.t4g.small"
# campaign_schedule_enabled = false
# campaign_schedule_expression = "rate(1 day)"
+
+# Security & audit (both default to true; set to false to skip in dev)
+# enable_waf = true # AWS WAF v2 on CloudFront (rate limiting + OWASP rules)
+# enable_cloudtrail = true # Multi-region CloudTrail to a dedicated S3 bucket
diff --git a/phishing-platform-infra/terraform/variables.tf b/phishing-platform-infra/terraform/variables.tf
index e9f2ede..2455980 100644
--- a/phishing-platform-infra/terraform/variables.tf
+++ b/phishing-platform-infra/terraform/variables.tf
@@ -124,3 +124,15 @@ variable "campaign_schedule_expression" {
type = string
default = "rate(1 day)"
}
+
+variable "enable_waf" {
+ description = "Enable AWS WAF v2 Web ACL on CloudFront (rate limiting + OWASP managed rules)"
+ type = bool
+ default = true
+}
+
+variable "enable_cloudtrail" {
+ description = "Enable multi-region AWS CloudTrail audit logging with a dedicated S3 bucket"
+ type = bool
+ default = true
+}
diff --git a/phishing-platform-infra/terraform/waf.tf b/phishing-platform-infra/terraform/waf.tf
new file mode 100644
index 0000000..6fe7258
--- /dev/null
+++ b/phishing-platform-infra/terraform/waf.tf
@@ -0,0 +1,101 @@
+# ─── AWS WAF v2 Web ACL ───────────────────────────────────────────────────────
+# CloudFront-scoped WAF rules MUST be deployed in us-east-1.
+# Uses the `aws.us_east_1` provider alias declared in main.tf.
+
+resource "aws_wafv2_web_acl" "app" {
+ count = var.enable_waf ? 1 : 0
+ provider = aws.us_east_1
+
+ name = "${local.prefix}-waf"
+ scope = "CLOUDFRONT"
+
+ default_action {
+ allow {}
+ }
+
+ # ─── Rate-based rule: block IPs exceeding 300 requests / 5 min ───────────
+ rule {
+ name = "RateLimitPerIP"
+ priority = 1
+
+ action {
+ block {}
+ }
+
+ statement {
+ rate_based_statement {
+ limit = 300
+ aggregate_key_type = "IP"
+ }
+ }
+
+ visibility_config {
+ cloudwatch_metrics_enabled = true
+ metric_name = "${local.prefix}-rate-limit"
+ sampled_requests_enabled = true
+ }
+ }
+
+ # ─── Managed Rule: OWASP Top 10 (SQLi, XSS, etc.) ────────────────────────
+ rule {
+ name = "AWSManagedRulesCommonRuleSet"
+ priority = 10
+
+ override_action {
+ none {}
+ }
+
+ statement {
+ managed_rule_group_statement {
+ name = "AWSManagedRulesCommonRuleSet"
+ vendor_name = "AWS"
+ }
+ }
+
+ visibility_config {
+ cloudwatch_metrics_enabled = true
+ metric_name = "${local.prefix}-common-rules"
+ sampled_requests_enabled = true
+ }
+ }
+
+ # ─── Managed Rule: Known bad inputs (Log4j, path traversal, etc.) ─────────
+ rule {
+ name = "AWSManagedRulesKnownBadInputsRuleSet"
+ priority = 20
+
+ override_action {
+ none {}
+ }
+
+ statement {
+ managed_rule_group_statement {
+ name = "AWSManagedRulesKnownBadInputsRuleSet"
+ vendor_name = "AWS"
+ }
+ }
+
+ visibility_config {
+ cloudwatch_metrics_enabled = true
+ metric_name = "${local.prefix}-bad-inputs"
+ sampled_requests_enabled = true
+ }
+ }
+
+ visibility_config {
+ cloudwatch_metrics_enabled = true
+ metric_name = "${local.prefix}-waf"
+ sampled_requests_enabled = true
+ }
+
+ tags = {
+ Name = "${local.prefix}-waf"
+ }
+}
+
+# ─── Outputs ──────────────────────────────────────────────────────────────────
+
+output "waf_web_acl_arn" {
+ description = "ARN of the WAF v2 Web ACL attached to CloudFront (empty if WAF disabled)"
+ value = var.enable_waf ? aws_wafv2_web_acl.app[0].arn : ""
+}
From e8f1c4f83c6a73c9e8a7af96b38edcda0d51692b Mon Sep 17 00:00:00 2001
From: Panacota96
Date: Tue, 7 Apr 2026 16:59:02 +0200
Subject: [PATCH 2/5] fix: address PR 28 review findings
---
CHANGELOG.md | 6 +-
config.py | 98 +++++++++++-----
.../terraform/cloudtrail.tf | 6 +-
.../terraform/github_actions_oidc.tf | 56 ++++++++-
phishing-platform-infra/terraform/lambda.tf | 17 ++-
phishing-platform-infra/terraform/outputs.tf | 9 --
.../terraform/variables.tf | 6 +
tests/test_config.py | 110 ++++++++++++++++++
8 files changed, 253 insertions(+), 55 deletions(-)
create mode 100644 tests/test_config.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3717e9..83f4eea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,9 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **AWS CloudTrail** (`cloudtrail.tf`): Multi-region trail writing to a dedicated AES256-encrypted S3 bucket with log-file validation enabled and S3 data-event logging for the application bucket. Controlled by `enable_cloudtrail` variable (default `true`).
- **DynamoDB PITR**: Point-in-Time Recovery enabled on all 11 DynamoDB tables for a 35-day recovery window.
- **SQS DLQ alarms**: Two new CloudWatch alarms (`{prefix}-registration-dlq-depth` and `{prefix}-campaign-dlq-depth`) fire on the SNS alerts topic when either dead-letter queue accumulates ≥ 1 message.
-- **`config.py` Secrets Manager resolver**: `_resolve_secret_key()` and `_resolve_msal_client_secret()` fetch credentials from Secrets Manager when `SECRET_ARN` is set, falling back to the `SECRET_KEY` / `MSAL_CLIENT_SECRET` environment variables for local development and unit tests.
-- **Terraform variables**: `enable_waf` (bool, default `true`) and `enable_cloudtrail` (bool, default `true`) added to `variables.tf`.
-- **Terraform outputs**: `waf_arn`, `app_secrets_arn`, `secrets_manager_arn`, and `cloudtrail_bucket` added to `outputs.tf`.
+- **`config.py` Secrets Manager resolver**: shared secret payload is fetched once and cached; `SECRET_KEY` now fails closed when `SECRET_ARN` is configured but the key cannot be retrieved, while local development and tests still use env-var fallbacks when `SECRET_ARN` is absent.
+- **Terraform variables**: `enable_waf` (bool, default `true`), `enable_cloudtrail` (bool, default `true`), and `cloudtrail_bucket_force_destroy` (bool, default `false`) added to `variables.tf`.
+- **Terraform outputs**: canonical outputs `waf_web_acl_arn`, `app_secrets_arn`, and `cloudtrail_bucket` exposed for the new infrastructure components.
### Changed
- `lambda.tf`: Lambda env var `SECRET_KEY` replaced with `SECRET_ARN`; `MSAL_CLIENT_SECRET` removed from plaintext env vars (now fetched at runtime from Secrets Manager).
diff --git a/config.py b/config.py
index 96d9993..0620f18 100644
--- a/config.py
+++ b/config.py
@@ -1,9 +1,53 @@
import json
import logging
import os
+from typing import Any
logger = logging.getLogger(__name__)
+_APP_SECRETS_CACHE: dict[str, Any] | None = None
+_APP_SECRETS_CACHE_ARN: str | None = None
+
+
+def _get_app_secrets() -> dict[str, Any]:
+ """Fetch and cache the shared app secret bundle from Secrets Manager."""
+ global _APP_SECRETS_CACHE, _APP_SECRETS_CACHE_ARN
+
+ secret_arn = os.environ.get("SECRET_ARN", "").strip()
+ if not secret_arn:
+ return {}
+
+ if (
+ _APP_SECRETS_CACHE is not None
+ and _APP_SECRETS_CACHE_ARN == secret_arn
+ ):
+ return _APP_SECRETS_CACHE
+
+ import boto3
+ from botocore.exceptions import BotoCoreError, ClientError
+
+ try:
+ region = os.environ.get("AWS_REGION_NAME", "eu-west-3")
+ client = boto3.client("secretsmanager", region_name=region)
+ resp = client.get_secret_value(SecretId=secret_arn)
+ data = json.loads(resp["SecretString"])
+ if not isinstance(data, dict):
+ raise ValueError("secret payload must be a JSON object")
+ except (
+ ClientError,
+ BotoCoreError,
+ json.JSONDecodeError,
+ KeyError,
+ ValueError,
+ ) as exc:
+ raise RuntimeError(
+ f"Failed to load application secrets from {secret_arn}: {exc}"
+ ) from exc
+
+ _APP_SECRETS_CACHE = data
+ _APP_SECRETS_CACHE_ARN = secret_arn
+ return data
+
def _resolve_secret_key() -> str:
"""Return the Flask SECRET_KEY.
@@ -18,41 +62,39 @@ def _resolve_secret_key() -> str:
development default) when ``SECRET_ARN`` is absent — this preserves
backward-compatibility for local development and unit tests.
"""
- secret_arn = os.environ.get("SECRET_ARN", "")
+ secret_arn = os.environ.get("SECRET_ARN", "").strip()
if secret_arn:
- try:
- import boto3
- from botocore.exceptions import BotoCoreError, ClientError
-
- region = os.environ.get("AWS_REGION_NAME", "eu-west-3")
- client = boto3.client("secretsmanager", region_name=region)
- resp = client.get_secret_value(SecretId=secret_arn)
- data = json.loads(resp["SecretString"])
- return data.get("SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production"))
- except (ClientError, BotoCoreError) as exc:
- logger.warning("Secrets Manager fetch failed (%s); falling back to SECRET_KEY env var", exc)
- except (json.JSONDecodeError, KeyError) as exc:
- logger.warning("Secrets Manager secret parse error (%s); falling back to SECRET_KEY env var", exc)
+ secret_key = _get_app_secrets().get("SECRET_KEY")
+ if secret_key:
+ return secret_key
+
+ env_secret_key = os.environ.get("SECRET_KEY", "")
+ if env_secret_key:
+ logger.warning(
+ "SECRET_ARN is set but SECRET_KEY was not found in Secrets Manager; "
+ "falling back to SECRET_KEY env var"
+ )
+ return env_secret_key
+
+ raise RuntimeError(
+ "SECRET_ARN is set but SECRET_KEY could not be resolved from Secrets "
+ "Manager and no SECRET_KEY env fallback is configured"
+ )
return os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")
def _resolve_msal_client_secret() -> str:
"""Return the MSAL client secret from Secrets Manager or env var."""
- secret_arn = os.environ.get("SECRET_ARN", "")
+ secret_arn = os.environ.get("SECRET_ARN", "").strip()
if secret_arn:
- try:
- import boto3
- from botocore.exceptions import BotoCoreError, ClientError
-
- region = os.environ.get("AWS_REGION_NAME", "eu-west-3")
- client = boto3.client("secretsmanager", region_name=region)
- resp = client.get_secret_value(SecretId=secret_arn)
- data = json.loads(resp["SecretString"])
- return data.get("MSAL_CLIENT_SECRET", os.environ.get("MSAL_CLIENT_SECRET", ""))
- except (ClientError, BotoCoreError) as exc:
- logger.warning("Secrets Manager fetch failed (%s); falling back to MSAL_CLIENT_SECRET env var", exc)
- except (json.JSONDecodeError, KeyError) as exc:
- logger.warning("Secrets Manager secret parse error (%s); falling back to MSAL_CLIENT_SECRET env var", exc)
+ msal_client_secret = _get_app_secrets().get("MSAL_CLIENT_SECRET")
+ if msal_client_secret:
+ return msal_client_secret
+
+ logger.warning(
+ "SECRET_ARN is set but MSAL_CLIENT_SECRET was not found in Secrets "
+ "Manager; falling back to MSAL_CLIENT_SECRET env var"
+ )
return os.environ.get("MSAL_CLIENT_SECRET", "")
diff --git a/phishing-platform-infra/terraform/cloudtrail.tf b/phishing-platform-infra/terraform/cloudtrail.tf
index c1c800a..634e747 100644
--- a/phishing-platform-infra/terraform/cloudtrail.tf
+++ b/phishing-platform-infra/terraform/cloudtrail.tf
@@ -9,7 +9,7 @@ data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "cloudtrail" {
count = var.enable_cloudtrail ? 1 : 0
bucket = "${local.prefix}-cloudtrail-${data.aws_caller_identity.current.account_id}"
- force_destroy = true
+ force_destroy = var.cloudtrail_bucket_force_destroy
tags = {
Name = "${local.prefix}-cloudtrail"
@@ -82,8 +82,8 @@ resource "aws_s3_bucket_policy" "cloudtrail" {
# ─── CloudTrail Trail ─────────────────────────────────────────────────────────
resource "aws_cloudtrail" "app" {
- count = var.enable_cloudtrail ? 1 : 0
- name = "${local.prefix}-trail"
+ count = var.enable_cloudtrail ? 1 : 0
+ name = "${local.prefix}-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail[0].id
include_global_service_events = true
diff --git a/phishing-platform-infra/terraform/github_actions_oidc.tf b/phishing-platform-infra/terraform/github_actions_oidc.tf
index b239588..4e6b7a8 100644
--- a/phishing-platform-infra/terraform/github_actions_oidc.tf
+++ b/phishing-platform-infra/terraform/github_actions_oidc.tf
@@ -106,6 +106,7 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
"dynamodb:BatchGetItem",
"dynamodb:DescribeTimeToLive",
"dynamodb:UpdateTimeToLive",
+ "dynamodb:UpdateContinuousBackups",
"dynamodb:ListTagsOfResource",
"dynamodb:TagResource",
"dynamodb:DescribeContinuousBackups",
@@ -127,9 +128,9 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
]
},
{
- Sid = "APIGateway"
- Effect = "Allow"
- Action = "apigateway:*"
+ Sid = "APIGateway"
+ Effect = "Allow"
+ Action = "apigateway:*"
Resource = "*"
},
{
@@ -148,6 +149,55 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
]
Resource = "*"
},
+ {
+ Sid = "SecretsManager"
+ Effect = "Allow"
+ Action = [
+ "secretsmanager:CreateSecret",
+ "secretsmanager:DeleteSecret",
+ "secretsmanager:DescribeSecret",
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:ListSecretVersionIds",
+ "secretsmanager:PutSecretValue",
+ "secretsmanager:TagResource",
+ "secretsmanager:UntagResource",
+ "secretsmanager:UpdateSecret",
+ ]
+ Resource = "*"
+ },
+ {
+ Sid = "CloudTrail"
+ Effect = "Allow"
+ Action = [
+ "cloudtrail:AddTags",
+ "cloudtrail:CreateTrail",
+ "cloudtrail:DeleteTrail",
+ "cloudtrail:DescribeTrails",
+ "cloudtrail:GetTrail",
+ "cloudtrail:GetTrailStatus",
+ "cloudtrail:ListTags",
+ "cloudtrail:RemoveTags",
+ "cloudtrail:StartLogging",
+ "cloudtrail:StopLogging",
+ "cloudtrail:UpdateTrail",
+ ]
+ Resource = "*"
+ },
+ {
+ Sid = "WAFv2"
+ Effect = "Allow"
+ Action = [
+ "wafv2:CreateWebACL",
+ "wafv2:DeleteWebACL",
+ "wafv2:GetWebACL",
+ "wafv2:ListTagsForResource",
+ "wafv2:ListWebACLs",
+ "wafv2:TagResource",
+ "wafv2:UntagResource",
+ "wafv2:UpdateWebACL",
+ ]
+ Resource = "*"
+ },
{
Sid = "CloudWatchLogs"
Effect = "Allow"
diff --git a/phishing-platform-infra/terraform/lambda.tf b/phishing-platform-infra/terraform/lambda.tf
index ab2ba14..b9fc76a 100644
--- a/phishing-platform-infra/terraform/lambda.tf
+++ b/phishing-platform-infra/terraform/lambda.tf
@@ -13,14 +13,14 @@ resource "aws_lambda_function" "app" {
environment {
variables = {
- FLASK_ENV = var.environment
- SECRET_ARN = aws_secretsmanager_secret.app_secrets.arn
- AWS_REGION_NAME = var.aws_region
- DYNAMODB_USERS = aws_dynamodb_table.users.name
- DYNAMODB_QUIZZES = aws_dynamodb_table.quizzes.name
- DYNAMODB_ATTEMPTS = aws_dynamodb_table.attempts.name
- DYNAMODB_RESPONSES = aws_dynamodb_table.responses.name
- DYNAMODB_INSPECTOR = aws_dynamodb_table.inspector_attempts.name
+ FLASK_ENV = var.environment
+ SECRET_ARN = aws_secretsmanager_secret.app_secrets.arn
+ AWS_REGION_NAME = var.aws_region
+ DYNAMODB_USERS = aws_dynamodb_table.users.name
+ DYNAMODB_QUIZZES = aws_dynamodb_table.quizzes.name
+ DYNAMODB_ATTEMPTS = aws_dynamodb_table.attempts.name
+ DYNAMODB_RESPONSES = aws_dynamodb_table.responses.name
+ DYNAMODB_INSPECTOR = aws_dynamodb_table.inspector_attempts.name
DYNAMODB_INSPECTOR_ANON = aws_dynamodb_table.inspector_attempts_anon.name
DYNAMODB_BUGS = aws_dynamodb_table.bugs.name
DYNAMODB_ANSWER_KEY_OVERRIDES = aws_dynamodb_table.answer_key_overrides.name
@@ -37,7 +37,6 @@ resource "aws_lambda_function" "app" {
REDIS_USE_TLS = "false"
CAMPAIGN_LAMBDA_ARN = aws_lambda_function.campaign_mailer.arn
MSAL_CLIENT_ID = var.msal_client_id
- MSAL_CLIENT_SECRET = var.msal_client_secret
MSAL_AUTHORITY = var.msal_authority
MSAL_ADMIN_GROUP_ID = var.msal_admin_group_id
MSAL_INSTRUCTOR_GROUP_ID = var.msal_instructor_group_id
diff --git a/phishing-platform-infra/terraform/outputs.tf b/phishing-platform-infra/terraform/outputs.tf
index 15642fd..99024a8 100644
--- a/phishing-platform-infra/terraform/outputs.tf
+++ b/phishing-platform-infra/terraform/outputs.tf
@@ -88,12 +88,3 @@ output "custom_domain_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : ""
}
-output "secrets_manager_arn" {
- description = "ARN of the Secrets Manager secret holding Flask SECRET_KEY and MSAL credentials"
- value = aws_secretsmanager_secret.app_secrets.arn
-}
-
-output "waf_arn" {
- description = "ARN of the WAF v2 Web ACL attached to CloudFront (empty if WAF disabled)"
- value = var.enable_waf ? aws_wafv2_web_acl.app[0].arn : ""
-}
diff --git a/phishing-platform-infra/terraform/variables.tf b/phishing-platform-infra/terraform/variables.tf
index 2455980..a097c86 100644
--- a/phishing-platform-infra/terraform/variables.tf
+++ b/phishing-platform-infra/terraform/variables.tf
@@ -136,3 +136,9 @@ variable "enable_cloudtrail" {
type = bool
default = true
}
+
+variable "cloudtrail_bucket_force_destroy" {
+ description = "Allow Terraform to delete non-empty CloudTrail log buckets; keep false outside disposable environments"
+ type = bool
+ default = false
+}
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..f30f955
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,110 @@
+import importlib
+import sys
+import types
+
+import pytest
+
+
+def _load_config_module(
+ monkeypatch,
+ *,
+ env=None,
+ secret_payload=None,
+ secret_error_message=None,
+):
+ env = env or {}
+ calls = []
+
+ for key in (
+ "SECRET_ARN",
+ "SECRET_KEY",
+ "MSAL_CLIENT_SECRET",
+ "AWS_REGION_NAME",
+ ):
+ monkeypatch.delenv(key, raising=False)
+ for key, value in env.items():
+ monkeypatch.setenv(key, value)
+
+ class DummyBotoCoreError(Exception):
+ pass
+
+ class DummyClientError(Exception):
+ pass
+
+ class DummySecretsClient:
+ def get_secret_value(self, SecretId):
+ if secret_error_message is not None:
+ raise DummyClientError(secret_error_message)
+ return {"SecretString": secret_payload}
+
+ def client(service_name, region_name=None):
+ calls.append((service_name, region_name))
+ return DummySecretsClient()
+
+ boto3_module = types.ModuleType("boto3")
+ boto3_module.client = client
+ botocore_module = types.ModuleType("botocore")
+ botocore_exceptions = types.ModuleType("botocore.exceptions")
+ botocore_exceptions.BotoCoreError = DummyBotoCoreError
+ botocore_exceptions.ClientError = DummyClientError
+ botocore_module.exceptions = botocore_exceptions
+
+ monkeypatch.setitem(sys.modules, "boto3", boto3_module)
+ monkeypatch.setitem(sys.modules, "botocore", botocore_module)
+ monkeypatch.setitem(
+ sys.modules,
+ "botocore.exceptions",
+ botocore_exceptions,
+ )
+
+ existing = sys.modules.get("config")
+ if existing is None:
+ import config as config_module
+ else:
+ config_module = existing
+
+ if existing is None:
+ return config_module, calls
+
+ return importlib.reload(config_module), calls
+
+
+def test_config_fetches_shared_secret_once_and_reuses_cache(monkeypatch):
+ module, calls = _load_config_module(
+ monkeypatch,
+ env={
+ "SECRET_ARN": (
+ "arn:aws:secretsmanager:eu-west-3:123456789012:secret:app"
+ ),
+ "AWS_REGION_NAME": "eu-west-3",
+ },
+ secret_payload=(
+ (
+ '{"SECRET_KEY": "prod-secret", '
+ '"MSAL_CLIENT_SECRET": "msal-secret"}'
+ )
+ ),
+ )
+
+ assert module.Config.SECRET_KEY == "prod-secret"
+ assert module.Config.MSAL_CLIENT_SECRET == "msal-secret"
+ assert calls == [("secretsmanager", "eu-west-3")]
+
+
+def test_config_raises_when_secret_key_cannot_be_loaded_without_env_fallback(
+ monkeypatch,
+):
+ with pytest.raises(
+ RuntimeError,
+ match="Failed to load application secrets",
+ ):
+ _load_config_module(
+ monkeypatch,
+ env={
+ "SECRET_ARN": (
+ "arn:aws:secretsmanager:eu-west-3:123456789012:secret:app"
+ )
+ },
+ secret_payload='{"SECRET_KEY": "unused"}',
+ secret_error_message="boom",
+ )
From 187f7f3c08dd73306fb3b625ea2b63945a7cf1cf Mon Sep 17 00:00:00 2001
From: Panacota96
Date: Mon, 13 Apr 2026 09:51:18 +0200
Subject: [PATCH 3/5] Clarify EnGarde project positioning
---
README.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/README.md b/README.md
index 4cfc81c..0bf7ff6 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,10 @@
An interactive phishing awareness platform for engineering students — quizzes, video training, and a hands-on Email Threat Inspector, all running serverless on AWS.
+
+ Built to make phishing defense practical, measurable, and easier to teach.
+
+
@@ -25,6 +29,8 @@
+> Why it matters: this repo shows how I translate blue-team awareness into hands-on student training with real phishing signals, analytics, and deployable infrastructure.
+
---
## Table of Contents
From 694d921afaaa6624950a0d8d11d7e20b5eec4876 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Santiago=20Gonz=C3=A1lez=20Rico?=
<118935424+Panacota96@users.noreply.github.com>
Date: Mon, 20 Apr 2026 08:00:32 +0200
Subject: [PATCH 4/5] fix: extend deploy role permissions for new security
resources
---
CHANGELOG.md | 1 +
phishing-platform-infra/terraform/github_actions_oidc.tf | 7 +++++++
2 files changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c031a1..c9701ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Local Docker defaults no longer rely on a pre-created `.env`; `.env.example` is now the explicit starting point.
- Documentation now treats `documentation/WORKBOARD.md` as the source of truth for milestone, issue, and branch tracking.
- Terraform, Docker, and config baselines were corrected to match the current repository layout and runtime behavior.
+- Expanded GitHub Actions OIDC deploy-role permissions for newly managed security resources (CloudTrail event selectors, additional Secrets Manager read/list/restore actions, and WAFv2 resource lookups) to avoid `AccessDenied` during Terraform apply.
---
diff --git a/phishing-platform-infra/terraform/github_actions_oidc.tf b/phishing-platform-infra/terraform/github_actions_oidc.tf
index 4e6b7a8..c98d200 100644
--- a/phishing-platform-infra/terraform/github_actions_oidc.tf
+++ b/phishing-platform-infra/terraform/github_actions_oidc.tf
@@ -157,8 +157,11 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
+ "secretsmanager:GetResourcePolicy",
+ "secretsmanager:ListSecrets",
"secretsmanager:ListSecretVersionIds",
"secretsmanager:PutSecretValue",
+ "secretsmanager:RestoreSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret",
@@ -173,9 +176,12 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
"cloudtrail:CreateTrail",
"cloudtrail:DeleteTrail",
"cloudtrail:DescribeTrails",
+ "cloudtrail:GetEventSelectors",
"cloudtrail:GetTrail",
"cloudtrail:GetTrailStatus",
"cloudtrail:ListTags",
+ "cloudtrail:ListTrails",
+ "cloudtrail:PutEventSelectors",
"cloudtrail:RemoveTags",
"cloudtrail:StartLogging",
"cloudtrail:StopLogging",
@@ -190,6 +196,7 @@ resource "aws_iam_role_policy" "github_actions_deploy" {
"wafv2:CreateWebACL",
"wafv2:DeleteWebACL",
"wafv2:GetWebACL",
+ "wafv2:GetWebACLForResource",
"wafv2:ListTagsForResource",
"wafv2:ListWebACLs",
"wafv2:TagResource",
From a9a1e47a4ad0016b545af5753154e44956389f1e Mon Sep 17 00:00:00 2001
From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com>
Date: Mon, 20 Apr 2026 06:00:53 +0000
Subject: [PATCH 5/5] chore: harden cloudtrail retention
Co-authored-by: Panacota96 <118935424+Panacota96@users.noreply.github.com>
---
CHANGELOG.md | 4 ++--
.../terraform/cloudtrail.tf | 21 ++++++++++++++++++-
.../terraform/variables.tf | 6 ------
3 files changed, 22 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9701ad..bb02c80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,11 +26,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- **AWS WAF v2** (`waf.tf`): CloudFront-scoped Web ACL (us-east-1) with `AWSManagedRulesCommonRuleSet`, `AWSManagedRulesKnownBadInputsRuleSet`, and a per-IP rate-based rule (300 requests / 5 minutes). Controlled by `enable_waf` variable (default `true`).
- **AWS Secrets Manager** (`secrets_manager.tf`): Flask `SECRET_KEY` and `MSAL_CLIENT_SECRET` are now stored in a Secrets Manager secret (`{prefix}/app-secrets`). The Lambda receives `SECRET_ARN` instead of the plaintext values, preventing credentials from appearing in the AWS console environment variables view.
-- **AWS CloudTrail** (`cloudtrail.tf`): Multi-region trail writing to a dedicated AES256-encrypted S3 bucket with log-file validation enabled and S3 data-event logging for the application bucket. Controlled by `enable_cloudtrail` variable (default `true`).
+- **AWS CloudTrail** (`cloudtrail.tf`): Multi-region trail writing to a dedicated AES256-encrypted S3 bucket with log-file validation enabled and S3 data-event logging for the application bucket. Controlled by `enable_cloudtrail` variable (default `true`) with a 7-year retention policy (transition to Glacier after 90 days) and no force-destroy on the log bucket.
- **DynamoDB PITR**: Point-in-Time Recovery enabled on all 11 DynamoDB tables for a 35-day recovery window.
- **SQS DLQ alarms**: Two new CloudWatch alarms (`{prefix}-registration-dlq-depth` and `{prefix}-campaign-dlq-depth`) fire on the SNS alerts topic when either dead-letter queue accumulates ≥ 1 message.
- **`config.py` Secrets Manager resolver**: shared secret payload is fetched once and cached; `SECRET_KEY` now fails closed when `SECRET_ARN` is configured but the key cannot be retrieved, while local development and tests still use env-var fallbacks when `SECRET_ARN` is absent.
-- **Terraform variables**: `enable_waf` (bool, default `true`), `enable_cloudtrail` (bool, default `true`), and `cloudtrail_bucket_force_destroy` (bool, default `false`) added to `variables.tf`.
+- **Terraform variables**: `enable_waf` (bool, default `true`) and `enable_cloudtrail` (bool, default `true`) added to `variables.tf`.
- **Terraform outputs**: canonical outputs `waf_web_acl_arn`, `app_secrets_arn`, and `cloudtrail_bucket` exposed for the new infrastructure components.
### Changed
diff --git a/phishing-platform-infra/terraform/cloudtrail.tf b/phishing-platform-infra/terraform/cloudtrail.tf
index 634e747..bed6289 100644
--- a/phishing-platform-infra/terraform/cloudtrail.tf
+++ b/phishing-platform-infra/terraform/cloudtrail.tf
@@ -9,7 +9,26 @@ data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "cloudtrail" {
count = var.enable_cloudtrail ? 1 : 0
bucket = "${local.prefix}-cloudtrail-${data.aws_caller_identity.current.account_id}"
- force_destroy = var.cloudtrail_bucket_force_destroy
+
+ lifecycle_rule {
+ id = "cloudtrail-retention"
+ enabled = true
+
+ transition {
+ days = 90
+ storage_class = "GLACIER"
+ }
+
+ expiration {
+ days = 2555 # ~7 years
+ }
+
+ abort_incomplete_multipart_upload_days = 7
+
+ filter {
+ prefix = ""
+ }
+ }
tags = {
Name = "${local.prefix}-cloudtrail"
diff --git a/phishing-platform-infra/terraform/variables.tf b/phishing-platform-infra/terraform/variables.tf
index a097c86..2455980 100644
--- a/phishing-platform-infra/terraform/variables.tf
+++ b/phishing-platform-infra/terraform/variables.tf
@@ -136,9 +136,3 @@ variable "enable_cloudtrail" {
type = bool
default = true
}
-
-variable "cloudtrail_bucket_force_destroy" {
- description = "Allow Terraform to delete non-empty CloudTrail log buckets; keep false outside disposable environments"
- type = bool
- default = false
-}