Skip to content

feat(standard-tests): run the secret leak check on discover and read output - #1267

Draft
Anatolii Yatsuk (tolik0) wants to merge 8 commits into
mainfrom
tolik0/cdk/leak-check-discover-read
Draft

Anatolii Yatsuk (tolik0) wants to merge 8 commits into
mainfrom
tolik0/cdk/leak-check-discover-read

Conversation

@tolik0

@tolik0 Anatolii Yatsuk (tolik0) commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What

Until now, the standard tests ran the airbyte_secret leak check only for check. discover and read raised the connector's raw error traces, which can carry config secrets into public CI logs. For example, source-tiktok-marketing's test app gets a 403 from oauth2/advertiser/get/, and the stream-error trace quotes the request URL with app_id and secret in the query.

This PR runs the leak check on every discover and read job of the standard tests, before any assertion prints raw messages. It also masks the two other places that printed secrets during a test.

In process (source_base.py, connector_base.py): the check is passed to run_test_job as inspect_output, so it runs before the outcome assertions. It covers:

  • test_discover
  • the discover and the read of test_basic_read
  • the read of test_fail_read_with_bad_catalog

The future-state tests from #1265 (test_read_from_future_state and its Docker twin) already ran the check, with their own skip_message_types=FUTURE_STATE_READ_UNSCANNED_MESSAGES. That parameter and constant are removed: every test now skips the same message types (see below).

test_fail_read_with_invalid_config is left out, because its config holds no real values.

Docker (docker_base.py): test_docker_image_build_and_discover, and the discover and the read of test_docker_image_build_and_read, now:

  1. run with raise_if_errors=False;
  2. run the leak check;
  3. call raise_if_errors().

The Docker check uses the same new helper.

Printing paths masked:

  • entrypoint_wrapper._run_command printed the raw traceback of an unexpected exception to stdout. It now applies filter_secrets, as the CDK entrypoint does for an uncaught exception.
  • pytest's own report. pytest's log capture formats records without the CDK's secret filter. A failed test (or a passed one under -rA) printed lines like 'GET' request to '…/oauth2/advertiser/get/?secret=<raw>&app_id=<raw>' in "Captured log call". The dry-run found 28 raw occurrences in one tiktok test_basic_read log.
    • assert_no_secrets_in_output now records the secret forms it searched for: literal, JSON-escaped, repr-escaped and URL-encoded.
    • A pytest_runtest_makereport hookwrapper in pytest_hooks.py masks those forms in the report sections and the failure text.
    • After the change, the same tiktok run had 0 raw occurrences and 113 masked lines.
    • A skipped test keeps its (path, lineno, reason) report tuple, with the reason masked. Replacing it with a string made pytest exit with INTERNALERROR when a skip reason held a searched secret.
    • Only what pytest captured is masked. Live logging (log_cli) prints during the test, before the check; the monorepo pytest.ini does not turn it on. That pytest.ini does set --capture=no, so stdout and stderr print live and are not masked, beyond the CDK's exact-match filter on the log lines the CDK formats.

Records and state are not searched

find_leaked_secrets now skips RECORD and STATE messages, as it skips CONTROL messages:

  • The platform never logs records or state, and the standard-test assertions never print their values.
  • Both can legitimately hold a value equal to a config secret. An API can return an app ID marked airbyte_secret that a stream lists, and a partitioned stream keeps its partition key in its state: source-twilio partitions by its account_sid, which its spec marks airbyte_secret. Searching state turned twilio's test_basic_read red with /account_sid found only in STATE messages, while the raw value appeared nowhere in the printed output.
  • A read can emit many records, which would make the search the slowest part of the test.

Trace, log, connection-status and the other message types are searched, plus stderr on the Docker path. The same skip applies to check, which emits neither records nor state. The lint tests that used a STATE message to carry a leak now use TRACE messages, and two tests pin the skips.

Cost

  • spec runs at most once per test in process. pytest builds a new suite instance per test, so the spec is cached on the instance.
  • The Docker path reuses the existing per-image session cache, _docker_spec_cache.
  • If spec fails, the test is skipped, as check already was, and the spec tests report the failure.

Mutation pinning

Each guard was reverted or flipped in place, and unit_tests/test was run each time. All 21 mutations were killed. M1 to M18 ran before main was merged in; M1 and M19 to M21 ran on the current head.

# Mutation Result
M1 Search records again killed
M2 Drop the guard in test_discover killed
M3 Drop the guard on the discover of test_basic_read killed
M4 Drop the guard on the read of test_basic_read killed
M5 Drop the guard in test_fail_read_with_bad_catalog killed
M6 Drop the per-test spec cache killed (spec-runs-once test)
M7 Drop the Docker discover guard killed
M8 Drop the Docker discover raise_if_errors() killed
M9 Drop the guard on the Docker read test's discover killed
M10 Drop the raise_if_errors() on the Docker read test's discover killed
M11 Drop the guard on the Docker read test's read killed
M12 Drop the raise_if_errors() on the Docker read test's read killed
M13 Drop the filter_secrets on the traceback that _run_command prints killed
M14 Drop the Docker check guard (new helper) killed
M15 Leave the pytest report sections unmasked killed
M16 Leave the pytest failure text unmasked killed
M17 Stop recording the searched secret forms killed
M18 Mask shortest-first (a secret that contains another stays partly visible) killed
M19 Search state messages again killed (3 tests: the lint test and both future-state state tests)
M20 Drop the tuple branch for a skip report killed (skip-reason test)
M21 Keep the skip report tuple but leave its reason unmasked killed (skip-reason test)

unit_tests/test: 1159 passed. ruff format, ruff check and mypy airbyte_cdk/test are clean.

Fleet dry-run

The monorepo CI installs the CDK unpinned, so this reaches every connector on release. The dry-run used airbyte-cdk connector test from this branch with real secrets (airbyte-ops secrets fetch) on five certified manifest-only connectors. Every config used holds at least one searchable secret. Python connectors were not run.

Connector Result Leak failures
source-sentry full suite: 14 passed, 17 skipped 0
source-klaviyo full suite: 12 passed, 10 skipped 0
source-zendesk-support full suite: 3 failed, 13 passed, 15 skipped 0. The 3 failures come from assertions this PR does not change: check fails for config_oauth (in process and Docker), and test_basic_read gets no records from a stream.
source-jira changed tests only: Docker discover 2 passed; discover and bad-catalog 4 passed; test_basic_read 2 passed 0. A first full-suite run hung for 6 h in test_basic_read, idle on a network wait; the rerun took 3 min 45 s.
source-tiktok-marketing changed tests only: Docker discover 3 passed; discover and bad-catalog 4 passed; test_basic_read failed 1 test: /credentials/app_id and /credentials/secret in stream-error TRACE messages
source-twilio test_basic_read['config'] only: passed, as it does in monorepo CI on the released CDK 0. Before state messages were skipped, it failed with /account_sid found in STATE messages only.

tiktok is not newly red. Its test_basic_read already fails on main with the 403 AirbyteEntrypointException, and it now fails with the leak report instead.

The leaked traces come from the CDK itself. concurrent_read_processor.py (lines 253 and 259) emits a stream error's trace with as_airbyte_message(), while the synchronous abstract_source.py uses as_sanitized_airbyte_message(). That trace reaches production sync logs unfiltered. A local patch switching those two calls to the sanitized form made tiktok's leak report go away, and the test went back to failing on the 403.

#1266 has since fixed the concurrent read: it masks config secrets in those traces. It is merged into this branch and is not released yet. The cdk-migrations.md entry names tiktok and the cause.

Sibling PRs

main (with #1264, #1265 and #1266) is merged into this branch. git merge-tree was run against each open PR that touches these files:

No merge order is required.

Public logs

I checked the "Test source-tiktok-marketing Connector" job logs of monorepo PRs 90787, 85316, 81416, 76231 and 84446. Each has one secret= occurrence, and each shows secret=****, masked by the CDK runtime filter and by the CI masks that airbyte-ops secrets fetch --print-ci-secrets-masks registers. Both of those mask exact matches only, so encoded forms, keys missing from the mask list, local runs and agent session logs were still exposed. This PR closes that gap in the test harness.

🤖 Generated with Claude Code

…output

The `airbyte_secret` leak check ran only for `check`. `test_discover`,
`test_basic_read` and `test_fail_read_with_bad_catalog` (in-process), and
the Docker discover and read tests, raised the connector's raw error traces,
which can quote a request URL holding a config secret.

- In-process, those tests pass the leak check to `run_test_job` as
  `inspect_output`, so it runs before any assertion prints raw messages.
  The spec it needs is run once per test, not once per command.
- On the Docker path, `discover` and `read` run with `raise_if_errors=False`;
  the leak check runs first, then the errors are raised.
- RECORD messages are no longer searched: records are data the platform
  never logs and the assertions never print, an API may return a value equal
  to a secret, and a read can emit many records.
- `entrypoint_wrapper._run_command` masks config secrets in the traceback it
  prints for an unexpected error, as the CDK entrypoint does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a test

pytest's log capture formats records without the CDK's secret filter, so a failed
standard test printed an HTTP log line with the request URL's secret in it. The leak
check now records the secret forms it searched for, and the standard-tests plugin
masks them in the captured log, stdout and stderr sections and the failure text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This CDK Version

You can test this version of the CDK using the following:

# Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@tolik0/cdk/leak-check-discover-read#egg=airbyte-python-cdk[dev]' --help

# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch tolik0/cdk/leak-check-discover-read

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /autofix - Fixes most formatting and linting issues
  • /poetry-lock - Updates poetry.lock file
  • /test - Runs connector tests with the updated CDK
  • /prerelease - Triggers a prerelease publish with default arguments
  • /poe build - Regenerate git-committed build artifacts, such as the pydantic models which are generated from the manifest JSON schema in YAML.
  • /poe <command> - Runs any poe command in the CDK environment
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

Comment on lines +400 to +402
filter_secrets(
"".join(traceback.format_exception(None, exception, exception.__traceback__))
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this print is the masking this PR adds. The traceback goes through filter_secrets before it is printed, as the CDK entrypoint does for an uncaught exception; CodeQL does not model filter_secrets as a sanitizer.

@staticmethod
def read_config(config_path: str) -> MutableMapping[str, Any]:
config = Source.read_config(config_path)
logging.getLogger("airbyte").info("Loaded config for %s", _url_with(config["api_key"]))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this test fixture logs a fake key on purpose, so the tests can assert that the leak check reports it and that the pytest report masks it.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PyTest Results (Fast)

6 511 tests  +22   6 475 ✅ +22   7m 19s ⏱️ + 1m 35s
    1 suites ± 0      36 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 5ffceb1. ± Comparison against base commit 43ce5ea.

This pull request removes 2 and adds 24 tests. Note that renamed tests count towards both.
unit_tests.test.test_spec_lint ‑ test_find_leaked_secrets_reports_where_without_any_text[record_names_the_stream]
unit_tests.test.test_spec_lint ‑ test_find_leaked_secrets_skips_the_message_types_it_is_given
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_checks_for_leaks_before_raising[error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_checks_for_leaks_before_raising[log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_still_raises_errors_without_a_leak
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[discover_error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[discover_log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[read_error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[read_log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_does_not_search_records
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_still_raises_errors_without_a_leak[discover]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_still_raises_errors_without_a_leak[read]
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PyTest Results (Full)

6 514 tests  +22   6 478 ✅ +22   11m 6s ⏱️ +13s
    1 suites ± 0      36 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 5ffceb1. ± Comparison against base commit 43ce5ea.

This pull request removes 2 and adds 24 tests. Note that renamed tests count towards both.
unit_tests.test.test_spec_lint ‑ test_find_leaked_secrets_reports_where_without_any_text[record_names_the_stream]
unit_tests.test.test_spec_lint ‑ test_find_leaked_secrets_skips_the_message_types_it_is_given
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_checks_for_leaks_before_raising[error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_checks_for_leaks_before_raising[log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_discover_test_still_raises_errors_without_a_leak
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[discover_error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[discover_log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[read_error_trace]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_checks_for_leaks_before_raising[read_log]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_does_not_search_records
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_still_raises_errors_without_a_leak[discover]
unit_tests.test.test_discover_read_secret_leaks ‑ test_docker_read_test_still_raises_errors_without_a_leak[read]
…

♻️ This comment has been updated with latest results.

Keep both sides in `_spec_lint.py`: RECORD messages are skipped by default, and
`skip_message_types` (from #1265) skips more. #1265's read-leak test leaked through
a record, which is no longer searched, so it now leaks through an error trace.
A state message holds the position the platform stores, and a partition key
can legitimately equal a secret-marked config value (source-twilio partitions
by its `account_sid`). Skip STATE alongside CONTROL and RECORD in every test
and drop the `skip_message_types` parameter and
`FUTURE_STATE_READ_UNSCANNED_MESSAGES`, which only the future-state read used.
A skipped test's `longrepr` is a `(path, lineno, reason)` tuple that the
terminal summary unpacks. Replacing it with a string made pytest exit with
INTERNALERROR whenever a skip reason held a searched secret. Mask the reason
inside the tuple instead, and say in the hook docstring that output printed
live under `--capture=no` is not masked.
…-error masking

The leak check entry now lists STATE among the unsearched messages with the
partition-key reason, notes that the future-state tests have run the check
since 7.46.0, says that `--capture=no` in the monorepo's pytest.ini leaves
stdout and stderr unmasked, and reflects that the concurrent stream-error
traces are filtered after 7.47.0.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants