Skip to content

Commit a63d134

Browse files
authored
Merge pull request #2733 from bcgov/DEP-161-mask-sensitive-info
DEP-161: Mask sensitive information in API logs
2 parents d191797 + dad93f6 commit a63d134

24 files changed

Lines changed: 640 additions & 38 deletions

CHANGELOG.MD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## February 2, 2026
2+
3+
- **Security** Implemented automatic sensitive data masking in API logs. [🎟️ DEP-161](https://citz-gdx.atlassian.net/browse/DEP-161)
4+
- Added logging filter to automatically mask passwords, tokens, API keys, and database credentials
5+
- Replaced print() statements with logger calls to ensure masking coverage
6+
- Added linting rules (pylint + flake8) to prevent print statements and enforce logging
7+
- Updated logging configuration to apply masking app-wide
8+
- See `met_api.utils.logging_masker` for implementation details
9+
110
## January 28, 2026
211

312
- Added documentation for enabling and logging into the Sysdig monitoring platform for OpenShift projects. [🎟️ DEP-160](https://citz-gdx.atlassian.net/browse/DEP-160)

met-api/.pylintrc

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ ignore-patterns=^\.#
3333

3434
# Python code to execute, usually for sys.path manipulation such as
3535
# pygtk.require().
36-
#init-hook=
36+
init-hook='import sys; sys.path.append(".")'
3737

3838
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
3939
# number of processors available to use.
@@ -46,7 +46,8 @@ limit-inference-results=100
4646

4747
# List of plugins (as comma separated values of python module names) to load,
4848
# usually to register additional checkers.
49-
load-plugins=
49+
load-plugins=pylint_flask,
50+
pylint_plugins.no_print_checker
5051

5152
# Pickle collected data for later comparisons.
5253
persistent=yes
@@ -90,7 +91,9 @@ disable=raw-checker-failed,
9091
suppressed-message,
9192
useless-suppression,
9293
deprecated-pragma,
93-
use-symbolic-message-instead
94+
use-symbolic-message-instead,
95+
C0301,
96+
W0511
9497

9598
# Enable the message, report, category or checker with the given id(s). You can
9699
# either give multiple identifier separated by comma (,) or put this option
@@ -192,7 +195,7 @@ contextmanager-decorators=contextlib.contextmanager
192195
# List of members which are set dynamically and missed by pylint inference
193196
# system, and so shouldn't trigger E1101 when accessed. Python regular
194197
# expressions are accepted.
195-
generated-members=
198+
generated-members=Error
196199

197200
# Tells whether missing members accessed in mixin class should be ignored. A
198201
# class is considered mixin if its name matches the mixin-class-rgx option.
@@ -213,13 +216,13 @@ ignore-on-opaque-inference=yes
213216
# List of class names for which member attributes should not be checked (useful
214217
# for classes with dynamically set attributes). This supports the use of
215218
# qualified names.
216-
ignored-classes=optparse.Values,thread._local,_thread._local
219+
ignored-classes=optparse.Values,thread._local,_thread._local,scoped_session
217220

218221
# List of module names for which member attributes should not be checked
219222
# (useful for modules/projects where namespaces are manipulated during runtime
220223
# and thus existing member attributes cannot be deduced by static analysis). It
221224
# supports qualified module names, as well as Unix pattern matching.
222-
ignored-modules=
225+
ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session
223226

224227
# Show a hint with possible names when a member name was not found. The aspect
225228
# of finding the hint is based on edit distance.
@@ -290,7 +293,7 @@ indent-after-paren=4
290293
indent-string=' '
291294

292295
# Maximum number of characters on a single line.
293-
max-line-length=100
296+
max-line-length=120
294297

295298
# Maximum number of lines in a module.
296299
max-module-lines=1000
@@ -319,7 +322,7 @@ ignore-imports=no
319322
ignore-signatures=no
320323

321324
# Minimum lines number of a similarity.
322-
min-similarity-lines=4
325+
min-similarity-lines=15
323326

324327

325328
[STRING]
@@ -574,7 +577,7 @@ max-returns=6
574577
max-statements=50
575578

576579
# Minimum number of public methods for a class (see R0903).
577-
min-public-methods=2
580+
min-public-methods=1
578581

579582

580583
[EXCEPTIONS]

met-api/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ install-dev: ## Install local application
6868
ci: lint flake8 test ## CI flow
6969

7070
pylint: ## Linting with pylint
71-
. venv/bin/activate && pylint --rcfile=setup.cfg src/$(PROJECT_NAME)
71+
. venv/bin/activate && pylint --rcfile=.pylintrc src/$(PROJECT_NAME)
7272

7373
flake8: ## Linting with flake8
7474
. venv/bin/activate && flake8 src/$(PROJECT_NAME) tests

met-api/pylint_plugins/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Pylint Plugins
2+
3+
This directory contains custom Pylint checkers for the MET API.
4+
5+
## no_print_checker
6+
7+
**Purpose:** Prevents use of `print()` statements which bypass sensitive data masking.
8+
9+
**Why:** Print statements output directly to stdout/stderr and bypass the logging system's `SensitiveDataFilter`. This creates a security risk where passwords, tokens, API keys, and other credentials could be exposed in logs.
10+
11+
**What it does:**
12+
- Flags any use of `print()` function with error code **W9001**
13+
- Exception: Allows print in `config.py` for startup messages before logging is initialized
14+
15+
**Usage:**
16+
Instead of:
17+
```python
18+
print(f'User ID: {user_id}')
19+
print(f'Database URL: {db_url}')
20+
```
21+
22+
Use:
23+
```python
24+
from flask import current_app
25+
current_app.logger.info('User ID: %s', user_id)
26+
27+
# or
28+
import logging
29+
logger = logging.getLogger(__name__)
30+
logger.info('Database URL: %s', db_url)
31+
```
32+
33+
**Configuration:**
34+
The checker is automatically loaded via `.pylintrc`:
35+
```ini
36+
load-plugins=pylint_plugins.no_print_checker
37+
```
38+
39+
**Testing:**
40+
```bash
41+
# Test with a file containing print statements
42+
pylint --rcfile=.pylintrc path/to/file.py
43+
44+
# Should show:
45+
# W9001: Print statement found - use logging to ensure sensitive data masking
46+
```
47+
48+
## Adding New Checkers
49+
50+
1. Create a new file in this directory (e.g., `my_checker.py`)
51+
2. Implement a class that inherits from `pylint.checkers.BaseChecker`
52+
3. Define your messages and visit methods
53+
4. Add a `register()` function
54+
5. Update `.pylintrc` to load your plugin:
55+
```ini
56+
load-plugins=pylint_plugins.no_print_checker,pylint_plugins.my_checker
57+
```
58+
59+
See [Pylint documentation](https://pylint.readthedocs.io/en/latest/development_guide/how_tos/custom_checkers.html) for more details.

met-api/pylint_plugins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Pylint plugins for MET API code quality checks."""
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Pylint plugin to check for print statements.
2+
3+
Print statements bypass logging masking and pose a security risk by potentially
4+
exposing sensitive data (passwords, tokens, API keys, etc.) in logs.
5+
6+
Use current_app.logger or logging.getLogger(__name__) instead.
7+
"""
8+
9+
from astroid import nodes
10+
from pylint.checkers import BaseChecker
11+
12+
13+
class NoPrintChecker(BaseChecker):
14+
"""Check for print statements which bypass logging masking."""
15+
16+
name = "no-print"
17+
msgs = {
18+
"W9001": (
19+
"Print statement found - use logging to ensure sensitive data masking",
20+
"print-statement-found",
21+
"Print statements bypass the logging masking system and may expose "
22+
"sensitive data (passwords, tokens, API keys). Use current_app.logger "
23+
"or logging.getLogger(__name__) instead.",
24+
),
25+
}
26+
27+
def visit_call(self, node: nodes.Call) -> None:
28+
"""Check if the node is a print function call."""
29+
if isinstance(node.func, nodes.Name) and node.func.name == "print":
30+
# Allow print in config.py for startup messages before logging is initialized
31+
if node.root().file and "config.py" in node.root().file:
32+
return
33+
self.add_message("print-statement-found", node=node)
34+
35+
36+
def register(linter):
37+
"""Register the checker."""
38+
linter.register_checker(NoPrintChecker(linter))

met-api/setup.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ ignore = I001, I003, I004, I100, I101, I201, I202, E126, W504
3838
exclude = .git,*migrations*
3939
max-line-length = 120
4040
docstring-min-length=10
41+
# Disable print statement warning in config file - logger is not initialized yet
4142
per-file-ignores =
4243
*/__init__.py:F401
43-
*/config.py:N802
44+
*/config.py:N802,T201
4445

4546
[pycodestyle]
4647
max-line-length = 120

met-api/src/met_api/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
This module is for the initiation of the flask app.
44
"""
55

6+
import builtins
67
import os
8+
import warnings
79

810
import secure
911
from flask import Flask, current_app, g, request
@@ -18,6 +20,38 @@
1820
from met_api.utils import constants
1921
from met_api.utils.cache import cache
2022
from met_api.utils.roles import Role
23+
from met_api.utils.logging_masker import setup_logging_masking
24+
25+
26+
# Store reference to original print function
27+
_original_print = builtins.print
28+
29+
30+
class PrintDeprecationWarning(DeprecationWarning):
31+
"""Custom warning category for deprecated print() usage."""
32+
33+
34+
def _deprecated_print(*args, **kwargs):
35+
"""
36+
Issue a deprecation warning when print() is used.
37+
38+
Using print() bypasses the logging masking system and may expose sensitive data.
39+
Use logging instead: current_app.logger.info(), logger.debug(), etc.
40+
"""
41+
warnings.warn(
42+
'print() is deprecated in this application. '
43+
'Use logging instead (e.g., current_app.logger.info()) to ensure '
44+
'sensitive data is automatically masked. '
45+
'See met_api/utils/logging_masker.py for details.',
46+
PrintDeprecationWarning,
47+
stacklevel=2
48+
)
49+
# Still call original print to not break existing code during transition
50+
_original_print(*args, **kwargs)
51+
52+
53+
# Replace built-in print with deprecated version
54+
builtins.print = _deprecated_print
2155

2256
# Security Response headers
2357
csp = (
@@ -53,6 +87,17 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'development')):
5387
# Configure app from config.py
5488
app.config.from_object(get_named_config(run_mode))
5589

90+
# Replace built-in print with deprecated version AFTER config loads
91+
# (config.py prints during initialization before logging is set up)
92+
warnings.filterwarnings('default', category=PrintDeprecationWarning)
93+
builtins.print = _deprecated_print
94+
95+
# Set up logging with sensitive data masking
96+
# Automatically masks passwords, tokens, API keys, database credentials
97+
# Applied to both Flask app logger and root logger for full coverage
98+
setup_logging_masking(app.logger)
99+
setup_logging_masking()
100+
56101
CORS(app, origins=app.config['CORS_ORIGINS'], supports_credentials=True)
57102

58103
# Register blueprints

met-api/src/met_api/config.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,6 @@ def __init__(self) -> None:
8585
Performs more advanced configuration logic that is not possible
8686
in the normal class definition.
8787
"""
88-
# If extending this class, call super().__init__() in your constructor.
89-
print(f'SQLAlchemy URL: {self.SQLALCHEMY_DATABASE_URI}')
90-
9188
# apply configs to _Config in the format that flask_jwt_oidc expects
9289
# this flattens the JWT_CONFIG dict into individual attributes
9390
for key, value in self.JWT_CONFIG.items():

met-api/src/met_api/logging.conf

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ keys=console
77
[formatters]
88
keys=simple
99

10+
[filters]
11+
# Sensitive data filter automatically masks credentials in logs
12+
# See met_api.utils.logging_masker for pattern details
13+
keys=sensitive_data
14+
1015
[logger_root]
1116
level=DEBUG
1217
handlers=console
@@ -28,7 +33,11 @@ class=StreamHandler
2833
level=DEBUG
2934
formatter=simple
3035
args=(sys.stdout,)
36+
filters=sensitive_data # All other loggers inherit this filter via root logger's console handler
3137

3238
[formatter_simple]
3339
format=%(asctime)s - %(name)s - %(levelname)s in %(module)s:%(filename)s:%(lineno)d - %(funcName)s: %(message)s
34-
datefmt=
40+
datefmt=
41+
42+
[filter_sensitive_data]
43+
class=met_api.utils.logging_masker.SensitiveDataFilter

0 commit comments

Comments
 (0)