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 ![CloudFront](https://img.shields.io/badge/CloudFront-232F3E?logo=amazonaws&logoColor=white) ![API Gateway](https://img.shields.io/badge/API_Gateway-FF4F8B?logo=amazonaws&logoColor=white) ![CloudWatch](https://img.shields.io/badge/CloudWatch-FF4F8B?logo=amazonaws&logoColor=white) +![AWS WAF](https://img.shields.io/badge/AWS_WAF-DD344C?logo=amazonaws&logoColor=white) +![Secrets Manager](https://img.shields.io/badge/Secrets_Manager-DD344C?logo=amazonaws&logoColor=white) +![CloudTrail](https://img.shields.io/badge/CloudTrail-232F3E?logo=amazonaws&logoColor=white) 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 -}