Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 4 additions & 2 deletions .github/workflows/check-cosmetology-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ jobs:

- name: Upgrade pip
# Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219.
run: pip install --upgrade 'pip>=26.1'
# Held below 26.2, which drops pip._internal.utils.compat.stdlib_pkgs and breaks pip-sync.
run: pip install --upgrade 'pip>=26.1,<26.2'

- name: Install dev dependencies
run: "pip install -r backend/cosmetology-app/requirements-dev.txt"
Expand Down Expand Up @@ -87,7 +88,8 @@ jobs:

- name: Upgrade pip
# Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219.
run: pip install --upgrade 'pip>=26.1'
# Held below 26.2, which drops pip._internal.utils.compat.stdlib_pkgs and breaks pip-sync.
run: pip install --upgrade 'pip>=26.1,<26.2'

# Setup Node
- name: Setup Node
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/check-social-work-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ jobs:

- name: Upgrade pip
# Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219.
run: pip install --upgrade 'pip>=26.1'
# Held below 26.2, which drops pip._internal.utils.compat.stdlib_pkgs and breaks pip-sync.
run: pip install --upgrade 'pip>=26.1,<26.2'

- name: Install dev dependencies
run: "pip install -r backend/social-work-app/requirements-dev.txt"
Expand Down Expand Up @@ -87,7 +88,8 @@ jobs:

- name: Upgrade pip
# Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219.
run: pip install --upgrade 'pip>=26.1'
# Held below 26.2, which drops pip._internal.utils.compat.stdlib_pkgs and breaks pip-sync.
run: pip install --upgrade 'pip>=26.1,<26.2'

# Setup Node
- name: Setup Node
Expand Down
6 changes: 5 additions & 1 deletion backend/common-cdk/common_constructs/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ def license_types(self):
@cached_property
def common_env_vars(self):
return {
'DEBUG': 'true',
# DEBUG-level logging can include sensitive request/response data, so it must
# not be enabled by default. It remains available to aid technical support teams.
# Environment variables may be updated for individual lambdas as needed through
# AWS by support staff with administrator access.
'DEBUG': 'false',
Comment thread
jlkravitz marked this conversation as resolved.
'ALLOWED_ORIGINS': json.dumps(self.allowed_origins),
'COMPACTS': json.dumps(self.node.get_context('compacts')),
'JURISDICTIONS': json.dumps(self.node.get_context('jurisdictions')),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class Lambda implements LambdaInterface {
logger.info('Processing Cognito custom message event', {
triggerSource: event.triggerSource,
userPoolId: event.userPoolId,
userName: event.userName
userName: this.emailService.maskEmail(event.userName)
});

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export abstract class BaseEmailService {
return `${environmentVariableService.getUiBasePathUrl()}/img/email`;
}

protected maskEmail(email: string): string {
public maskEmail(email: string): string {
const at = email.indexOf('@');

if (at <= 0) {
Expand All @@ -77,7 +77,7 @@ export abstract class BaseEmailService {
return `${email[0]}***${email.slice(at)}`;
}

protected maskEmails(emails: string[]): string[] {
public maskEmails(emails: string[]): string[] {
return emails.map((email) => this.maskEmail(email));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class IngestEventEmailService extends BaseEmailService {
jurisdiction: string,
recipients: string[]
) {
this.logger.info('Sending report email', { recipients: recipients });
this.logger.info('Sending report email', { recipients: this.maskEmails(recipients) });

// Generate the HTML report
const htmlContent = this.generateReport(events, compactName, jurisdiction);
Expand All @@ -31,7 +31,7 @@ export class IngestEventEmailService extends BaseEmailService {
}

public async sendAllsWellEmail(compactName: string, jurisdiction: string, recipients: string[]) {
this.logger.info('Sending alls well email', { recipients: recipients });
this.logger.info('Sending alls well email', { recipients: this.maskEmails(recipients) });

// Generate the HTML report
const report = this.getNewEmailTemplate();
Expand All @@ -52,7 +52,7 @@ export class IngestEventEmailService extends BaseEmailService {
}

public async sendNoLicenseUpdatesEmail(compactName: string, jurisdiction: string, recipients: string[]) {
this.logger.info('Sending no license updates email', { recipients: recipients });
this.logger.info('Sending no license updates email', { recipients: this.maskEmails(recipients) });

// Generate the HTML report
const report = this.getNewEmailTemplate();
Expand Down
2 changes: 1 addition & 1 deletion backend/compact-connect/lambdas/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "NodeJS lambdas for CompactConnect",
"resolutions": {
"fast-xml-parser": "5.7.3",
"postcss": "8.5.12"
"postcss": "8.5.24"
},
"scripts": {
"build": "tsc",
Expand Down
18 changes: 9 additions & 9 deletions backend/compact-connect/lambdas/nodejs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4656,10 +4656,10 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==

napi-postinstall@^0.3.4:
version "0.3.4"
Expand Down Expand Up @@ -4870,12 +4870,12 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"

postcss@8.5.12, postcss@^8.3.11:
version "8.5.12"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb"
integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==
postcss@8.5.24, postcss@^8.3.11:
version "8.5.24"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.24.tgz#01d8b032451e1b9ec41ae66eaf02843f42a720d2"
integrity sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==
dependencies:
nanoid "^3.3.11"
nanoid "^3.3.16"
picocolors "^1.1.1"
source-map-js "^1.2.1"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ def _export_user_pool(self, export_timestamp: str) -> int:
self._export_single_user(user, export_timestamp)
users_exported += 1
except (ClientError, ValueError) as e:
logger.error('Failed to export user', username=user.get('Username', 'unknown'), error=str(e))
# Username is the user's email address, so we log the non-PII 'sub' identifier instead
logger.error(
'Failed to export user',
user_id=self._get_user_sub(user.get('Attributes', [])),
error=str(e),
)
raise

# Check for more pages
Expand Down Expand Up @@ -168,7 +173,12 @@ def _export_single_user(self, user_data: dict[str, Any], export_timestamp: str)
logger.debug('Exported user to S3', username=username, object_key=object_key)

except ClientError as e:
logger.error('Failed to upload user to S3', username=username, error=str(e))
# Username is the user's email address, so we log the non-PII 'sub' identifier instead
logger.error(
'Failed to upload user to S3',
user_id=self._get_user_sub(user_data.get('Attributes', [])),
error=str(e),
)
raise

def _extract_user_attributes(self, attributes: list[dict[str, str]]) -> dict[str, str]:
Expand All @@ -180,6 +190,15 @@ def _extract_user_attributes(self, attributes: list[dict[str, str]]) -> dict[str
"""
return {attr['Name']: attr['Value'] for attr in attributes}

def _get_user_sub(self, attributes: list[dict[str, str]]) -> str:
"""
Extract the non-PII Cognito 'sub' identifier from a list of user attributes, for use in logging.

:param attributes: List of Cognito user attributes
:return: The user's 'sub' value, or 'unknown' if not present
"""
return next((attr['Value'] for attr in attributes if attr['Name'] == 'sub'), 'unknown')


def backup_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG001 unused-argument
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def get_ssn_by_provider_id(self, *, compact: str, provider_id: str) -> str:
raise CCInternalException(f'Expected 1 SSN index record, got {len(resp)}')
return resp[0]['ssn']

@logger_inject_kwargs(logger, 'compact', 'jurisdiction', 'family_name', 'given_name')
@logger_inject_kwargs(logger, 'compact', 'jurisdiction')
def find_matching_license_record(
self,
*,
Expand All @@ -146,6 +146,8 @@ def find_matching_license_record(
:return: The matching license record if found, None otherwise
"""
logger.info('Querying license records', compact=compact, state=jurisdiction)
# family_name/given_name are PII, so they are only logged at DEBUG level
logger.debug('Querying license records details', family_name=family_name, given_name=given_name)

resp = self.config.provider_table.query(
IndexName=self.config.license_gsi_name,
Expand Down Expand Up @@ -243,7 +245,7 @@ def get_provider_user_records(
return ProviderUserRecords(resp['Items'])

@paginated_query(set_query_limit_to_match_page_size=False)
@logger_inject_kwargs(logger, 'compact', 'provider_name', 'jurisdiction')
@logger_inject_kwargs(logger, 'compact', 'jurisdiction')
def get_providers_sorted_by_family_name(
self,
*,
Expand All @@ -255,6 +257,8 @@ def get_providers_sorted_by_family_name(
exclude_providers_without_privileges: bool = False,
):
logger.info('Getting providers by family name')
# provider_name is PII, so it is only logged at DEBUG level
logger.debug('Getting providers by family name details', provider_name=provider_name)

# Create a name value to use in key condition if name fields are provided
name_value = None
Expand Down Expand Up @@ -4372,7 +4376,7 @@ def clear_provider_email_verification_data(
logger.error('Failed to clear provider email verification data', error=str(e))
raise CCAwsServiceException('Failed to clear provider email verification data') from e

@logger_inject_kwargs(logger, 'compact', 'provider_id', 'new_email_address')
@logger_inject_kwargs(logger, 'compact', 'provider_id')
def complete_provider_email_update(
self,
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,18 @@ def add_privilege_information_to_transactions(
line_items=line_items, item_id_prefix=item_id_prefix, privilege_id=privilege_id
)
else:
# raw records can contain PII (e.g. a deactivating staff user's name), so the full items are
# only logged at DEBUG level; the fields below are sufficient for triage at ERROR level
logger.error(
'No matching jurisdiction privilege record found for transaction. '
'Cannot determine privilege id for this transaction',
compact=compact,
transactionId=transaction.transactionId,
jurisdiction=jurisdiction,
provider_id=transaction.licenseeId,
)
logger.debug(
'Matching privilege records for transaction',
matching_privilege_records=response.get('Items', []),
)
# we set the privilege id to UNKNOWN, so that it will be visible in the report
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ def create_user(self, compact: str, attributes: dict, permissions: dict):
:param dict permissions: The permissions for the user
:return:
"""
logger.info('Creating staff user', attributes=attributes)
logger.info('Creating staff user', compact=compact)
# attributes contains PII (email, given/family name), so it is only logged at DEBUG level
logger.debug('Creating staff user with attributes', attributes=attributes)
attributes = self.user_attributes_schema.load(attributes)
permissions = self.compact_permissions_schema.load(permissions)

Expand All @@ -329,7 +331,7 @@ def create_user(self, compact: str, attributes: dict, permissions: dict):

# If the user was previously disabled, re-enable them
if not resp.get('Enabled', True):
logger.info('Re-enabling previously disabled user', user_id=user_id, email=attributes['email'])
logger.info('Re-enabling previously disabled user', user_id=user_id)
self.config.cognito_client.admin_enable_user(
UserPoolId=self.config.user_pool_id, Username=attributes['email']
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,22 @@ def _invoke_lambda(self, payload: dict[str, Any]) -> dict[str, Any]:

if response.get('FunctionError'):
error_message = f'Failed to send email notification: {response.get("FunctionError")}'
self._logger.error(error_message)
self._logger.error(error_message, template=payload.get('template'))
raise CCInternalException(error_message)

return response
except Exception as e:
error_message = f'Error invoking email notification service lambda: {str(e)}'
self._logger.error(error_message, payload=payload, exception=str(e))
# payload is never logged: it can contain PII and credentials (specificEmails, provider names,
# verificationCode, recoveryToken); the non-PII fields below are sufficient for triage
self._logger.error(
error_message,
template=payload.get('template'),
compact=payload.get('compact'),
jurisdiction=payload.get('jurisdiction'),
recipient_type=payload.get('recipientType'),
exception=str(e),
)
raise CCInternalException(error_message) from e

def send_provider_privilege_deactivation_email(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ def _validate_nonce_format(nonce: str) -> None:
import re

if not re.match(r'^[a-zA-Z0-9-]+$', nonce):
logger.warning('Invalid nonce format - contains invalid characters', nonce=nonce)
logger.warning('Invalid nonce format - contains invalid characters')
# the nonce is part of the request signing scheme, so it is only logged at DEBUG level
logger.debug('Invalid nonce format details', nonce=nonce)
raise CCUnauthorizedCustomResponseException('Nonce can only contain alphanumeric characters and hyphens')


Expand Down Expand Up @@ -217,11 +219,12 @@ def _validate_signature(event: dict, compact: str, jurisdiction: str, public_key

# Validate all required headers are present
if not all([algorithm, timestamp_str, nonce, signature_b64, key_id]):
# nonce is part of the request signing scheme, so it is not logged here; presence is sufficient for triage
logger.warning(
'Missing required signature headers',
algorithm=algorithm,
timestamp=timestamp_str,
nonce=nonce,
nonce_present=bool(nonce),
signature_present=bool(signature_b64),
key_id=key_id,
compact=compact,
Expand Down Expand Up @@ -380,8 +383,9 @@ def _validate_and_store_nonce(compact: str, jurisdiction: str, nonce: str) -> No
'Nonce reuse detected',
compact=compact,
jurisdiction=jurisdiction,
nonce=nonce,
)
# the nonce is part of the request signing scheme, so it is only logged at DEBUG level
logger.debug('Nonce reuse detected details', nonce=nonce)
raise CCUnauthorizedCustomResponseException('Nonce has already been used') from e
logger.error('Failed to validate nonce', error=str(e), compact=compact, jurisdiction=jurisdiction)
raise CCUnauthorizedException('Failed to validate nonce') from e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,18 @@ def caught_handler(event, context: LambdaContext):

content_type = event['headers'].get('Content-Type')

# Propagate these keys to all log messages in this with block
# Propagate these keys to all log messages in this with block.
# The caller's Cognito username (their email address) is intentionally excluded here, since the
# 'sub' claim already provides a non-PII identifier that is sufficient for tracing requests to a user.
# Similarly, only query parameter *names* (not values) are logged, since query parameter values could
# contain PII or other sensitive data depending on the endpoint.
with logger.append_context_keys(
method=event['httpMethod'],
origin=origin,
path=event['requestContext']['resourcePath'],
content_type=content_type,
identity={'user': event['requestContext'].get('authorizer', {}).get('claims', {}).get('sub')},
query_params=event['queryStringParameters'],
username=event['requestContext'].get('authorizer', {}).get('claims', {}).get('cognito:username'),
query_param_keys=sorted((event.get('queryStringParameters') or {}).keys()),
):
logger.info('Incoming request')

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
import json
import time

import boto3
Expand All @@ -26,7 +25,12 @@ def on_event(event: dict, context: LambdaContext): # noqa: ARG001 unused-argume
:param context: The Lambda context
:return: Physical resource ID on success
"""
logger.info('Entering SES email identity verification handler', event=json.dumps(event))
# The event is never logged in full: it includes a pre-signed 'ResponseURL' (with an access key id and
# signature) used to signal CloudFormation.
logger.info(
'Entering SES email identity verification handler',
request_type=event.get('RequestType')
)
properties = event['ResourceProperties']
request_type = event['RequestType']
match request_type:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,12 @@ def process_bulk_upload_file(
'Invalid license in line %s uploaded: %s',
i + 1,
str(e),
valid_data=report_license_data,
compact=compact,
jurisdiction=jurisdiction,
exc_info=e,
)
# valid_data may contain licensee PII (name, license number, npi), so it is only logged at DEBUG
logger.debug('Invalid license record details', record_number=i + 1, valid_data=report_license_data)
event_writer.put_event(
Entry={
'Source': f'org.compactconnect.bulk-ingest.{object_key}',
Expand Down
Loading
Loading