Repository navigation
fix(concurrent): mask config secrets in stream error traces - #1266
Conversation
The concurrent read processor emitted stream-error TRACE messages with `as_airbyte_message()`, which does not run `filter_secrets`. Because `launch()` prints `run()` output as-is, an HTTP error whose internal message quoted a request URL with a secret query parameter reached the platform unmasked. Use `as_sanitized_airbyte_message()` for both the AirbyteTracedException and the unhandled-exception branch, matching the non-concurrent `AbstractSource` path. The async job orchestrator's job-creation failure trace goes through the message repository to the same output, so sanitize it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou 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@fix/concurrent-sanitize-stream-error-traces#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 fix/concurrent-sanitize-stream-error-tracesPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
PyTest Results (Fast)6 489 tests +183 6 453 ✅ +168 7m 13s ⏱️ + 1m 41s Results for commit c3b7736. ± Comparison against base commit e97f461. This pull request removes 1 and adds 184 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
PyTest Results (Full)6 492 tests 6 456 ✅ 10m 55s ⏱️ Results for commit c3b7736. ♻️ This comment has been updated with latest results. |
Daryna Ishchenko (darynaishchenko)
left a comment
There was a problem hiding this comment.
Approve. Correct, minimal fix, and the change is not breaking.
Verified
- End-to-end probe through
AirbyteEntrypoint.runwith a concurrent declarative stream that has anairbyte_secretin the query string and in a 403 error message. Onmain, the secret leaks inmessage,internal_messageandstack_trace. At this head, all three are****. - I searched the rest of the read path for unmasked error messages. Only
connector_builder/*andtest/entrypoint_wrapper.pyremain, and the PR body already scopes those out.emit_message(the excepthook path) already filters. - Mutation pins re-run locally: each of the 3 reverts fails exactly its test. 359 tests pass in the touched suites.
Risk: low. It uses the same sanitizer AbstractSource already uses, and nothing changes when an error contains no registered secret. The Check: destination-motherduck failure is unrelated: its GSM secret has no enabled version, and that check is red on every recent PR.
One residual limitation, not caused by this PR: filter_secrets is exact-match only, so a secret that appears percent-encoded in a URL is still not masked.
…he unhandled-exception trace from_exception already sets the stream descriptor, and as_airbyte_message prefers it over the deprecated argument.
What
Concurrent sources emitted stream-error TRACE messages without masking config secrets. This PR sanitizes them the same way the non-concurrent path does.
ConcurrentReadProcessor.on_exceptionnow yieldsas_sanitized_airbyte_messageon both branches: anAirbyteTracedExceptionraised by the stream (as_sanitized_airbyte_message(stream_descriptor=...)), and an unhandled exception wrapped withfrom_exception(..., stream_descriptor=...)(as_sanitized_airbyte_message(), sincefrom_exceptionalready sets the descriptor). The stream descriptor and the user-facing message are unchanged.AsyncJobOrchestrator._keep_api_budget_with_failed_jobemits its job-creation failure trace through the message repository, which also reachesreadoutput. It now usesas_sanitized_airbyte_message()too.Production impact
AbstractSource(the non-concurrent path) already emits stream errors withas_sanitized_airbyte_message, which runsfilter_secretsovermessage,internal_messageandstack_trace.entrypoint.launch()prints every message fromrun()as-is, with no further filtering. So for concurrent sources, which include most declarative/manifest-only connectors, any stream error whose text quoted a config secret reached the platform unmasked: in the sync's failure reason, in the job logs, and in whatever the platform forwards (Sentry, attempt failure summaries).The concrete case is source-tiktok-marketing: its
oauth2/advertiser/get/?secret=...&app_id=...request quotes the app secret in the URL. When that request returns 403, theHTTPErrorinternal message and stack trace contain the full URL, and the trace was emitted with the secret in plain text. After this change, those fields show****instead of the secret. Manifest-only connectors such as source-tiktok-marketing pin their SDM base image, so they get the fix with their next base-image bump.Nothing changes for messages that contain no registered secrets.
filter_secretsonly replaces values registered byset_up_secret_filter(the spec'sairbyte_secretfields).Paths checked and left as is
ConcurrentReadProcessor.is_donegoes throughsys.excepthook, thenAirbyteTracedException.emit_message(), which already runsfilter_secretsover the serialized message. Its message is also built bygenerate_failed_streams_error_message, which filters each exception repr.connector_builder/*andtest/entrypoint_wrapper.pystill useas_airbyte_message(). They are not part of thelaunch()/readoutput path, so they are out of scope here.cli/source_declarative_manifest/_run.pyprints startup-error traces before the secret filter is set up, sofilter_secretswould not mask anything there.Tests
test_on_exception_masks_config_secrets_in_trace_messageinunit_tests/sources/streams/concurrent/test_concurrent_read_processor.py, parametrized over both branches. It registers a secret withupdate_secrets, raises a stream exception whose text contains the secret, and asserts that the yielded TRACE has****and not the secret inmessage(ATE case),internal_messageandstack_trace. It also asserts that the stream descriptor and the user-facing message are preserved.test_given_exception_when_start_job_then_emitted_trace_masks_config_secretsinunit_tests/sources/declarative/async_job/test_job_orchestrator.py.Mutation pins (run on a scratch copy):
as_airbyte_message(stream_descriptor=...)airbyte_traced_exceptioncase fails.as_airbyte_message()unhandled_exceptioncase failsas_airbyte_message()Local run:
unit_tests/sources/streams/concurrent,unit_tests/sources/concurrent_source,unit_tests/sources/declarative/async_jobandunit_tests/utils/test_traced_exception.pyall pass (359 passed).Related and overlapping PRs
discoverandread) is the test-harness side of this work. Its tiktok dry-run found the leak this PR fixes. The two PRs share no files and merge cleanly, and no merge order is needed: tiktok'stest_basic_readis red with or without either PR, because its test app gets a 403. Once this PR is released, the tiktok paragraph in feat(standard-tests): run the secret leak check on discover and read output #1267'scdk-migrations.mdentry, which says the concurrent read emits stream-error traces unfiltered, needs to point to this fix.git merge-treeshows new textual conflicts with fix(oauth): surface token refresh retry exhaustion as transient_error #1225 (concurrent_read_processor.py), fix(async-job): preserve async and stream failure types #994 (job_orchestrator.py) and fix: treat 429 retry budget exhaustion as terminal in async job orchestrator #1048 (test_job_orchestrator.py, test additions only). Each of these is a nearby-line conflict that is quick to resolve. fix(async-job): propagate underlying failure_type through orchestrator wrappers #1002 already conflicts withmainin the same files.🤖 Generated with Claude Code