fix(web): keep exception text out of calendar responses; annotate moved code

The split made scanners report existing findings in the moved code as new:
- CodeQL: the calendar auth and calendar-list routes returned exception
  text (redacted, but still derived from the exception). Both now log the
  exception and return a fixed message pointing at the log.
- MD5 in the asset upload only makes a filename unique: usedforsecurity=False.
- pickle reads/writes the calendar plugin's own OAuth token (as before):
  annotated. Token-status labels and a log line naming the secrets path are
  false positives: annotated with the repo's nosec/nosemgrep convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 11:22:24 -04:00
co-authored by Claude Opus 5.5
parent f7bdb24772
commit 5e695b7c39
6 changed files with 23 additions and 19 deletions
@@ -402,8 +402,8 @@ class TestDiagnosticsAreRedacted:
tmp_path, tmp_path,
monkeypatch): monkeypatch):
# OSError from the spawn carries the interpreter path and whatever the # OSError from the spawn carries the interpreter path and whatever the
# OS chose to say; it reaches the client through the redactor like # OS chose to say; none of it reaches the client, which is pointed at
# everything else. # the log instead.
script = tmp_path / 'calendar_registration.py' script = tmp_path / 'calendar_registration.py'
script.write_text('', encoding='utf-8') script.write_text('', encoding='utf-8')
@@ -414,7 +414,8 @@ class TestDiagnosticsAreRedacted:
payload, error = mod._run_calendar_registration(tmp_path, '') payload, error = mod._run_calendar_registration(tmp_path, '')
assert payload is None assert payload is None
assert 'abcd1234' not in error, error assert 'abcd1234' not in error, error
assert 'OSError' in error, error assert 'Exec format' not in error, error
assert 'log' in error, error
def test_a_missing_google_library_is_reported_without_raw_exception_text( def test_a_missing_google_library_is_reported_without_raw_exception_text(
self, client, monkeypatch): self, client, monkeypatch):
+3 -2
View File
@@ -1466,8 +1466,9 @@ def _run_calendar_registration(plugin_dir: Path, stdin_payload: str):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return None, 'Authentication timed out after 120s' return None, 'Authentication timed out after 120s'
except OSError as e: except OSError as e:
logger.error('Could not run calendar_registration.py', exc_info=True) # The exception (a path, an errno) goes to the log, not the client.
return None, 'Could not run the authentication script: %s' % describe_exception(e) logger.error('Could not run calendar_registration.py: %s', e, exc_info=True)
return None, 'Could not run the authentication script; see the web service log'
for line in reversed((result.stdout or '').splitlines()): for line in reversed((result.stdout or '').splitlines()):
line = line.strip() line = line.strip()
@@ -142,7 +142,9 @@ def upload_plugin_asset():
for file, file_ext, file_size, file_content in accepted: for file, file_ext, file_size, file_content in accepted:
# Generate unique filename # Generate unique filename
timestamp = int(_pkg.time.time()) timestamp = int(_pkg.time.time())
file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8] # Only makes the filename unique; nothing is verified with it.
file_hash = hashlib.md5(file_content + file.filename.encode(),
usedforsecurity=False).hexdigest()[:8]
safe_filename = f"image_{timestamp}_{file_hash}{file_ext}" safe_filename = f"image_{timestamp}_{file_hash}{file_ext}"
file_path = assets_dir / safe_filename file_path = assets_dir / safe_filename
@@ -6,7 +6,7 @@ so their endpoint names do not depend on which module they live in.
from web_interface.blueprints.api_v3 import ( from web_interface.blueprints.api_v3 import (
Path, _CALENDAR_LIST_MAX_PAGES, _plugin_directory, Path, _CALENDAR_LIST_MAX_PAGES, _plugin_directory,
_prune_credential_backups, _run_calendar_registration, api_v3, _prune_credential_backups, _run_calendar_registration, api_v3,
describe_exception, json, jsonify, logger, os, redact_text, request, json, jsonify, logger, os, redact_text, request,
shutil, shutil,
) )
from src.config_manager_atomic import atomic_write_text from src.config_manager_atomic import atomic_write_text
@@ -158,29 +158,28 @@ def list_calendar_calendars():
}), 400 }), 400
try: try:
import pickle import pickle # nosec B403 - reads only the plugin's own OAuth token # nosemgrep
from google.auth.transport.requests import Request as GoogleRequest from google.auth.transport.requests import Request as GoogleRequest
from googleapiclient.discovery import build as build_google_service from googleapiclient.discovery import build as build_google_service
except ImportError as e: except ImportError as e:
# Which module is missing goes to the log; the exception itself (it
# can quote a path) doesn't go to the client.
logger.warning('Calendar picker: Google API libraries missing: %s', e)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
# The name of the missing module is the whole diagnosis, but it
# arrives as an exception, so it goes through the redactor like
# any other -- an ImportError can quote a path.
'message': ('The Google API libraries are not installed. Install ' 'message': ('The Google API libraries are not installed. Install '
"the calendar plugin's requirements.txt. (%s)" "the calendar plugin's requirements.txt.")
% describe_exception(e))
}), 500 }), 500
with open(token_file, 'rb') as handle: with open(token_file, 'rb') as handle:
# Written only by this plugin's own OAuth flow, into its own # Written only by this plugin's own OAuth flow, into its own
# directory, and read here exactly as the plugin itself reads it. # directory, and read here exactly as the plugin itself reads it.
creds = pickle.load(handle) # nosec B301 - locally generated token creds = pickle.load(handle) # nosec B301 - locally generated token # nosemgrep
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
creds.refresh(GoogleRequest()) creds.refresh(GoogleRequest())
with open(token_file, 'wb') as handle: with open(token_file, 'wb') as handle:
pickle.dump(creds, handle) pickle.dump(creds, handle) # nosec B301 - same token, same format # nosemgrep
os.chmod(token_file, 0o600) os.chmod(token_file, 0o600)
if not creds or not creds.valid: if not creds or not creds.valid:
@@ -435,7 +435,8 @@ def save_plugin_config():
) )
except Exception: except Exception:
secrets_path = api_v3.config_manager.secrets_path secrets_path = api_v3.config_manager.secrets_path
logger.error("Error saving secrets config for %s (path=%s)", plugin_id, secrets_path, exc_info=True) # Logs the file path, not any secret value.
logger.error("Error saving secrets config for %s (path=%s)", plugin_id, secrets_path, exc_info=True) # nosemgrep
return error_response( return error_response(
ErrorCode.CONFIG_SAVE_FAILED, ErrorCode.CONFIG_SAVE_FAILED,
"Failed to save secrets configuration; see logs for details", "Failed to save secrets configuration; see logs for details",
@@ -738,7 +738,7 @@ def get_github_auth_status():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': { 'data': {
'token_status': 'none', 'token_status': 'none', # nosec B105 - a status label # nosemgrep
'authenticated': False, 'authenticated': False,
'rate_limit': 60, 'rate_limit': 60,
'message': 'No GitHub token configured', 'message': 'No GitHub token configured',
@@ -753,7 +753,7 @@ def get_github_auth_status():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': { 'data': {
'token_status': 'valid', 'token_status': 'valid', # nosec B105 - a status label # nosemgrep
'authenticated': True, 'authenticated': True,
'rate_limit': 5000, 'rate_limit': 5000,
'message': 'GitHub API authenticated', 'message': 'GitHub API authenticated',
@@ -764,7 +764,7 @@ def get_github_auth_status():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': { 'data': {
'token_status': 'invalid', 'token_status': 'invalid', # nosec B105 - a status label # nosemgrep
'authenticated': False, 'authenticated': False,
'rate_limit': 60, 'rate_limit': 60,
'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid', 'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid',