Skip to content

Commit 4ef1cf7

Browse files
authored
Merge branch 'main' into feature/te-ELID-466-further-cicd-enhancements
2 parents 9c720a9 + d3ea593 commit 4ef1cf7

File tree

18 files changed

+623
-31
lines changed

18 files changed

+623
-31
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: "OWASP Dependency Scan"
2+
description: "Scan dependencies for known vulnerabilities using OWASP Dependency-Check"
3+
runs:
4+
using: "composite"
5+
steps:
6+
- name: "Run OWASP Dependency-Check"
7+
uses: dependency-check/Dependency-Check_Action@main
8+
id: Depcheck
9+
with:
10+
project: "eligibility-signposting-api"
11+
path: "."
12+
format: "SARIF"
13+
out: "reports"
14+
args: >
15+
--failOnCVSS 7
16+
--enableRetired
17+
- name: "Upload OWASP results to GitHub Security tab"
18+
uses: github/codeql-action/upload-sarif@v3
19+
with:
20+
sarif_file: reports/dependency-check-report.sarif

.github/workflows/stage-1-commit.yaml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ jobs:
8989
checkov-terraform:
9090
name: "Checkov Terraform"
9191
runs-on: ubuntu-latest
92+
permissions:
93+
contents: read
94+
security-events: write
9295
timeout-minutes: 3
9396
steps:
9497
- name: "Checkout code"
@@ -100,11 +103,10 @@ jobs:
100103
soft_fail: false
101104
output_format: sarif
102105
output_file_path: checkov-report.sarif
103-
- name: Upload Checkov results to GitHub Security tab
104-
uses: actions/upload-artifact@v5
106+
- name: "Upload Checkov results to GitHub Security tab"
107+
uses: github/codeql-action/upload-sarif@v3
105108
with:
106-
name: checkov_results
107-
path: checkov-report.sarif
109+
sarif_file: checkov-report.sarif
108110
count-lines-of-code:
109111
name: "Count lines of code"
110112
runs-on: ubuntu-latest
@@ -143,3 +145,15 @@ jobs:
143145
idp_aws_report_upload_region: "${{ secrets.IDP_AWS_REPORT_UPLOAD_REGION }}"
144146
idp_aws_report_upload_role_name: "${{ secrets.IDP_AWS_REPORT_UPLOAD_ROLE_NAME }}"
145147
idp_aws_report_upload_bucket_endpoint: "${{ secrets.IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT }}"
148+
owasp-dependency-scan:
149+
name: "OWASP Dependency Scan"
150+
runs-on: ubuntu-latest
151+
permissions:
152+
contents: read
153+
security-events: write
154+
timeout-minutes: 5
155+
steps:
156+
- name: "Checkout code"
157+
uses: actions/checkout@v5
158+
- name: "Run OWASP Dependency Scan"
159+
uses: ./.github/actions/owasp-dependency-scan

docs/security-headers.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Security Headers Implementation
2+
3+
This document describes the implementation of security headers for the Eligibility Signposting API.
4+
5+
## Overview
6+
7+
Security headers have been implemented using a two-layer approach:
8+
9+
1. **API Gateway Responses** (Terraform) - For error responses (4xx, 5xx) generated by API Gateway
10+
2. **Flask middleware** (Python) - For all successful responses (200, 201, etc.) from the Lambda function
11+
12+
This ensures security headers are present on **all** responses, regardless of where they originate.
13+
14+
## Security Headers
15+
16+
The following security headers are applied to all responses:
17+
18+
| Header | Value | Purpose |
19+
|--------|-------|---------|
20+
| `Cache-Control` | `no-store, private` | Prevents caching of sensitive data |
21+
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Enforces HTTPS for 1 year on all subdomains |
22+
| `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing |
23+
24+
## Implementation Details
25+
26+
### 1. API Gateway Responses (Terraform)
27+
28+
File: `infrastructure/stacks/api-layer/gateway_responses.tf`
29+
30+
Gateway responses handle error responses generated by API Gateway itself (e.g., validation failures, authorization errors, throttling).
31+
32+
The following response types have been configured:
33+
34+
- `DEFAULT_4XX` - All 4xx errors
35+
- `DEFAULT_5XX` - All 5xx errors
36+
- `UNAUTHORIZED` (401)
37+
- `ACCESS_DENIED` (403)
38+
- `THROTTLED` (429)
39+
40+
### 2. Flask middleware
41+
42+
Files:
43+
44+
- `src/eligibility_signposting_api/middleware/security_headers.py` - middleware implementation
45+
- `src/eligibility_signposting_api/middleware/__init__.py` - Package exports
46+
- `src/eligibility_signposting_api/app.py` - middleware registration
47+
48+
The middleware uses Flask's `after_request` hook to add security headers to all responses from the Lambda function. It's non-overriding: Allows specific endpoints to override headers if needed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Gateway Responses with Security Headers
2+
# These responses are used when API Gateway itself returns an error (e.g., validation failures, auth errors)
3+
# They ensure security headers are present even on API Gateway-generated error responses
4+
5+
resource "aws_api_gateway_gateway_response" "response_4xx" {
6+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
7+
response_type = "DEFAULT_4XX"
8+
9+
response_parameters = {
10+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
11+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
12+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
13+
}
14+
}
15+
16+
resource "aws_api_gateway_gateway_response" "response_5xx" {
17+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
18+
response_type = "DEFAULT_5XX"
19+
20+
response_parameters = {
21+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
22+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
23+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
24+
}
25+
}
26+
27+
resource "aws_api_gateway_gateway_response" "unauthorized" {
28+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
29+
response_type = "UNAUTHORIZED"
30+
status_code = "401"
31+
32+
response_parameters = {
33+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
34+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
35+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
36+
}
37+
}
38+
39+
resource "aws_api_gateway_gateway_response" "access_denied" {
40+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
41+
response_type = "ACCESS_DENIED"
42+
status_code = "403"
43+
44+
response_parameters = {
45+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
46+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
47+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
48+
}
49+
}
50+
51+
resource "aws_api_gateway_gateway_response" "throttled" {
52+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
53+
response_type = "THROTTLED"
54+
status_code = "429"
55+
56+
response_parameters = {
57+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
58+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
59+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
60+
}
61+
}

infrastructure/stacks/api-layer/patient_check.tf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,8 @@ resource "aws_api_gateway_gateway_response" "bad_request_parameters" {
112112
response_parameters = {
113113
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
114114
"gatewayresponse.header.Content-Type" = "'application/fhir+json'"
115+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
116+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
117+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
115118
}
116119
}

scripts/config/vale/styles/config/vocabularies/words/accept.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Gitleaks
88
Grype
99
idempotence
1010
Makefile
11+
Middleware
12+
middleware
1113
OAuth
1214
Octokit
1315
onboarding

src/eligibility_signposting_api/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from eligibility_signposting_api.logging.logs_helper import log_request_ids_from_headers
1818
from eligibility_signposting_api.logging.logs_manager import add_lambda_request_id_to_logger, init_logging
1919
from eligibility_signposting_api.logging.tracing_helper import tracing_setup
20+
from eligibility_signposting_api.middleware import SecurityHeadersMiddleware
2021
from eligibility_signposting_api.views import eligibility_blueprint
2122

2223
if os.getenv("ENABLE_XRAY_PATCHING"):
@@ -62,6 +63,9 @@ def create_app() -> Flask:
6263
app = Flask(__name__)
6364
logger.info("app created")
6465

66+
# Register security headers middleware
67+
SecurityHeadersMiddleware(app)
68+
6569
# Register views & error handler
6670
app.register_blueprint(eligibility_blueprint, url_prefix=f"/{URL_PREFIX}")
6771
app.register_error_handler(Exception, handle_exception)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Middleware package for the Eligibility Signposting API."""
2+
3+
from eligibility_signposting_api.middleware.security_headers import SecurityHeadersMiddleware
4+
5+
__all__ = ["SecurityHeadersMiddleware"]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Security headers middleware for Flask application.
2+
3+
This middleware adds security headers to all responses from the Lambda function.
4+
These headers are applied to successful responses (200, 201, etc.) from the application.
5+
6+
For API Gateway error responses (4xx, 5xx generated by API Gateway itself),
7+
see gateway_responses.tf in the infrastructure configuration.
8+
"""
9+
10+
from typing import ClassVar
11+
12+
from flask import Flask, Response
13+
14+
15+
class SecurityHeadersMiddleware:
16+
"""Middleware to add security headers to all Flask responses."""
17+
18+
# Security headers recommended for NHS APIs
19+
SECURITY_HEADERS: ClassVar[dict[str, str]] = {
20+
"Cache-Control": "no-store, private",
21+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
22+
"X-Content-Type-Options": "nosniff",
23+
}
24+
25+
def __init__(self, app: Flask | None = None) -> None:
26+
"""Initialize the middleware.
27+
28+
Args:
29+
app: Flask application instance. Can be provided later via init_app()
30+
"""
31+
if app is not None:
32+
self.init_app(app)
33+
34+
def init_app(self, app: Flask) -> None:
35+
"""Initialize the middleware with a Flask application.
36+
37+
Args:
38+
app: Flask application instance to apply middleware to
39+
"""
40+
app.after_request(self.add_security_headers)
41+
42+
@classmethod
43+
def add_security_headers(cls, response: Response) -> Response:
44+
"""Add security headers to the Flask response.
45+
46+
This is called automatically by Flask after each request.
47+
48+
Args:
49+
response: Flask response object
50+
51+
Returns:
52+
Modified response object with security headers added
53+
"""
54+
for header, value in cls.SECURITY_HEADERS.items():
55+
# Only add header if it doesn't already exist
56+
# This allows specific endpoints to override if needed
57+
if header not in response.headers:
58+
response.headers[header] = value
59+
60+
return response

src/eligibility_signposting_api/model/campaign_config.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,16 @@ def normalize_virtual(cls, value: str) -> Virtual:
138138
class IterationRule(BaseModel):
139139
type: RuleType = Field(..., alias="Type")
140140
name: RuleName = Field(..., alias="Name")
141-
code: RuleCode | None = Field(None, alias="Code", description="use the `rule_code` property instead.")
142-
description: RuleDescription = Field(..., alias="Description", description="use the `rule_text` property instead.")
141+
code: RuleCode | None = Field(None, alias="Code", description="use `rule_code` property instead.")
142+
description: RuleDescription = Field(..., alias="Description", description="use `rule_text` property instead.")
143143
priority: RulePriority = Field(..., alias="Priority")
144144
attribute_level: RuleAttributeLevel = Field(..., alias="AttributeLevel")
145145
attribute_name: RuleAttributeName | None = Field(None, alias="AttributeName")
146-
cohort_label: CohortLabel | None = Field(None, alias="CohortLabel")
146+
cohort_label: CohortLabel | None = Field(
147+
None,
148+
alias="CohortLabel",
149+
description="Raw label input. Prefer using `parsed_cohort_labels` for normalized access.",
150+
)
147151
operator: RuleOperator = Field(..., alias="Operator")
148152
comparator: RuleComparator = Field(..., alias="Comparator")
149153
attribute_target: RuleAttributeTarget | None = Field(None, alias="AttributeTarget")
@@ -197,6 +201,18 @@ def rule_text(self) -> str:
197201
rule_text = rule_entry.rule_text
198202
return rule_text or self.description
199203

204+
@cached_property
205+
def parsed_cohort_labels(self) -> list[str]:
206+
"""
207+
Parses the cohort_label string into a list of individual labels.
208+
209+
Returns:
210+
A list of cohort labels, split by comma. If no label is set, returns an empty list.
211+
"""
212+
if not self.cohort_label:
213+
return []
214+
return [label.strip() for label in self.cohort_label.split(",") if label.strip()]
215+
200216
def __str__(self) -> str:
201217
return json.dumps(self.model_dump(by_alias=True), indent=2)
202218

0 commit comments

Comments
 (0)