Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +28 to +38

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entry says MSAL_CLIENT_SECRET was removed from plaintext Lambda env vars and that Lambda receives only SECRET_ARN, but phishing-platform-infra/terraform/lambda.tf still sets MSAL_CLIENT_SECRET = var.msal_client_secret in the environment. Either update the infrastructure to actually remove it, or adjust the changelog to reflect the current behavior.

Copilot uses AI. Check for mistakes.
- `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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.3
1.3.0
59 changes: 57 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
@@ -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")

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When SECRET_ARN is set but the Secrets Manager call fails, this falls back to os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production"). In the new Terraform setup, SECRET_KEY is no longer provided to the Lambda env, so a transient Secrets Manager/VPC endpoint/IAM issue would silently switch production to the insecure dev default (breaking session integrity). Consider failing fast if SECRET_ARN is set and SECRET_KEY env var is absent, or at least avoid ever using the dev default in that path.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when SECRET_KEY cannot be retrieved

When SECRET_ARN is present and Secrets Manager retrieval fails (for example due to IAM, throttling, or regional issues), this path falls back to os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production"). In the same commit lambda.tf replaces SECRET_KEY with SECRET_ARN, so production can start with the known dev key instead of refusing to boot, which weakens session and CSRF signing security.

Useful? React with 👍 / 👎.



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", "")
Comment on lines +52 to +98

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both _resolve_secret_key() and _resolve_msal_client_secret() call Secrets Manager independently, which results in two network calls during cold start (and repeats JSON parsing). Consider factoring this into a single helper that fetches/parses the secret once and memoizes the result (module-level cache), then have both resolvers read from that cached dict.

Copilot uses AI. Check for mistakes.


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.
Expand Down Expand Up @@ -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/<tenant-id>/v2.0" or
# "https://login.microsoftonline.com/common/v2.0" for multi-tenant.
MSAL_AUTHORITY = os.environ.get(
Expand Down
44 changes: 36 additions & 8 deletions documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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"]
Expand All @@ -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"]
Expand All @@ -110,45 +124,59 @@ 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)"]
end

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:*"]
end

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
SNSAlerts --> CWAlarms
LApp --> CWLogs
LWorker --> CWLogs
APIGW --> CWLogs
CT --> S3Trail
RoleLambda --> LApp
RoleWorker --> LWorker
OIDC --> RoleGHA
Expand Down
1 change: 1 addition & 0 deletions phishing-platform-infra/terraform/cloudfront.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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://", "")
Expand Down
116 changes: 116 additions & 0 deletions phishing-platform-infra/terraform/cloudtrail.tf
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +9 to +32

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force_destroy = true on the CloudTrail log bucket makes it easy to accidentally delete audit logs (including non-empty buckets) during Terraform destroy/replace, which is usually undesirable for audit/compliance and incident response. Consider removing force_destroy (or making it an explicit opt-in variable for non-prod) and adding a retention/lifecycle policy instead.

Copilot uses AI. Check for mistakes.
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 : ""
}
Loading
Loading