Skip to content

Commit f36b41a

Browse files
committed
fix: add handling for multi-line ci secrets mask (with tests)
1 parent 7e93206 commit f36b41a

File tree

2 files changed

+58
-9
lines changed

2 files changed

+58
-9
lines changed

airbyte_cdk/cli/airbyte_cdk/_secrets.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -459,31 +459,39 @@ 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+
print(f"::add-mask::{line!s}")
470+
471+
462472
def _print_ci_secret_mask_for_value(value: Any) -> None:
463473
"""Print GitHub CI mask for a single secret value.
464474
465-
Dict and list values masked as their JSON stringified versions.
475+
Call this function for any values identified as secrets, regardless of type.
466476
"""
467477
if isinstance(value, dict):
468-
# For nested dicts, we also need to mask the json-stringified version
478+
# For nested dicts, we call recursively on each value
469479
for v in value.values():
470480
_print_ci_secret_mask_for_value(v)
471481

472482
return
473483

474484
if isinstance(value, list):
475-
# For lists, we also need to mask the json-stringified version
485+
# For lists, we call recursively on each list item
476486
for list_item in value:
477487
_print_ci_secret_mask_for_value(list_item)
478488

479489
return
480490

481-
# For other types besides dict and list, we convert to string and mask each line
482-
# separately to handle multi-line secrets (e.g. private keys)
483-
484-
secret_str_lines = str(value).splitlines()
485-
for line in secret_str_lines:
486-
print(f"::add-mask::{line!s}")
491+
# For any other types besides dict and list, we convert to string and mask each line
492+
# separately to handle multi-line secrets (e.g. private keys).
493+
for line in str(value).splitlines():
494+
_print_ci_secret_mask_for_string(line)
487495

488496

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

0 commit comments

Comments
 (0)