Skip to content

security: validate_ident let a name with a trailing newline through - #788

Merged
fas89 merged 3 commits into
mainfrom
fix/sql-safety-trailing-newline
Nov 3, 2026
Merged

fas89 merged 3 commits into
mainfrom
fix/sql-safety-trailing-newline

Conversation

@fas89

@fas89 fas89 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

What was wrong

providers/_sql_safety.py::validate_ident checks a name against ^[A-Za-z_][A-Za-z0-9_]*$ with .match. Python's $ also matches just before a trailing newline, so validate_ident("orders\n") returned "orders\n" unchanged. About 40 modules route contract-supplied names through it before the names reach DDL, an OpenTofu module, or a line-based file.

A security review of #784 found where this matters most. That PR writes expected.target=<namespace>.<table> into a Java properties file, so a table name that ends in \n gets past the check and splits the line. The gap predates #784, so this PR fixes it on its own.

Reproduced through the real CLI on main f322f8b. I ran fluid generate iac on examples/aws-redshift-kinesis-streaming with external_schema: "spectrum_events\n". It exits 0, and main.tf.json contains CREATE EXTERNAL SCHEMA IF NOT EXISTS spectrum_events\n ....

What changes

  • The four whole-string allowlists in _sql_safety.py now end in \Z instead of $:

    • _SAFE_IDENT
    • _SAFE_EXPR_CHARS
    • _SQL_TYPE_PARAM_PAYLOAD
    • _SQL_TYPE_NAME

    \Z matches only at the very end of the string, so the change can only tighten them. Call sites keep .match.

  • Only _SAFE_IDENT changes behaviour.

    • validate_sql_type_name, validate_sql_type_param_payload and validate_sql_expression_allowlist already .strip() their input before matching. For them, \Z stops a later edit that drops the strip from reopening the gap.
    • _SAFE_EXPR_CHARS admits \s by design, newlines included. It changes only for consistency.
  • With the fix, the same fluid generate iac run exits 1 with Invalid SQL identifier: 'spectrum_events\n' and writes no module.

  • Behaviour change: a name that reaches validate_ident from an environment variable, or from a file that ends in a newline, is now refused too. Before, it went through with the newline. Two of those paths are {{ env.NAME }} values (build_runners/base.py:76, read with os.getenv and not stripped) and the dbt runner's env_var('NAME') catalog/schema (build_runners/dbt/profiles.py:221). The error names the field. The fix is to strip the value where it is set.

  • CHANGELOG.md: a ### Security entry.

  • .secrets.baseline: the two Secret Keyword hits in _sql_safety.py (the DSN password= lines) sit four lines lower now, at 248/249 instead of 244/245. I regenerated the baseline with detect-secrets 1.5.0, the version CI uses.

Borrow before build

  • The fix is the stdlib anchor. Python 3.14 adds \z as a synonym, but the package supports 3.10+, so this uses \Z. The alternative was re.fullmatch at each call site. Anchoring the pattern itself also covers any .match added to these patterns later.
  • Prior art: the MCP Python SDK fixed the same bug in tool-name validation by switching to re.fullmatch (modelcontextprotocol/python-sdk#3086, merged 2026-07-17). Seth Larson's write-up covers how $, \Z and \z behave across regex engines.
  • I found no Semgrep or CodeQL rule for this case. CodeQL's py/regex/unmatchable-dollar checks something different.

The same ^...$ + .match shape also appears outside this module, for example:

  • providers/quality_engine.py:44
  • exporters/sodacl.py:85
  • copilot/catalog/_patterns.py:103
  • cli/discover/_jdbc_introspect.py:544
  • providers/_path_safety.py:49

A sweep of those follows in a separate PR.

Validation

  • tests/providers/test_sql_safety_type_param.py gains 6 cases. 4 fail on main and pass here:

    • validate_ident("orders\n")
    • each of _SAFE_IDENT, _SQL_TYPE_NAME and _SQL_TYPE_PARAM_PAYLOAD matched against its value plus a trailing \n

    "Main" here is b2d05c0; _sql_safety.py is unchanged from there through f322f8b. The other 2 cases, "orders\r\n" and "ord\ners", already raise on main. They stay as pins, so a later fix that strips the input or matches line by line fails them.

  • Live run: fluid generate iac as above exits 1 on the branch and 0 on main. The unmodified example emits a byte-identical main.tf.json on both.

  • Whole-tree checks are clean: ruff check fluid_build/ tests/, black==24.10.0 --check fluid_build/ tests/, scripts/check_license_headers.py, lint-imports (6 contracts kept) and the product guardrail.

  • Tests:

    • The 16 test files that import _sql_safety, plus tests/perf/test_startup_budget.py: 1215 passed.
    • tests/providers tests/iac tests/output_ports tests/build_runners tests/engines tests/security tests/copilot: 9860 passed.
    • The failures fail the same way on clean main. They come from the local machine load (load average 150-200):
      • the cold --help wall-clock backstop: 4.37 s on the branch, 4.50 s on main, against 2.0 s
      • 2 tests in test_duckdb_sandbox_workspace_root.py
      • 2 timeout tests in test_credentials_stay_out_of_files_and_diagnostics.py
  • Reviews scoped to the diff:

    • Defect hunt, where a hunter lists candidates and a skeptic tries to reproduce them: nothing reproduced.
    • Security review: no blocking findings.
      • Every str.splitlines() line break is refused in trailing, leading and embedded positions.
      • A sweep of all of Unicode admits only the 63 ASCII [A-Za-z0-9_] characters.
      • Over 300k random strings, no pattern accepts anything it rejected before.
    • One LOW, left as is: validate_sql_type_param_payload checks payload.strip() but returns the raw payload, and its \s* admits a newline around the comma. Nothing exploits it, because both callers (snowflake/util/types.py:132, iac/providers/databricks.py:355) discard the return value.

Documentation

  • No docs needed — this change does not affect user-facing behaviour
    • Justification: no documented command, flag or schema field changes. The only visible change is that a name ending in a newline, which no documented identifier rule allows, is now refused with Invalid SQL identifier: '<name>\n'. The CHANGELOG Security entry records it.

fas89 added 2 commits November 3, 2026 20:43
Python's `$` also matches just before a trailing newline, so
`_SAFE_IDENT.match("orders\n")` succeeded and validate_ident returned
"orders\n" unchanged. Every DDL emitter routes identifiers through it,
and a name written into a line-based file (a Java properties file, as
in #784) gains a second line.

The four whole-string allowlists in _sql_safety.py now end in `\Z`.
Only _SAFE_IDENT changes behaviour: the type-name, type-parameter and
RLS-expression helpers already strip their input, so `\Z` there keeps
a later edit that drops the strip from reopening the gap.

tests/providers/test_sql_safety_type_param.py pins validate_ident
against "\n", "\r\n" and an embedded "\n", and each pattern against a
trailing "\n".
@github-actions github-actions Bot added provider Changes or requests related to providers tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

@github-actions github-actions Bot added security Security-related changes or reports and removed needs-docs Pull request needs a linked docs update or justification labels Nov 3, 2026
@fas89
fas89 merged commit d7cbea6 into main Nov 3, 2026
38 checks passed
fas89 added a commit that referenced this pull request Nov 3, 2026
* security: refuse a trailing newline in whole-string validators

Python's `$` also matches just before a final newline, so
`re.match(r"^[a-z]+$", "orders\n")` succeeds and the caller goes on to
use "orders\n". #788 closed this in providers/_sql_safety.py. The same
`^...$` plus `.match` shape guarded 36 more validator patterns across
fluid_build/, fed by contract YAML (a `|` block scalar ends in a
newline), .env values, CLI flags, LLM tool calls and percent-encoded
URL queries.

31 of those patterns now end in `\Z`. Two call sites use `fullmatch`
instead: the catalog quoting chokepoint, which also covers the BigQuery
patterns callers pass in, and the federation workspace id, whose error
message prints the pattern. Seen through the CLI before the fix,
`fluid generate iac` put `region = "eu-west-1\n"` on the AWS provider,
and `branch = "main\n"` on a Databricks job from `?branch=main%0A`.

81 other `$`-ending patterns stay as they are. Their input is stripped
first, or they are deny lists and detectors where `\Z` would loosen
the check, or they are parsers whose captured groups are what is used.

tests/security/test_trailing_newline_anchors.py pins each fixed site
with a value and the same value plus "\n". All 39 cases fail on the
parent commit.

The fix follows Django's username validators (ticket #30257, `$` to
`\Z`) and the MCP Python SDK's tool-name check (#3076, `fullmatch`).

* docs(changelog): 36 more validators refuse a value with a trailing newline

* fix(iac): refuse an AWS binding region with whitespace around it

With `\Z`, provider_block_for dropped a binding region of
"eu-west-1\n" as no region code. That left the provider on the
environment's region and skipped _guard_region_move. Pinning the
stripped value is no better. Both sovereignty checkers
(policy/sovereignty.py, providers/aws/util/sovereignty.py) compare
the region as written, so "us-east-1\n" passes
`deniedRegions: [us-east-1]` while the provider would be pinned to
us-east-1.

provider_block_for now raises UnsupportedBindingError
(aws-region-whitespace) for a region code with whitespace around it.
generate iac and apply render it as a typed CLI error before anything
is applied, and fluid diff reports the inspector error. On main the
value went onto the provider with its newline.

* security: match the BigQuery catalog and imported-schema patterns whole

copilot/catalog/bigquery.py::_validate_ident checked the same `$`
patterns with `.match` that the quoting chokepoint now checks with
`fullmatch`. So `scope.tables` values and FQN parts such as
"orders\n" reached `table_name IN ('...')`. The ODCS and ODPS
importers compile the bundled schema's identifier and semver
patterns, which end in `$`, and also used `.match`: an imported id
"orders\n" was kept verbatim, and "1.2.3\n" counted as semver. All
three now call `fullmatch`.

The two patterns that keep `$` because their call sites use
`fullmatch` (`_DEFAULT_IDENTIFIER`, `_WORKSPACE_ID_RE`) now say so
in a comment beside them.

* fix: show the refused newline in AWS name and mission path messages

These messages quoted the value as '{value}', so a name refused for a
trailing newline printed as a valid-looking name split over two
lines. They now use repr, which prints any other value exactly as
before.

* chore: keep the domain slug patterns in step with the profile-name guard

The comment on forge_domain_enrichment._SAFE_DOMAIN_RE says it
matches forge_copilot_prompts._PROFILE_NAME_RE, and the comment on
_ai_setup_storage._DOMAIN_SLUG_RE says it mirrors _SAFE_DOMAIN_RE.
The profile-name guard now ends in `\Z`, so both do too. Neither is
reachable with a trailing newline: one runs after a disk lookup, the
other on stripped input.

* docs: drop the validator count and reword validate_id's summary

The count in the changelog entry depended on how patterns were
grouped. The entry now lists what is covered instead, including the
AWS region refusal and the imports. validate_id's summary said it
refused a trailing newline "as well", which the shared grammar now
does itself.

* test: skip the BigQuery validation pin without the GCP SDK

bigquery_validation imports google.cloud.bigquery at module load. The
iac Stage 1 unit lane installs no GCP extra, so the new pin failed
collection there with ModuleNotFoundError. It now skips via
pytest.importorskip, the guard tests/providers/test_validation_sql_injection.py
already applies to the same module.
fas89 added a commit that referenced this pull request Nov 3, 2026
…gelog-rules conflict (#798)

* chore(ci): bump pypa/gh-action-pypi-publish from 1.14.2 to 1.14.3 (#783)

Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.2 to 1.14.3.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](pypa/gh-action-pypi-publish@dc37677...7245906)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(ci): bump docker/login-action from 4.6.0 to 4.7.0 (#781)

Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](docker/login-action@dbcb813...81c6f93)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(databricks): column masks and row filters are enforced in Unity Catalog (#785)

* feat(databricks): column restrictions and row filters compile to Unity Catalog SQL functions

policy.authz.columnRestrictions and rowFilters were refused. They now compile to SQL
functions named by a hash of their definition, with the semantics GCP and AWS give the same
fields: deny is a mask that returns NULL, mask is last_four, first_four or NULL, one mask per
column with a deny beating a mask, and one row filter per table that is the union of its
principals' predicates for their members and every row for everyone else. A predicate is
parsed as one boolean expression, checked node by node against an allowlist and rendered
from the tree. access: allow, a principal SQL cannot test and a text mask on a non-string
column are refused. Nothing runs them yet.

* feat(databricks): fluid apply binds the masks and row filter around tofu apply, and revokes SELECT when it cannot

The provider has no function resource and no mask or filter attribute, so the statements run
on the SQL warehouse through the SQL Statement Execution API. pre_apply binds on the tables
that exist before tofu apply runs, so a grant the apply adds lands on a protected table;
post_apply binds on the tables the apply created, drops a mask or filter this module bound
that the contract no longer asks for, and, for a table it cannot bind, revokes SELECT from
the principals the contract restricts and exits non-zero. Live bindings are read from the
Tables API and only what differs runs, so a second apply executes nothing. A dry run prints
the statements and runs none. The stand-in executes the statements and serves the bindings.

* feat(verify): fluid verify checks a Databricks table's row filter and column masks

The contract's restrictions are Unity Catalog functions bound on the table, named by a hash
of their definition. Verify compares the table's live row_filter and each column's mask,
from the GetTable it already reads, with the names the contract expects: a binding that is
missing, or is another function (a changed predicate is a new name), is CRITICAL and the fix
is fluid apply. No restriction in the contract skips the dimension.

* test(databricks): a real tofu apply binds the masks and row filter against the stand-in, and fails safe

Apply binds the filter and masks and a second apply executes no statement; a table that
exists is bound before the apply can grant on it; a changed predicate binds a new function
and keeps the old; a removed restriction is dropped and said to widen access; a binding
that fails revokes SELECT from the restricted principals only, fails the run, and the next
apply binds first and grants again; a failure before the apply stops it with nothing applied.

* docs(databricks): the changelog and tested-honestly rows say what is enforced, how, and what is not proven live

* fix(databricks): a contract with no restriction needs no databricks-sdk at apply and says nothing without it

* fix(databricks): a mask or filter tests every identity as a group and as a user, so a remapped principal is still restricted

The test was chosen from the logical principal's prefix. binding.principals may map a group:
principal to a service principal's application ID or a user's email, and the group test then
matched no one while the grant to that identity still applied: the mask and filter never
took effect. Every identity is tested as is_account_group_member(x) OR current_user() = x;
a name that is both a group and a user is restricted either way, which errs closed. The
prefix only refuses domain: and unprefixed principals now.

* fix(databricks): a mask rule belongs to the principal that names it, and the strictest matching one applies

One rules[column] kept the last restriction's rule for every masked member, so a nullify for
one principal and a last_four for another gave the first the last four characters, a weaker
mask than declared. Members are grouped by rule and the function has one WHEN branch per
rule, strictest first: deny, nullify, first_four, last_four, so a member of several groups
gets the strictest that matches. Each text rule is type-checked against the column.
restrictions_for takes rule_per_principal for a platform whose mask tests each principal
itself; GCP and AWS keep refusing two rules for one column.

* fix(databricks): a tofu apply that fails part way still binds the masks and row filter of the tables that exist

A failed apply raised before post_apply ran, so a table it created, its grants and the dbt job
that fills it could exist with no mask or filter, and a scheduled run would expose unmasked
rows. Before the apply error is raised, post_apply now runs best effort in a bind-only mode
(after_failed_apply): it binds what exists, revokes SELECT where it cannot bind, skips a table
that does not exist, and drops nothing, since dropping widens access and waits for an apply
that succeeded. A failure there is printed; the run still raises opentofu_apply_failed.

* docs(databricks): the changelog and tested-honestly rows describe per-identity tests, per-principal mask rules and the bind-only pass after a failed apply

* fix(databricks): a function under a hashed name is read back and must be the one this module creates, before it is bound

The name fluid_mask_<table>_<column>_<hash8> can be computed from the contract, so anyone with
CREATE FUNCTION on the schema could create it first with a body that returns the value
unmasked: CREATE FUNCTION IF NOT EXISTS kept theirs, SET MASK bound it and fluid verify, which
compared names only, passed. After each create and before any bind the function is read
(GET /api/2.1/unity-catalog/functions/{name}) and its owner, body, parameters and return type
must be the expected ones, whitespace-normalised; the owner must be the identity the run
authenticates as (userName of scim/v2/Me, who CREATE FUNCTION makes the owner). A mismatch is a
failed bind, printed with what differs. fluid verify reads every bound function back and checks
the same, accepting the table's owner as well since verify may run as someone else.

* fix(databricks): a row filter or mask set by hand is never replaced silently

SET ROW FILTER and SET MASK overwrite whatever is bound, so a filter or mask an admin set (a
function without the fluid_ prefix) was lost without a word when the contract asked for its
own. Every table is read and planned before any statement runs, and a live binding this
module did not create, where the contract asks for one, is refused naming the table, the
column and the live function, with the remedy: remove it, or move its rule into the contract.
Nothing is changed on any table. After an apply it is a failed bind (the fail-safe runs).

* fix(databricks): a Tables API read that fails for one table is a failed bind for it, and the other tables are still tried

_read raised outside the try that triggers the fail-safe, so a transient 429 or 503 on one
table aborted post_apply with that table granted and unbound and the rest never tried. Read
failures are caught per table, after an apply and after a failed one: a table with
restrictions is a failed bind (the fail-safe runs, the error names it) and the loop goes on;
one without is a note. A read that fails before the apply still stops the run.

* fix(databricks): a failed bind binds a deny-all row filter first, and only then locks the table by revoking

The fail-safe revoked SELECT from the restricted principals alone, which misses a user in
another granted group and SELECT inherited from the schema or catalog, and the message claimed
none of them could read unmasked rows. A failed bind now binds a fixed-purpose deny-all row
filter for the restricted identities (a zero-column function, RETURN NOT <any restricted
identity>, content-hashed and checked for owner and body like every function): it blocks them
whatever the grant path. If that cannot be bound, or would replace a row filter this module
did not create, SELECT (and ALL PRIVILEGES) is revoked on the table from every grantee but its
owner, listed through the permissions API. The message says exactly what was done and names
each grant path still open, a schema or catalog grant a table-level REVOKE cannot close, with
the statement to run.

* fix(databricks): a row filter that needs a column the apply adds is held by a deny-all filter until then

pre_apply skipped a filter whose columns the table does not have yet, and the table exists
with rows, so the restricted principals read every row until post_apply. When the real filter
cannot bind and the table exists, pre_apply now binds the interim deny-all row filter (the
fail-safe's own: a zero-column function, SET ROW FILTER ... ON (), owner and body checked)
for the restricted identities, and post_apply replaces it with the real one. A dry run shows
it; a hostile function under its name, or a row filter this module did not create, stops the
run before the apply. SET replaces a bound filter directly: a DROP first would leave a window
with no filter.

* fix(databricks): generate iac refuses a module a plain tofu apply would leave unmasked

The masks and row filters are bound on the SQL warehouse around tofu apply, not in the module,
so fluid generate iac wrote a module that creates the table and grants SELECT with none of
them and told the reader to review and apply it with OpenTofu. generate iac now asks the
plugin (standalone_module_refusal) before it writes one, and refuses a Databricks contract
that compiles any mask or row filter as databricks-enforcement-outside-module, saying that a
plain tofu apply would grant unmasked access and to use fluid apply. emit stays pure; fluid
apply, plan and validate are unaffected.

* fix(databricks): a dbt job that writes a table not yet protected is created paused

A continuous or frequent cron job created in the same tofu apply as its restricted table and
the grant on it could run and fill the table before post_apply binds the masks and row filter.
DatabricksIacPlugin.emit (the apply path; the pure compile validate uses is unchanged) now
reads the live bindings of each restricted table a job writes and sets pause_status to PAUSED on
the job's schedule or continuous block unless the table exists, its row filter and masks are the
expected functions on the expected columns, and each function is the one this module creates.
An unreadable workspace counts as not protected. The next fluid apply, once they are bound,
unpauses it in place, with no drift from unpausing by hand. A manual job has nothing to pause.
plan_notices says why a job is created paused and that run-now works meanwhile. The engine
comment that claimed binding happens before any build can fill a table is corrected.

* docs(databricks): the changelog and tested-honestly rows describe the verified functions, the deny-all fail-safe, the paused jobs and what is still unproven live

* chore(tests): the unused names the code-quality review found are gone

An unused import of copy and Optional, two unused helper imports and an
unused unpacking target in the Databricks tests; the stand-in's deploy
identity is now the one constant the module itself uses.

* chore(ci): bump reviewdog/action-actionlint from 1.80.6 to 1.81.1 (#782)

Bumps [reviewdog/action-actionlint](https://github.com/reviewdog/action-actionlint) from 1.80.6 to 1.81.1.
- [Release notes](https://github.com/reviewdog/action-actionlint/releases)
- [Commits](reviewdog/action-actionlint@48e36f5...619a81e)

---
updated-dependencies:
- dependency-name: reviewdog/action-actionlint
  dependency-version: 1.81.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(ci): bump github/codeql-action init and analyze to 4.39.1 together (#745)

Dependabot split the 4.39.1 bump into #745 (init) and #747 (analyze). CodeQL fails any run whose steps are on different versions, so each PR alone was red on all three Analyze jobs. This one now bumps both to the same pinned SHA (9b3254085494b55a0f7c4c2b3d2d52cc712ce370, checked against the upstream v4.39.1 tag), and dependabot.yml groups github/codeql-action* so init, analyze and upload-sarif arrive in one PR from now on.

* fix: doctor --strict, an honest roadmap, and a readable, redacted OpenTofu apply failure (#786)

* feat(doctor): --strict exits 1 when doctor reports "Action needed"

fluid doctor printed "Action needed" for a copilot that is not ready and
still exited 0, so a CI gate on doctor could not fail. With --strict it
exits 1 whenever the summary says "Action needed", in the text report and
with --json, whose payload now carries action_needed and copilot_ready.
The default exit code is unchanged; --strict has no effect with
--features-only, which prints no summary.

Found by an external hands-on assessment of 0.26.0.

* fix(roadmap): stop claiming a 1.0 release; name every provider in the description

fluid roadmap printed "forge-cli v1.0 shipped 2026-04-23" while the
package is 0.x and classified Beta. The roadmap now says so and calls its
"v1" headings an internal milestone, and a test fails if it announces a
1.x release while the package is below 1.0. The package description named
GCP, Snowflake and Local; it now also names AWS and Databricks.

Found by an external hands-on assessment of 0.26.0.

* fix(apply): a failed OpenTofu apply names its diagnostic and the way on, redacted

The runner uses -json, so the error a failed apply printed was the last
800 characters of diagnostic JSON lines, with no hint. The error now reads
the first error diagnostic (summary, resource address, provider detail),
falls back to the first "Error:" or the exit code, and says that OpenTofu
records state as it goes, so fixing the cause and re-running fluid apply
normally continues; it names the workdir for `tofu state list`, and only a
state-lock error points at `tofu force-unlock`. The raw tail goes to
--debug.

Every value a CLIError prints is now redacted and has its control
characters spelled out, as the other error handlers already did: the new
detail field carries provider text, which can hold a credential.

Found by an external hands-on assessment of 0.26.0.

* fix(cli): error values go through the full secret redactor; a locked plan says how to clear it

The top-level CLIError handler ran context values through _console's
narrow pattern only (bare password/token/secret/api key followed by : or
=), which leaves an AKIA access key id, a Bearer token, a URL password,
JSON-quoted and *_secret_key shapes and env-literal secrets in clear.
Context values and suggestions now go through redact_secret_text first,
the redactor the logs already use. _apply_failure redacts the diagnostic
before cutting it at 800 characters, since a credential split at the
limit (a URL that lost its @) matches no pattern.

A stale state lock first surfaces at tofu plan, which printed only the
raw tail; _plan_failure now redacts it and, for a state-lock error only,
says to wait for the other run or clear it with tofu force-unlock in the
named workdir.

Found by the security review of this branch.

* test: allowlist the deliberate fake credentials the secret scan flags

detect-secrets failed Lint & Format on the AWS-example key and the
admin:s3cretpw DSN these redaction tests feed in on purpose. Each value
now carries `# pragma: allowlist secret` on every line that holds it,
not just the first: detect-secrets reports one finding per value per
file, so allowlisting only lines 690/696 and 412/413 would have surfaced
the copies at 695/701 and 424 on the next run.

Two lines were pre-split so the pragma stays on the value. With the
comment appended, black would otherwise rewrap them and leave the pragma
on a closing bracket, where detect-secrets does not look.

* test: make welcome-scan-dependent tests independent of probe timing (#792)

run_welcome_scan drops any probe result that misses its 0.4s/1.0s budget,
and pick_mode takes its default from those results. On a loaded runner the
workspace probe overran and test_pick_mode_default_picks_from_product_when_
workspace_has_products saw "ai" instead of "from_product" (PR #782, Python
3.14 coverage leg).

Add a deterministic_welcome_scan fixture that lifts the budgets and points
the dbt probe at "no dbt" (it sizes its `dbt --version` subprocess from the
same budget), and apply it to the four modules whose tests assert on probe
results. Exposure audit: with every probe result dropped, 13 tests in those
four modules fail; the other test files that reach the scan do not. A new
test pins the original failure with a deliberately slow workspace probe.

The product budget is unchanged.

* security: validate_ident let a name with a trailing newline through (#788)

* security: validate_ident let a name with a trailing newline through

Python's `$` also matches just before a trailing newline, so
`_SAFE_IDENT.match("orders\n")` succeeded and validate_ident returned
"orders\n" unchanged. Every DDL emitter routes identifiers through it,
and a name written into a line-based file (a Java properties file, as
in #784) gains a second line.

The four whole-string allowlists in _sql_safety.py now end in `\Z`.
Only _SAFE_IDENT changes behaviour: the type-name, type-parameter and
RLS-expression helpers already strip their input, so `\Z` there keeps
a later edit that drops the strip from reopening the gap.

tests/providers/test_sql_safety_type_param.py pins validate_ident
against "\n", "\r\n" and an embedded "\n", and each pattern against a
trailing "\n".

* docs(changelog): validate_ident refuses a name with a trailing newline

* chore(secrets): move the two _sql_safety baseline hits to their new lines

* ci: freeze dependency weight and startup cost before the diet (#791)

* ci: freeze dependency weight and startup cost before the diet

Guards at today's numbers, so nothing grows while later PRs move litellm,
mcp and openlineage out of the base install.

- scripts/ci/dep_budget.py and .github/workflows/dep-budget.yml resolve
  the base install with `uv pip compile` for linux/3.12, linux/3.10 and
  windows/3.12 and fail above a committed ceiling (88/86/89), naming the
  packages added and removed against a baseline. It is its own workflow
  (PRs touching pyproject, main, weekly), because an upstream release can
  move the count with no change here.
- scripts/ci/bare_install_smoke.sh, called from ci.yml build-check and
  release.yml, caps the bare wheel's build_parser() import cost (869) and
  runs validate, plan, bundle, generate iac and doctor on an AWS example.
- .importlinter-external is an import-linter forbidden contract with
  external packages: litellm, mcp, openlineage and boto3 stay off the core
  modules. The 8 lazy routes that exist today are listed one by one.
- ruff TID253 bans module-level imports of the heavy SDKs, with 4
  exemptions. It does not see an import inside a module-level try:, which
  the config says.
- deptry 0.25.1 joins [dev]. CI runs it as advisory until 0.29.0.
- tests/perf/test_startup_budget.py lowers MAX_MODULES from 1100 to 895
  (852 on 3.12, plus 5%) and adds a discover_providers() probe capped at
  916. Both counts print in the CI log.

Borrowed, not built: uv, import-linter (include_external_packages,
ignore_imports), ruff flake8-tidy-imports, deptry.

* ci: keep the bare-install smoke out of the release build job

release.yml's build job holds id-token: write and attestations: write.
Running the wheel and its ~88 transitive dependencies (resolved at
release time, not hash-locked) through validate, plan, bundle,
generate iac and doctor there widened an exposure that `fluid init`
already had. A failing doctor check could also block a tag.

A tag's merge commit has already run the same smoke on the same wheel
build in ci.yml build-check. So release.yml goes back to its
--version and init check, and the smoke runs only in CI.

* ci: the dependency budget fails loudly and ratchets

Fixes from the defect hunt on the budget guard:

- A uv failure (a resolver conflict or an index outage) now prints uv's
  own stderr. CalledProcessError's message left it out.
- `name @ url` requirements are counted. Any line the parser does not
  understand raises, so no package slips past the budget uncounted.
- --update compares against the committed baseline before rewriting it,
  so an over-ceiling run still names the packages added and removed.
- A count under its ceiling prints a notice to lower the ceiling.
- Pushes to main run the workflow only when pyproject.toml, the script,
  the baselines or the workflow change, as PRs already did. Upstream
  drift still shows in the weekly run without turning unrelated pushes
  red.

The bare-install smoke now resolves the venv path to an absolute one,
because its probe runs from /tmp, and removes its temp dir on exit.

* fix: install hints name data-product-forge, not fluid-build (#790)

* fix: install hints name data-product-forge, not fluid-build

Doctor, the copilot, the MCP, ODCS and DuckDB errors, the generated
Jenkinsfile and the agent docs told users to `pip install fluid-build[...]`.
The distribution is `data-product-forge`; every hint now names it with the
right extra.

- Add an empty `mcp` extra so `data-product-forge[mcp]` resolves today; it
  is filled when mcp leaves the base install.
- tests/test_no_wrong_package_name.py fails on any `fluid-build` not
  followed by `.` (domains are a separate fence). The allowlist matches a
  line, not a file (only OTel's service.namespace), and a coverage guard
  keeps the scan honest. Pattern borrowed from
  tests/test_no_reserved_tld_defaults.py.

* test: catch every shape of a wrong install hint, and missing extras

Follow-up from the security review:

- An extras-exist check: every `data-product-forge[...]` in the scanned
  files must name extras that pyproject declares. It failed first on two
  existing hints. tracing.py now names the OTel packages it imports
  (opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http), and the
  CONTRIBUTING.md example names `atlasclient`. Neither extra existed, so
  pip installed the base package and the feature still failed.
- A sentence-final "fluid-build." no longer passes as a domain.
- Install commands (pip, pip3, pipx, uv pip) fail on any fluid[-_.]build
  spelling, since PyPI normalises them to one name. The import package
  `fluid_build` stays legal everywhere else.
- The scan now covers the non-.py files shipped in the wheel and
  CONTRIBUTING.md, and asserts that it read them.
- odcs.py quotes its extra, which zsh rejected unquoted.

* fix: the vowl hint keeps its extra, and the secrets baseline follows

From the verifier:

- `fluid odcs validate` printed the vowl hint through Rich markup, which
  read `[odcs-strict]` as a tag and dropped it. Users were told to
  install the base package, which has no vowl. It now prints with
  markup=False, as generate_ci already does. A test drives the real
  cprint.
- tests/test_litellm_backend.py moved a baselined fake key from line 566
  to 607. CI's detect-secrets-hook on changed files would exit 3, so
  .secrets.baseline is refreshed. The diff is that line number and
  generated_at only; no secret entry was added.

* fix(diff): fluid diff --baseline sees the columns of a 0.7.x contract (#789)

fluid diff --baseline read an expose's columns from exposes[].schema, but a 0.7.x
contract keeps them in exposes[].contract.schema, so a removed column, a type
change or a new NOT NULL column reported "No changes detected.", --fail-on-breaking
exited 0, and the fluid forge --refine preview said "No changes vs the existing
contract." Columns now come from contract.schema when schema is absent, and a
column is NOT NULL when required is true or nullable is false.

* fix(diff): fluid diff --baseline sees the columns of a 0.7.x contract
* fix(diff): a column is NOT NULL when required or nullable: false says so
* test(diff): reuse the shared 0.7.6 fixture and pin which spelling names a nullability finding

* test(streaming): say why the live fixture poll swallows OSError

Answers the code-quality review on #784 (empty except): the fixture is
still starting, so the poll retries.

* fix(diff): a container child's own name no longer hides its change

`_diff_container_children` built `{"name": key, **child}`, so a `name`
inside an element/key/value mapping overrode the slot name. A non-string
name dropped the child from the diff, and a string one moved its path.
The slot name now wins. Found by the resolution review; inherited from #784.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fas89 added a commit that referenced this pull request Nov 4, 2026
…atch (#816)

Python's $ also matches just before a final "\n", so
re.match(r"^[a-z]+$", "orders\n") succeeds. #788 and #797 fixed the known
validators with \Z or fullmatch, but nothing kept the class from coming
back: no ruff, pylint, bandit, dlint or Semgrep CE rule covers it
(astral-sh/ruff#15017 proposes one).

tests/security/test_no_dollar_anchored_validators.py walks the AST of
fluid_build/ and follows the pattern of every .match / .search call back
to where it is written: a literal or constant, a dict or tuple, an import,
a helper's return value, a property, a re.Pattern parameter, or a pattern
compiled at run time. It reads each pattern with re's own parser, so
alternatives, groups, escapes and VERBOSE count the way re reads them.
Calls whose $ is deliberate are listed in _ACCEPTED with the reason. An
entry that no longer matches a call fails as well, so the list only
shrinks.

On main before #797 it fails on 44 calls, including the shapes the sweep
missed: a pattern held in a dict, schema patterns compiled at run time,
and a pattern the caller passes in. It also caught the $ + .match sites
merged since (#793, #801, #799). Each was read and is listed with its
reason.
@fas89 fas89 mentioned this pull request Nov 4, 2026
2 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

provider Changes or requests related to providers security Security-related changes or reports tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant