Skip to content

Commit 90fdec5

Browse files
authored
fix: properly handle multi-line secrets in secret masking (#774)
1 parent 5b10ee9 commit 90fdec5

File tree

2 files changed

+85
-8
lines changed

2 files changed

+85
-8
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -459,25 +459,60 @@ def _print_ci_secrets_masks(
459459
_print_ci_secrets_masks_for_config(config=config_dict)
460460

461461

462+
def _print_ci_secret_mask_for_string(secret: str) -> None:
463+
"""Print GitHub CI mask for a single secret string.
464+
465+
We expect single-line secrets, but we also handle the case where the secret contains newlines.
466+
For multi-line secrets, we must print a secret mask for each line separately.
467+
"""
468+
for line in secret.splitlines():
469+
if line.strip(): # Skip empty lines
470+
print(f"::add-mask::{line!s}")
471+
472+
473+
def _print_ci_secret_mask_for_value(value: Any) -> None:
474+
"""Print GitHub CI mask for a single secret value.
475+
476+
Call this function for any values identified as secrets, regardless of type.
477+
"""
478+
if isinstance(value, dict):
479+
# For nested dicts, we call recursively on each value
480+
for v in value.values():
481+
_print_ci_secret_mask_for_value(v)
482+
483+
return
484+
485+
if isinstance(value, list):
486+
# For lists, we call recursively on each list item
487+
for list_item in value:
488+
_print_ci_secret_mask_for_value(list_item)
489+
490+
return
491+
492+
# For any other types besides dict and list, we convert to string and mask each line
493+
# separately to handle multi-line secrets (e.g. private keys).
494+
for line in str(value).splitlines():
495+
if line.strip(): # Skip empty lines
496+
_print_ci_secret_mask_for_string(line)
497+
498+
462499
def _print_ci_secrets_masks_for_config(
463500
config: dict[str, str] | list[Any] | Any,
464501
) -> None:
465502
"""Print GitHub CI mask for secrets config, navigating child nodes recursively."""
466503
if isinstance(config, list):
504+
# Check each item in the list to look for nested dicts that may contain secrets:
467505
for item in config:
468506
_print_ci_secrets_masks_for_config(item)
469507

470-
if isinstance(config, dict):
508+
elif isinstance(config, dict):
471509
for key, value in config.items():
472510
if _is_secret_property(key):
473511
logger.debug(f"Masking secret for config key: {key}")
474-
print(f"::add-mask::{value!s}")
475-
if isinstance(value, dict):
476-
# For nested dicts, we also need to mask the json-stringified version
477-
print(f"::add-mask::{json.dumps(value)!s}")
478-
479-
if isinstance(value, (dict, list)):
480-
_print_ci_secrets_masks_for_config(config=value)
512+
_print_ci_secret_mask_for_value(value)
513+
elif isinstance(value, (dict, list)):
514+
# Recursively check nested dicts and lists
515+
_print_ci_secrets_masks_for_config(value)
481516

482517

483518
def _is_secret_property(property_name: str) -> bool:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Unit tests for the secret masking functionality in the Airbyte CDK CLI."""
3+
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from airbyte_cdk.cli.airbyte_cdk import _secrets
9+
10+
11+
@pytest.mark.parametrize(
12+
"config,expected_calls",
13+
[
14+
# Test masking in a flat dict
15+
({"password": "secret123", "regular": "value"}, ["secret123"]),
16+
# Test masking in a nested dict
17+
({"outer": {"api_key": "keyval"}}, ["keyval"]),
18+
# Test masking in a list of dicts
19+
([{"token": "tok1"}, {"name": "v"}], ["tok1"]),
20+
# Test masking in a dict with a list value
21+
({"passwords": ["a", "b"]}, ["a", "b"]),
22+
# Test masking of multi-line secrets
23+
({"password": "multi\nline\nsecret"}, ["multi", "line", "secret"]),
24+
# Test masking in a deeply nested structure
25+
({"a": [{"b": {"secret": "deep"}}]}, ["deep"]),
26+
# Test masking with no secrets
27+
({"foo": "bar"}, []),
28+
# Additional edge case: mixed types
29+
({"password": ["a", 123, {"nested": "val"}]}, ["a", "123", "val"]),
30+
([{"password": "foo"}], ["foo"]),
31+
],
32+
)
33+
def test_print_ci_secrets_masks_for_config(
34+
config: dict,
35+
expected_calls: list,
36+
) -> None:
37+
with patch(
38+
"airbyte_cdk.cli.airbyte_cdk._secrets._print_ci_secret_mask_for_string",
39+
) as mask_mock:
40+
_secrets._print_ci_secrets_masks_for_config(config)
41+
actual_calls = [str(call.args[0]) for call in mask_mock.call_args_list]
42+
assert sorted(actual_calls) == sorted(expected_calls)

0 commit comments

Comments
 (0)