Compare commits

..

1 Commits

Author SHA1 Message Date
Andrew Shumway
b6aa359957 Update h11 to 0.16.0 2025-04-24 12:41:21 -06:00
24 changed files with 1508 additions and 2184 deletions

View File

@@ -127,16 +127,6 @@
}
],
"results": {
".github/actions/deploy-proxy/action.yml": [
{
"type": "Hex High Entropy String",
"filename": ".github/actions/deploy-proxy/action.yml",
"hashed_secret": "a6c13f5da3788e8d654cd24001dc79a238723248",
"is_verified": false,
"line_number": 18,
"is_secret": false
}
],
".github/workflows/checks.yml": [
{
"type": "Secret Keyword",
@@ -161,7 +151,7 @@
"filename": ".github/workflows/daily_checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 71,
"line_number": 61,
"is_secret": false
},
{
@@ -169,7 +159,7 @@
"filename": ".github/workflows/daily_checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 87,
"line_number": 77,
"is_secret": false
}
],
@@ -213,6 +203,16 @@
"is_secret": false
}
],
"tests/app/aws/test_s3.py": [
{
"type": "Hex High Entropy String",
"filename": "tests/app/aws/test_s3.py",
"hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747",
"is_verified": false,
"line_number": 42,
"is_secret": false
}
],
"tests/app/clients/test_document_download.py": [
{
"type": "Secret Keyword",
@@ -384,5 +384,5 @@
}
]
},
"generated_at": "2025-06-02T13:22:36Z"
"generated_at": "2025-02-27T21:09:45Z"
}

View File

@@ -15,7 +15,7 @@ inputs:
default: https://github.com/GSA-TTS/cg-egress-proxy.git
proxy_version:
description: git ref to be deployed
default: 1500c67157c1a7a6fbbda7a2de172b3d0a67e703
default: main
runs:
using: composite
steps:

View File

@@ -15,10 +15,4 @@ runs:
python-version: "3.12.3"
- name: Install poetry
shell: bash
run: pip install poetry==2.1.3
- name: Install poetry export
shell: bash
run: poetry self add poetry-plugin-export
- name: Downgrade virtualenv to compatible version
shell: bash
run: pip install "virtualenv<20.30"
run: pip install poetry==1.8.5

View File

@@ -11,7 +11,3 @@ updates:
interval: "daily"
labels:
- "dependabot" # Custom label to identify Dependabot PRs
assignees:
- "alexjanousekGSA"
reviewers:
- "alexjanousekGSA"

View File

@@ -87,20 +87,12 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --output requirements_tmp.txt --without-hashes
- name: Filter requirements.txt
run: grep -v "oscrypto@ git" requirements_tmp.txt > requirements.txt
- name: Verify requirements.txt
run: ls -l requirements.txt
- name: Print requirements.txt
run: |
echo "Contents of requirements.txt:"
cat requirements.txt
- uses: pypa/gh-action-pip-audit@v1.1.0
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- uses: pypa/gh-action-pip-audit@v1.0.8
with:
inputs: requirements.txt
ignore-vulns: |
PYSEC-2023-312
PYSEC-2022-43162
static-scan:
runs-on: ubuntu-latest

View File

@@ -26,20 +26,10 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --output requirements_tmp.txt --without-hashes
- name: Filter requirements.txt
run: grep -v "oscrypto@ git" requirements_tmp.txt > requirements.txt
- name: Verify requirements.txt
run: ls -l requirements.txt
- name: Print requirements.txt
run: |
echo "Contents of requirements.txt:"
cat requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- uses: pypa/gh-action-pip-audit@v1.1.0
with:
inputs: requirements.txt
ignore-vulns: |
PYSEC-2023-312
- name: Upload pip-audit artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -44,7 +44,7 @@ jobs:
run: make bootstrap
- name: Create requirements.txt
run: poetry export --output requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main

View File

@@ -48,7 +48,7 @@ jobs:
run: make bootstrap
- name: Create requirements.txt
run: poetry export --output requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main

View File

@@ -50,7 +50,7 @@ jobs:
run: make bootstrap
- name: Create requirements.txt
run: poetry export --output requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main

View File

@@ -6,4 +6,4 @@
export http_proxy=$egress_proxy
export https_proxy=$egress_proxy
export NEW_RELIC_PROXY_HOST=$egress_proxy
export no_proxy="apps.internal,s3-fips.us-gov-west-1.amazonaws.com"
export no_proxy="apps.internal"

View File

@@ -16,7 +16,9 @@ GIT_HOOKS_PATH ?= $(shell git config --global core.hooksPath || echo "")
.PHONY: bootstrap
bootstrap: ## Set up everything to run the app
make generate-version-file
poetry sync --no-root
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
poetry run pre-commit install
createdb notification_api || true
createdb test_notification_api || true
@@ -25,7 +27,9 @@ bootstrap: ## Set up everything to run the app
.PHONY: bootstrap-with-git-hooks
bootstrap-with-git-hooks: ## Sets everything up and accounts for pre-existing git hooks
make generate-version-file
poetry sync --no-root
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
git config --global --unset-all core.hooksPath
poetry run pre-commit install
git config --global core.hookspath "${GIT_HOOKS_PATH}"
@@ -110,19 +114,19 @@ test: ## Run tests and create coverage report
.PHONY: py-lock
py-lock: ## Syncs dependencies and updates lock file without performing recursive internal updates
poetry sync --no-root
poetry lock
poetry lock --no-update
poetry install --sync
.PHONY: freeze-requirements
freeze-requirements: ## Pin all requirements including sub dependencies into requirements.txt
poetry export --output > requirements.txt
poetry export --without-hashes --format=requirements.txt > requirements.txt
.PHONY: audit
audit:
poetry requirements > requirements.txt
poetry requirements --dev > requirements_for_test.txt
poetry run pip-audit -r requirements.txt --skip-editable
poetry run pip-audit -r requirements_for_test.txt --skip-editable
poetry run pip-audit -r requirements.txt
poetry run pip-audit -r requirements_for_test.txt
.PHONY: static-scan
static-scan:

View File

@@ -221,7 +221,7 @@ If you don't have a line for your `$PATH` environment variable, add it in like
this, which will include the PostgreSQL binaries:
```
export PATH="/opt/homebrew/opt/postgresql@15/bin:$PATH"
export PATH="/opt/homebrew/opt/postgresql@15/bin:$PATH
```
_NOTE: You don't want to overwrite your existing `$PATH` environment variable! Hence the reason why it is included on the end like this; paths are separated by a colon._
@@ -339,21 +339,6 @@ you'll be set with an upgraded version of Python.
_If you're not sure about the details of your current virtual environment, you can run `poetry env info` to get more information. If you've been using `pyenv` for everything, you can also see all available virtual environments with `pyenv virtualenvs`._
#### Poetry upgrades ####
If you are doing a new project setup, then after you install poetry you need to install the export plugin
```sh
poetry self add poetry-plugin-export
```
If you are upgrading from poetry 1.8.5, you need to do this:
```sh
curl -sSL https://install.python-poetry.org | python3 - --version 2.1.3
poetry self add poetry-export-plugin
```
### Final environment setup
There's one final thing to adjust in the newly created `.env` file. This
@@ -477,8 +462,6 @@ instructions above for more details.
- [Onboarding](./docs/all.md#onboarding)
- [Setting up the infrastructure](./docs/all.md#setting-up-the-infrastructure)
- [Using the logs](./docs/all.md#using-the-logs)
- [`git` hooks](./docs/all.md#git-hooks)
- [detect-secrets pre-commit plugin](./docs/all.md#detect-secrets-pre-commit-plugin)
- [Testing](./docs/all.md#testing)
- [CI testing](./docs/all.md#ci-testing)
- [Manual testing](./docs/all.md#manual-testing)
@@ -524,10 +507,8 @@ instructions above for more details.
- [Deploying to Production](./docs/all.md#-deploying-to-production)
- [Smoke-testing the App](./docs/all.md#-smoke-testing-the-app)
- [Configuration Management](./docs/all.md#-configuration-management)
- [DNS and Domain Changes](./docs/all.md#-dns-and-domain-changes)
- [Exporting daily scan results for compliance monitoring](./docs/all.md#exporting-daily-scan-results-for-compliance-monitoring)
- [Reviewing daily scan results for compliance](./docs/all.md#reviewing-daily-scan-results-for-compliance)
- [Rotating environment variable secrets](./docs/all.md#rotating-environment-variable-secrets)
- [DNS Changes](./docs/all.md#-dns-changes)
- [Exporting test results for compliance monitoring](./docs/all.md#exporting-test-results-for-compliance-monitoring)
- [Known Gotchas](./docs/all.md#-known-gotchas)
- [User Account Management](./docs/all.md#-user-account-management)
- [SMS Phone Number Management](./docs/all.md#-sms-phone-number-management)

View File

@@ -5,7 +5,7 @@ import string
import time
import uuid
from contextlib import contextmanager
from threading import Lock
from multiprocessing import Manager
from time import monotonic
from celery import Celery, Task, current_task
@@ -32,9 +32,6 @@ from notifications_utils.clients.encryption.encryption_client import Encryption
from notifications_utils.clients.redis.redis_client import RedisClient
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
job_cache = {}
job_cache_lock = Lock()
class NotifyCelery(Celery):
def init_app(self, app):
@@ -155,6 +152,9 @@ def create_app(application):
redis_store.init_app(application)
document_download_client.init_app(application)
manager = Manager()
application.config["job_cache"] = manager.dict()
register_blueprint(application)
# avoid circular imports by importing this file later

View File

@@ -2,14 +2,13 @@ import csv
import datetime
import re
import time
from concurrent.futures import ThreadPoolExecutor
from io import StringIO
import botocore
import eventlet
from boto3 import Session
from flask import current_app
from app import job_cache, job_cache_lock
from app.clients import AWS_CLIENT_CONFIG
from notifications_utils import aware_utcnow
@@ -25,33 +24,31 @@ s3_client = None
s3_resource = None
def get_service_id_from_key(key):
key = key.replace("service-", "")
key = key.split("/")
key = key[0].replace("-notify", "")
return key
def set_job_cache(key, value):
# current_app.logger.debug(f"Setting {key} in the job_cache to {value}.")
with job_cache_lock:
job_cache[key] = (value, time.time() + 8 * 24 * 60 * 60)
current_app.logger.debug(f"Setting {key} in the job_cache.")
job_cache = current_app.config["job_cache"]
job_cache[key] = (value, time.time() + 8 * 24 * 60 * 60)
def get_job_cache(key):
job_cache = current_app.config["job_cache"]
ret = job_cache.get(key)
if ret is None:
current_app.logger.warning(f"Could not find {key} in the job_cache.")
else:
current_app.logger.debug(f"Got {key} from job_cache.")
return ret
def len_job_cache():
job_cache = current_app.config["job_cache"]
ret = len(job_cache)
current_app.logger.debug(f"Length of job_cache is {ret}")
return ret
def clean_cache():
job_cache = current_app.config["job_cache"]
current_time = time.time()
keys_to_delete = []
for key, (_, expiry_time) in job_cache.items():
@@ -61,9 +58,8 @@ def clean_cache():
current_app.logger.debug(
f"Deleting the following keys from the job_cache: {keys_to_delete}"
)
with job_cache_lock:
for key in keys_to_delete:
del job_cache[key]
for key in keys_to_delete:
del job_cache[key]
def get_s3_client():
@@ -77,7 +73,7 @@ def get_s3_client():
aws_secret_access_key=secret_key,
region_name=region,
)
s3_client = session.client("s3", config=AWS_CLIENT_CONFIG)
s3_client = session.client("s3")
return s3_client
@@ -120,9 +116,9 @@ def list_s3_objects():
)
else:
break
except Exception as e:
except Exception:
current_app.logger.exception(
f"An error occurred while regenerating cache #notify-debug-admin-1200: {str(e)}",
"An error occurred while regenerating cache #notify-debug-admin-1200",
)
@@ -188,24 +184,23 @@ def read_s3_file(bucket_name, object_key, s3res):
"""
try:
job_id = get_job_id_from_s3_object_key(object_key)
service_id = get_service_id_from_key(object_key)
if get_job_cache(job_id) is None:
job = (
object = (
s3res.Object(bucket_name, object_key)
.get()["Body"]
.read()
.decode("utf-8")
)
set_job_cache(job_id, job)
set_job_cache(f"{job_id}_phones", extract_phones(job, service_id, job_id))
set_job_cache(job_id, object)
set_job_cache(f"{job_id}_phones", extract_phones(object))
set_job_cache(
f"{job_id}_personalisation",
extract_personalisation(job),
extract_personalisation(object),
)
except Exception as e:
current_app.logger.exception(str(e))
except LookupError:
# perhaps our key is not formatted as we expected. If so skip it.
current_app.logger.exception("LookupError #notify-debug-admin-1200")
def get_s3_files():
@@ -220,21 +215,11 @@ def get_s3_files():
current_app.logger.info(
f"job_cache length before regen: {len_job_cache()} #notify-debug-admin-1200"
)
count = 0
try:
for object_key in object_keys:
read_s3_file(bucket_name, object_key, s3res)
count = count + 1
eventlet.sleep(0.2)
with ThreadPoolExecutor() as executor:
executor.map(lambda key: read_s3_file(bucket_name, key, s3res), object_keys)
except Exception:
current_app.logger.exception(
f"Trouble reading {object_key} which is # {count} during cache regeneration"
)
except OSError as e:
current_app.logger.exception(
f"Egress proxy issue reading {object_key} which is # {count}"
)
raise e
current_app.logger.exception("Connection pool issue")
current_app.logger.info(
f"job_cache length after regen: {len_job_cache()} #notify-debug-admin-1200"
@@ -304,7 +289,9 @@ def file_exists(file_location):
def get_job_location(service_id, job_id):
current_app.logger.debug(
f"#notify-debug-s3-partitioning NEW JOB_LOCATION: {NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
)
return (
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id),
@@ -320,7 +307,9 @@ def get_old_job_location(service_id, job_id):
but it will take a few days where we have to support both formats.
Remove this when everything works with the NEW_FILE_LOCATION_STRUCTURE.
"""
current_app.logger.debug(
f"#notify-debug-s3-partitioning OLD JOB LOCATION: {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
)
return (
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
FILE_LOCATION_STRUCTURE.format(service_id, job_id),
@@ -386,7 +375,7 @@ def get_job_from_s3(service_id, job_id):
)
retries += 1
sleep_time = backoff_factor * (2**retries) # Exponential backoff
eventlet.sleep(sleep_time)
time.sleep(sleep_time)
continue
else:
# Typically this is "NoSuchKey"
@@ -459,6 +448,7 @@ def extract_personalisation(job):
def get_phone_number_from_s3(service_id, job_id, job_row_number):
job = get_job_cache(job_id)
if job is None:
current_app.logger.debug(f"job {job_id} was not in the cache")
job = get_job_from_s3(service_id, job_id)
# Even if it is None, put it here to avoid KeyErrors
set_job_cache(job_id, job)
@@ -472,16 +462,8 @@ def get_phone_number_from_s3(service_id, job_id, job_row_number):
)
return "Unavailable"
phones = get_job_cache(f"{job_id}_phones")
if phones is None:
current_app.logger.debug("HAVE TO REEXTRACT PHONES!")
phones = extract_phones(job, service_id, job_id)
set_job_cache(f"{job_id}_phones", phones)
current_app.logger.debug(f"SETTING PHONES TO {phones}")
else:
phones = phones[
0
] # we only want the phone numbers not the cache expiration time
phones = extract_phones(job, service_id, job_id)
set_job_cache(f"{job_id}_phones", phones)
# If we can find the quick dictionary, use it
phone_to_return = phones[job_row_number]
@@ -500,6 +482,7 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number):
# So this is a little recycling mechanism to reduce the number of downloads.
job = get_job_cache(job_id)
if job is None:
current_app.logger.debug(f"job {job_id} was not in the cache")
job = get_job_from_s3(service_id, job_id)
# Even if it is None, put it here to avoid KeyErrors
set_job_cache(job_id, job)
@@ -517,9 +500,7 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number):
)
return {}
personalisation = get_job_cache(f"{job_id}_personalisation")
if personalisation is None:
set_job_cache(f"{job_id}_personalisation", extract_personalisation(job))
set_job_cache(f"{job_id}_personalisation", extract_personalisation(job))
return get_job_cache(f"{job_id}_personalisation")[0].get(job_row_number)

View File

@@ -1,6 +1,6 @@
import json
from time import sleep
import eventlet
from celery.signals import task_postrun
from flask import current_app
from requests import HTTPError, RequestException, request
@@ -83,7 +83,7 @@ def process_job(job_id, sender_id=None):
process_row(row, template, job, service, sender_id=sender_id)
count = count + 1
if count % 3 == 0:
eventlet.sleep(1)
sleep(1)
# End point/Exit point for message send flow.
job_complete(job, start=start)

View File

@@ -151,7 +151,7 @@ class AwsCloudwatchClient(Client):
# result = temp_client.get_query_results(queryId=query_id)
# if result['status'] == 'Complete':
# break
# eventlet.sleep(1)
# time.sleep(1)
# delivery_receipts = []
# for log in result['results']:

View File

@@ -1,5 +1,4 @@
import itertools
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
@@ -505,10 +504,6 @@ def get_all_notifications_for_service(service_id):
if "page_size" in data
else current_app.config.get("PAGE_SIZE")
)
# HARD CODE TO 100 for now. 1000 or 10000 causes reports to time out before they complete (if big)
# Tests are relying on the value in config (20), whereas the UI seems to pass 10000
if page_size > 100:
page_size = 100
limit_days = data.get("limit_days")
include_jobs = data.get("include_jobs", True)
include_from_test_key = data.get("include_from_test_key", False)
@@ -522,8 +517,6 @@ def get_all_notifications_for_service(service_id):
f"get pagination with {service_id} service_id filters {data} \
limit_days {limit_days} include_jobs {include_jobs} include_one_off {include_one_off}"
)
start_time = time.time()
current_app.logger.debug(f"Start report generation with page.size {page_size}")
pagination = notifications_dao.get_notifications_for_service(
service_id,
filter_dict=data,
@@ -535,13 +528,9 @@ def get_all_notifications_for_service(service_id):
include_from_test_key=include_from_test_key,
include_one_off=include_one_off,
)
current_app.logger.debug(f"Query complete at {int(time.time()-start_time)*1000}")
for notification in pagination.items:
if notification.job_id is not None:
current_app.logger.debug(
f"Processing job_id {notification.job_id} at {int(time.time()-start_time)*1000}"
)
notification.personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,

View File

@@ -3,17 +3,6 @@ from flask_socketio import join_room, leave_room
def register_socket_handlers(socketio):
@socketio.on("connect")
def on_connect():
current_app.logger.info(
f"Socket {request.sid} connected from {request.environ.get('HTTP_ORIGIN')}"
)
return True
@socketio.on("disconnect")
def on_disconnect():
current_app.logger.info(f"Socket {request.sid} disconnected")
@socketio.on("join")
def on_join(data): # noqa: F401
room = data.get("room")

View File

@@ -4,7 +4,7 @@ from __future__ import print_function
from flask import Flask
from werkzeug.serving import WSGIRequestHandler
from app import create_app, socketio # noqa: F401
from app import create_app
WSGIRequestHandler.version_string = lambda self: "SecureServer"

View File

@@ -7,7 +7,6 @@
- [Setting up the infrastructure](#setting-up-the-infrastructure)
- [Using the logs](#using-the-logs)
- [`git` hooks](#git-hooks)
- [detect-secrets pre-commit plugin](#detect-secrets-pre-commit-plugin)
- [Testing](#testing)
- [CI testing](#ci-testing)
- [Manual testing](#manual-testing)
@@ -54,10 +53,8 @@
- [Smoke-testing the App](#-smoke-testing-the-app)
- [Simulated bulk send testing](#-simulated-bulk-send-testing)
- [Configuration Management](#-configuration-management)
- [DNS and Domain Changes](#-dns-and-domain-changes)
- [Exporting daily scan results for compliance monitoring](#exporting-daily-scan-results-for-compliance-monitoring)
- [Reviewing daily scan results for compliance](#reviewing-daily-scan-results-for-compliance)
- [Rotating environment variable secrets](#rotating-environment-variable-secrets)
- [DNS Changes](#-dns-changes)
- [Exporting test results for compliance monitoring](#exporting-test-results-for-compliance-monitoring)
- [Known Gotchas](#-known-gotchas)
- [User Account Management](#-user-account-management)
- [SMS Phone Number Management](#-sms-phone-number-management)
@@ -263,17 +260,6 @@ The configuration is stored in `.pre-commit-config.yaml`. In that config, there
We do not maintain any hooks in this repository.
## detect-secrets pre-commit plugin
One of the pre-commit hooks we use is [`detect-secrets`](https://github.com/Yelp/detect-secrets), which checks for all sorts of things that might be committed accidently that should not be. The project is already set up with a baseline file (`.ds.baseline`) and this should just work out of the box, but occasionally it will flag something new when you try and commit something; or, the file may need a refresh after a while. In either case, to get things back on track and update the `.ds.baseline` file, run these two commands:
```sh
detect-secrets scan --baseline .ds.baseline
detect-secrets audit .ds.baseline
```
The second command will walk you through all of the new detected secrets and ask you to validate if they actually are or if they're false positives. Mark off each one as apppropriate (they should all be false positives - if they're not please stop and check in with the team!), then commit the updates to the `.ds.baseline` file and push them remotely so the project stays up-to-date.
# Testing
```
@@ -1082,7 +1068,7 @@ that the security of the system is maintained.
1. [Smoke-testing the App](#smoke-testing)
1. [Simulated bulk send testing](#simulated-bulk-send-testing)
1. [Configuration Management](#cm)
1. [DNS and Domain Changes](#dns)
1. [DNS Changes](#dns)
1. [Known Gotchas](#gotcha)
1. [User Account Management](#ac)
1. [SMS Phone Number Management](#phone-numbers)
@@ -1253,43 +1239,17 @@ US_Notify Administrators are responsible for ensuring that remediations for vuln
- Low - 180 days
- Informational - 365 days (depending on the analysis of the issue)
## <a name="dns"></a> DNS and Domain Changes
## <a name="dns"></a> DNS Changes
Notify.gov DNS records are maintained within [the GSA-TTS/dns repository](https://github.com/GSA-TTS/dns/blob/main/terraform/notify.gov.tf), and the domains and routes are managed directly in our Cloud.gov production space.
Notify.gov DNS records are maintained within [the 18f/dns repository](https://github.com/18F/dns/blob/main/terraform/notify.gov.tf). To create new DNS records for notify.gov or any subdomains:
**Step 1: Make changes to the DNS records**
1. Update the `notify.gov.tf` terraform to update oƒr create the new records within Route53 and push the branch to the 18f/dns repository.
1. Open a PR.
1. Verify that the plan output within circleci creates the records that you expect.
1. Request a PR review from the 18F/tts-tech-portfolio team
1. Once the PR is approved and merged, verify that the apply step happened correctly within [CircleCI](https://app.circleci.com/pipelines/github/18F/dns)
1. If you haven't already, clone a local copy of [the GSA-TTS/dns repository](https://github.com/GSA-TTS/dns).
1. Create a new branch and update the [`notify.gov.tf`]((https://github.com/GSA-TTS/dns/blob/main/terraform/notify.gov.tf)) Terraform file to update, create, or remove DNS records within AWS Route 53.
1. Open a PR in the repository and verify that the plan output within CircleCI makes the changes that you expect.
1. Request a PR review from the `@tts-tech-operations` team within the GSA-TTS GitHub org.
1. Once the PR is approved and merged, verify that the apply step happened correctly within [CircleCI](https://app.circleci.com/pipelines/github/GSA-TTS/dns).
**Step 2: Make changes to the domains and routes in Cloud.gov**
The domains and routes are managed via the [external domain service](https://www.cloud.gov/docs/services/external-domain-service/) within Cloud.gov.
If you're creating new domains:
1. Sign in to the `cf` CLI in your terminal and target the `notify-production` space.
1. Create the new domain(s) with [`cf create-private-domain`](https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html#private-domains).
1. Map the routes needed to the new domain(s) with [`cf map-route`](https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html#map-route).
1. Update the service to account for the new domain(s): `cf update-service notify-admin-domain-production -c '{"domains": "example.gov,www.example.gov,..."}'` (make sure to list *all* domains that need to be accounted for, including any existing ones that you want to keep!).
If you're removing existing domains:
1. Sign in to the `cf` CLI in your terminal and target the `notify-production` space.
1. Unmap the routes to the existing domain(s) with [`cf unmap-route`](https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html#unmap-route).
1. Delete the existing domain(s) with [`cf delete-private-domain`](https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html#private-domains).
1. Update the service to account for the deleted domain(s): `cf update-service notify-admin-domain-production -c '{"domains": "example.gov,www.example.gov,..."}'` (make sure to list *all* domains that need to be accounted for, including any existing ones that you want to keep!).
**Step 3: Redeploy or restage the Admin app:**
Restage or redeploy the `notify-admin-production` app. To restage, you can trigger the action in GitHub or run the command directly: `cf restage notify-admin-production --strategy rolling`.
Test that the changes took effect properly by going to the domain(s) that were adjusted and seeing if they resolve correctly and/or no longer resolve as expected. Note that this may take up to 72 hours, depending on how long it takes for the DNS changes to propogate.
## Exporting daily scan results for compliance monitoring
## Exporting test results for compliance monitoring
- Head to https://github.com/GSA/notifications-api/actions/workflows/daily_checks.yml
- Open the most recent scan (it should be today's)
@@ -1301,115 +1261,16 @@ Test that the changes took effect properly by going to the domain(s) that were a
- Rename to `api_static_scan_DATE.zip` and add it to 🔒 https://drive.google.com/drive/folders/1dSe9H7Ag_hLfi5hmQDB2ktWaDwWSf4_R
- Repeat for https://github.com/GSA/notifications-admin/actions/workflows/daily_checks.yml
## Reviewing daily scan results for compliance
## Rotating the DANGEROUS_SALT
To review the daily scan results and check for any new reported findings that need to be remediated, perform the following steps.
**For the API**
1. Go to the daily scan page: https://github.com/GSA/notifications-api/actions/workflows/daily_checks.yml
1. Click on the latest scan (it should have run on the current day and be at the time)
1. Scroll to the bottom and download the two artifacts: `bandit-report` and `zap_scan` - these are zip files that contain the full scan reports
1. Click on the `pip-audit` job in the menu on the left of the screen
1. Click on the `Run pypa/gh-action-pip-audit` step (the version number may change over time as it gets updated)
1. Check that the output of the step doesn't show any new audit findings (the step and job will have failed if it did)
1. Click on the `static-scan` job in the menu on the left of the screen
1. Click on the `Run scan` step
1. Check that the output of the step doesn't show any new scan findings (note: the step and job may still show as successful even if something was found)
1. Click on the `dynamic-scan` job in the menu on the left of the screen
1. Click on the `Run OWASP API Scan` step
1. Check that the output of the step doesn't show any new scan findings (note: the step and job may still show as successful even if something was found)
Once you're done performing the steps above to gather all of the information, make a note of any new findings that need to be accounted for and remediated and create issues to track the work.
**For the Admin**
1. Go to the daily scan page: https://github.com/GSA/notifications-admin/actions/workflows/daily_checks.yml
1. Click on the latest scan (it should have run on the current day and be at the time)
1. Scroll to the bottom and download the artifact: `zap_scan` - this is a zip file that contains the full scan reports
1. Click on the `dependency-audits` job in the menu on the left of the screen
1. Click on the `Run pypa/gh-action-pip-audit` step (the version number may change over time as it gets updated)
1. Check that the output of the step doesn't show any new audit findings (the step and job will have failed if it did)
1. Click on the `Run npm audit` step
1. Check that the output of the step doesn't show any new audit findings (the step and job will have failed if it did)
1. Click on the `static-scan` job in the menu on the left of the screen
1. Click on the `Run scan` step
1. Check that the output of the step doesn't show any new scan findings (note: the step and job may still show as successful even if something was found)
1. Click on the `dynamic-scan` job in the menu on the left of the screen
1. Click on the `Run OWASP Full Scan` step
1. Check that the output of the step doesn't show any new scan findings (note: the step and job may still show as successful even if something was found)
Once you're done performing the steps above to gather all of the information, make a note of any new findings that need to be accounted for and remediated and create issues to track the work.
## Rotating environment variable secrets
There are a few different ways to handle rotating environment variable secrets, depending on what the secret is.
### Secret environment variables (set directly)
The `ADMIN_CLIENT_SECRET`, `DANGEROUS_SALT`, and `SECRET_KEY` environment variables are all generated random strings of characters. To make a new value for any of these environment variables, perform the following steps:
1. Start the API locally with the command `make run-procfile`
1. In a separate terminal tab, navigate to the API project and run `poetry run flask command generate-salt` (this command is found in the [`app/commands.py` file](https://github.com/GSA/notifications-api/blob/main/app/commands.py#L1030-L1037))
1. A random secret will appear in the tab, which you will use to update the value(s) in GitHub
Next, you'll need to go into GitHub for either the [API repo environment settings](https://github.com/GSA/notifications-api/settings/environments) or [Admin repo environment settings](https://github.com/GSA/notifications-admin/settings/environments). Once there you'll see a list of all of the environments; click into the one that you're looking to update and then find the corresponding environment that you need to update. Click on the pencil icon to the right of the environment variable name to edit the value, then paste in the value you generated with the previous steps.
**NOTE:** These values must match between the API and Admin environment variables per environment (meaning, if you change the Admin repo value for any of these values in any environment, the same variable for the API in the same environment must be changed to match it!).
The important thing is to use the same secret for Admin and API on each tier -- i.e. you only generate three secrets per environment.
**NOTE:** You may also have to update these values for Dependabot as well! To do this, go into GitHub and the navigate through `Settings -> Secrets and variables -> Dependabot`, which will take you to a special page to manage environment variables specifically for Dependabot. This is more necessary in the Admin repo because of the E2E tests.
### E2E environment variables (set directly)
See the [end-to-end testing section](#end-to-end-testing).
### Service bindings for Cloud.gov-managed services
For any Cloud.gov service instance that you need to rotate credentials for, you need to run the following commands:
1. `cf unbind-service <APP NAME> <SERVICE NAME>`
1. `cf bind-service <APP NAME> <SERVICE NAME>`
Once you are done unbinding and re-binding all services you're looking to rotate credentials for, you need to restage or redeploy the application(s) for the changes to take effect. You can restage directly in the command line: `cf restage <APP NAME> --strategy rolling`
### Rotating New Relic API keys and licenses
To rotate New Relic API key, license key, and other credentials, you need access to New Relic. If you have access, sign in and then click on your name in the lower left. Click on `API keys` and you'll be taken to the management screen for all of the API keys. From there, perform these steps:
1. Create new versions of whichever key(s) you would like to rotate
1. Update the corresponding environment variable(s) in GitHub for both the [API repo environment settings](https://github.com/GSA/notifications-api/settings/environments) and the [Admin repo environment settings](https://github.com/GSA/notifications-admin/settings/environments)
1. Restage or redeploy the applications
1. Once you confirm the new key(s) in New Relic are working, delete the old keys on the API Key management screen
### Terraform state bucket key rotation
To rotate the Terraform state bucket key, run these commands in the `api/terraform/bootstrap` directory of the API repo:
```sh
# comment out prevent_destroy in terraform/bootstrap/main.tf
# update username to create in run.sh and teardown-creds.sh
$ ./run.sh plan -replace=cloudfoundry_service_key.bucket_creds
$ ./run.sh apply -replace=cloudfoundry_service_key.bucket_creds
```
Once that's done, copy the key generating to the staging, demo, and production environments of both the API and the Admin.
### Refreshing/rotating the Login.gov certificate
1. generate certificate: `openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.crt -nodes`
1. update the github secrets for staging, demo, production (contents of key.pem go in LOGIN_PEM and contents of cert.crt in LOGIN_PUB). **DO NOT RESTAGE YET**.
1. use the same certificate for staging, demo, and production
1. login to the login.gov partner app (https://portal.int.identitysandbox.gov)
1. add the new certificate to the production version of Notify in the partner app (our partner app account has sandbox and production)
1. Make a Zendesk support request for login.gov to push the new version of Notify (https://zendesk.login.gov)
1. Do not delete the old certificate, because you need things to keep working until you complete the transition.
1. When you receive an email from login.gov that the app has been pushed successfully, restage notify on the staging tier
1. If staging works, you can restage demo and production
1. Delete the old certificate in the partner app, send another zendesk request to push again. This is best practice but a lower priority, because certificates eventually expire anyway and we have changed the certificate in github secrets, so the old cert is no longer relevant.
1. Start API locally `make run-procfile`
2. In a separate terminal tab, navigate to the API project and run `poetry run flask command generate-salt`
3. A random secret will appear in the tab
4. Go to github->settings->secrets and variables->actions in the admin project and find the DANGEROUS_SALT secret for the admin project for staging. Open it and paste the result of #3 into the secret and save. Repeat for the API project, for staging.
5. Repeat #3 and #4 but do it for demo
6. Repeat #3 and #4 but do it for production
The important thing is to use the same secret for Admin and API on each tier--i.e. you only generate three secrets.
## <a name="gotcha"></a> Known Gotchas
@@ -1646,3 +1507,19 @@ Note: better to search on space 'notify-production' rather than specifically for
#notify-admin-1505 (general login issues)
#notify-admin-1701 (wrong sender phone number)
#notify-admin-1859 (job is created with created_at being the wrong time)
### refreshing the login.gov certificate
1. generate certificate: `openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.crt -nodes`
2. update the github secrets for staging, demo, production (contents of key.pem go in LOGIN_PEM and contents of cert.crt in LOGIN_PUB).
DO NOT RESTAGE YET.
3. use the same certificate for staging, demo, and production
4. login to the login.gov partner app (https://portal.int.identitysandbox.gov)
5. add the new certificate to the production version of Notify in the partner app (our partner app account has sandbox and production)
6. Make a Zendesk support request for login.gov to push the new version of Notify (https://zendesk.login.gov)
7. Do not delete the old certificate, because you need things to keep working until you complete the transition.
8. When you receive an email from login.gov that the app has been pushed successfully, restage notify on the staging tier
9. If staging works, you can restage demo and production
10. Delete the old certificate in the partner app, send another zendesk request to push again. This is best practice but a lower
priority, because certificates eventually expire anyway and we have changed the certificate in github secrets, so the old cert is
no longer relevant.

View File

@@ -570,7 +570,7 @@ paths:
reference:
type: string
example:
phone_number: "800-555-0100"
phone_number: "2028675309"
template_id: "85b58733-7ebf-494e-bee2-a21a4ce17d58"
personalisation:
variable: "value"

3143
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
[tool.poetry]
name = "notifications-api"
package-mode = false
version = "0.1.0"
description = "Notify.gov backend"
authors = ["Your Name <you@example.com>"]
@@ -9,25 +8,25 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12.2"
alembic = "==1.16.1"
alembic = "==1.13.2"
amqp = "==5.3.1"
beautifulsoup4 = "==4.13.4"
beautifulsoup4 = "==4.12.3"
boto3 = "^1.34.150"
botocore = "^1.34.159"
cachetools = "==6.0.0"
celery = {version = "==5.5.2", extras = ["redis"]}
cachetools = "==5.4.0"
celery = {version = "==5.4.0", extras = ["redis"]}
certifi = ">=2022.12.7"
cffi = "==1.17.1"
charset-normalizer = "^3.4.2"
click = "==8.2.1"
charset-normalizer = "^3.4.1"
click = "==8.1.8"
click-datetime = "==0.4.0"
click-didyoumean = "==0.3.1"
click-plugins = "==1.1.1"
click-repl = "==0.3.0"
deprecated = "==1.2.14"
eventlet = "==0.36.1"
eventlet = "==0.39.1"
expiringdict = "==1.2.2"
flask = "~=3.1"
flask = "~=3.0"
flask-bcrypt = "==1.0.1"
flask-marshmallow = "==1.2.1"
flask-migrate = "==4.1.0"
@@ -35,37 +34,37 @@ flask-redis = "==0.4.0"
flask-sqlalchemy = "==3.1.1"
gunicorn = {version = "==23.0.0", extras = ["eventlet"]}
iso8601 = "==2.1.0"
jsonschema = {version = "==4.24.0", extras = ["format"]}
lxml = "==5.4.0"
jsonschema = {version = "==4.23.0", extras = ["format"]}
lxml = "==5.3.1"
marshmallow = "==3.26.1"
marshmallow-sqlalchemy = "==1.0.0"
newrelic = "*"
notifications-python-client = "==10.0.1"
notifications-python-client = "==10.0.0"
oscrypto = { git = "https://github.com/wbond/oscrypto.git", rev = "1547f53" }
packaging = "==25.0"
poetry-dotenv-plugin = "==0.2.0"
psycopg2-binary = "==2.9.9"
pyjwt = "==2.10.1"
python-dotenv = "==1.1.0"
sqlalchemy = "==2.0.41"
python-dotenv = "==1.0.1"
sqlalchemy = "==2.0.31"
werkzeug = "^3.0.6"
faker = "^37.3.0"
faker = "^26.0.0"
async-timeout = "^5.0.1"
bleach = "^6.1.0"
geojson = "^3.2.0"
numpy = "^2.2.6"
numpy = "^2.2.5"
ordered-set = "^4.1.0"
phonenumbers = "^9.0.6"
phonenumbers = "^8.13.42"
python-json-logger = "^3.3.0"
regex = "^2024.11.6"
shapely = "^2.1.1"
shapely = "^2.0.5"
smartypants = "^2.0.1"
mistune = "^3.1.3"
blinker = "^1.9.0"
cryptography = "^45.0.3"
cryptography = "^44.0.1"
idna = "^3.7"
jmespath = "^1.0.1"
markupsafe = "^3.0.2"
markupsafe = "^2.1.5"
pycparser = "^2.22"
python-dateutil = "^2.9.0.post0"
pyyaml = "^6.0.2"
@@ -78,7 +77,6 @@ jinja2 = "^3.1.6"
redis = "^5.0.8"
requests = "^2.32.3"
flask-socketio = "^5.5.1"
virtualenv = "<20.30"
[tool.poetry.group.dev.dependencies]
@@ -86,33 +84,29 @@ awscli = "^1.33.32"
bandit = "*"
black = "^25.1.0"
cloudfoundry-client = "*"
exceptiongroup = "==1.3.0"
exceptiongroup = "==1.2.2"
flake8 = "^7.2.0"
flake8-bugbear = "^24.12.12"
freezegun = "^1.5.2"
freezegun = "^1.5.1"
honcho = "*"
isort = "^6.0.1"
isort = "^5.13.2"
jinja2-cli = {version = "==0.8.2", extras = ["yaml"]}
moto = "==5.1.5"
moto = "==5.1.4"
pip-audit = "*"
pre-commit = "^4.2.0"
pytest = "^8.3.2"
pytest-env = "^1.1.3"
pytest-mock = "^3.14.1"
pytest-mock = "^3.14.0"
pytest-cov = "^6.1.1"
pytest-xdist = "^3.7.0"
pytest-xdist = "^3.5.0"
radon = "^6.0.1"
requests-mock = "^1.11.0"
setuptools = "^80.9.0"
setuptools = "^75.8.0"
sqlalchemy-utils = "^0.41.2"
vulture = "^2.10"
detect-secrets = "^1.5.0"
poetry-dotenv-plugin = "^0.2.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.plugins."poetry.application.plugin"]
dotenv = "poetry_dotenv_plugin.plugin:PoetryDotenvPlugin"

View File

@@ -1,7 +1,7 @@
import os
from datetime import timedelta
from os import getenv
from unittest.mock import ANY, MagicMock, Mock, call, patch
from unittest.mock import MagicMock, Mock, call, patch
import botocore
import pytest
@@ -39,7 +39,7 @@ default_region = getenv("CSV_AWS_REGION")
def single_s3_object_stub(key="foo", last_modified=None):
return {
"ETag": '"d"',
"ETag": '"d41d8cd98f00b204e9800998ecf8427e"',
"Key": key,
"LastModified": last_modified or utc_now(),
}
@@ -221,6 +221,20 @@ def test_get_s3_file_makes_correct_call(notify_api, mocker):
2,
"5555555552",
),
(
# simulate file saved with utf8withbom
"\\ufeffPHONE NUMBER\n",
"eee",
2,
"5555555552",
),
(
# simulate file saved without utf8withbom
"\\PHONE NUMBER\n",
"eee",
2,
"5555555552",
),
],
)
def test_get_phone_number_from_s3(
@@ -228,7 +242,6 @@ def test_get_phone_number_from_s3(
):
get_job_mock = mocker.patch("app.aws.s3.get_job_from_s3")
get_job_mock.return_value = job
phone_number = get_phone_number_from_s3("service_id", job_id, job_row_number)
assert phone_number == expected_phone_number
@@ -407,17 +420,29 @@ def test_get_s3_files_success(client, mocker):
"CSV_UPLOAD_BUCKET": {"bucket": "test-bucket"},
"job_cache": {},
}
mock_thread_pool_executor = mocker.patch("app.aws.s3.ThreadPoolExecutor")
mock_read_s3_file = mocker.patch("app.aws.s3.read_s3_file")
mock_list_s3_objects = mocker.patch("app.aws.s3.list_s3_objects")
mock_get_s3_resource = mocker.patch("app.aws.s3.get_s3_resource")
mock_list_s3_objects.return_value = ["file1.csv", "file2.csv"]
mock_s3_resource = MagicMock()
mock_get_s3_resource.return_value = mock_s3_resource
mock_executor = MagicMock()
def mock_map(func, iterable):
for item in iterable:
func(item)
mock_executor.map.side_effect = mock_map
mock_thread_pool_executor.return_value.__enter__.return_value = mock_executor
get_s3_files()
# mock_current_app.config.__getitem__.assert_called_once_with("CSV_UPLOAD_BUCKET")
mock_list_s3_objects.assert_called_once()
mock_thread_pool_executor.assert_called_once()
mock_executor.map.assert_called_once()
calls = [
(("test-bucket", "file1.csv", mock_s3_resource),),
@@ -448,7 +473,7 @@ def test_get_s3_client(mocker):
mock_session.return_value.client.return_value = mock_s3_client
result = get_s3_client()
mock_session.return_value.client.assert_called_once_with("s3", config=ANY)
mock_session.return_value.client.assert_called_once_with("s3")
assert result == mock_s3_client
@@ -590,6 +615,15 @@ def test_get_s3_files_handles_exception(mocker):
mock_read_s3_file = mocker.patch(
"app.aws.s3.read_s3_file", side_effect=[None, Exception("exception here")]
)
mock_thread_pool_executor = mocker.patch("app.aws.s3.ThreadPoolExecutor")
mock_executor = mock_thread_pool_executor.return_value.__enter__.return_value
def mock_map(func, iterable):
for item in iterable:
func(item)
mock_executor.map.side_effect = mock_map
get_s3_files()
calls = [
@@ -598,6 +632,4 @@ def test_get_s3_files_handles_exception(mocker):
]
mock_read_s3_file.assert_has_calls(calls, any_order=True)
mock_current_app.logger.exception.assert_called_with(
"Trouble reading file2.csv which is # 1 during cache regeneration"
)
mock_current_app.logger.exception.assert_called_with("Connection pool issue")