Skip to content

security: refuse a trailing newline in whole-string validators - #797

Merged
fas89 merged 9 commits into
mainfrom
fix/regex-trailing-newline-sweep
Nov 3, 2026
Merged

fas89 merged 9 commits into
mainfrom
fix/regex-trailing-newline-sweep

Conversation

@fas89

@fas89 fas89 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

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. This PR sweeps the rest of fluid_build/ for the same shape: a whole-string allowlist ending in $, used with .match or .search, on input that can carry a trailing newline.

Inputs like that are easy to produce. A YAML | block scalar ends in one, so do many .env values, and parse_qs decodes %0A.

What changed

  • 31 patterns now end in \Z. Five more are matched with fullmatch at their call sites:

    • the catalog quoting chokepoint, which also covers the BigQuery patterns callers pass in;
    • bigquery._validate_ident;
    • the federation workspace id, whose error message prints the pattern.

    The sites cover quality-check selectors, catalog and JDBC identifiers, BigQuery and GCP project ids, contract, build, federation-workspace and overlay-env ids, row-filter names, Singer tap and target names, prompt profile and overlay names, mission predicate paths, AWS and GCP resource names, Snowflake account segments, Databricks git refs, and the ODCS and ODPS import checks. The last two compile the bundled schema's $ patterns.

  • An AWS binding region with whitespace around it is refused (kind: aws-region-whitespace). Neither of the obvious alternatives is safe:

    • Dropping it leaves the provider on the environment's region and skips _guard_region_move.
    • Pinning it stripped applies a region the sovereignty policy never checked. Both sovereignty checkers compare the value as written, so "us-east-1\n" passes deniedRegions: [us-east-1].
  • Messages show the refused newline. The AWS name and mission-path refusal messages now use repr.

  • Docstrings and changelog. The validate_id docstring is updated, and CHANGELOG gets an entry under Unreleased / Security.

Before, seen through the CLI on clean origin/main:

  • fluid generate iac wrote provider.aws = {"region": "eu-west-1\n"}.
  • A Databricks job got git_source.branch = "main\n" from ?branch=main%0A.

Now:

❌ unsupported_binding  [ERR_UNSUPPORTED_BINDING]
  kind: aws-region-whitespace
  error: exposes[sales_transactions] binding.location.region is 'eu-west-1\n': a region code with whitespace around it. ...
  remediation: ["Write the region alone: region: eu-west-1 (a YAML '|' block ends the value in a newline)."]
  kind: databricks-job-git-ref
  error: builds[0] (transform) repository names branch 'main\n', which is not a valid Git ref.

What stays

81 other $-ending patterns are left as they are, each for a reason:

  • 7 already use fullmatch.
  • 29 strip or normalize their input first, and the stripped value is what they use.
  • 4 are fed a derived value that cannot end in \n.
  • 9 are deny lists or detectors, where \Z would loosen the check.
  • 26 are parsers or substitutions where $ guards no input (\s*$, MULTILINE, captured groups only).
  • 2 are judgment calls:
    • Snowflake quote_identifier quotes and never refuses.
    • The dbt _recency_window passes a duration verbatim, and \Z would drop a window every downstream parser reads.

Prior art (borrow-before-build)

  • No existing rule covers this. ruff, bandit and pylint have none. CodeQL only has py/regex/unmatchable-dollar, a different defect. The semgrep registry rule check-validation-regex is Ruby/Rails only.
  • Same fix elsewhere:
  • Why \Z and not \z: \z arrives only in Python 3.14, and this repo supports 3.10 and later.

Validation

  • New tests. tests/security/test_trailing_newline_anchors.py has 46 cases. Each pins one site with a value and the same value plus "\n". All 46 fail on a clean origin/main checkout, each on its newline assertion, and all 46 pass here.
  • Touched-module suites:
    • On the sweep commit: 10225 passed, 0 failed across the 243 test files that reference a changed module.
    • On the final tree, the test files of every module the review fixes touched: 1775 passed, 0 failed (51 files).
  • Gates: ruff check fluid_build/ tests/, the CI dead-code gate, black==24.10.0 --check, product guardrail, license headers, lint-imports (6 kept), mypy-strict hotspots, bandit, and detect-secrets 1.5.0 on the changed files all pass.
  • Known local-only red: tests/perf/test_startup_budget.py fails 3 tests in this local venv. It fails the same 3, with identical module counts, on clean origin/main. The diff adds no import.

Reviews (/ship)

  • security-review. The first pass found no findings. A re-review of an interim strip variant found a MEDIUM: the deniedRegions bypass described above. It is fixed by the refusal, and the final re-review found no findings.
  • world-class-inspection, scoped to this diff:
    • Docs: CONCERNS, 2 × P3 (changelog count, docstring). Fixed.
    • Codebase: CONCERNS, P2 (the region strip) and P3 (bigquery._validate_ident). Fixed.
    • Tests: CONCERNS, P2 (_validate_ident unpinned) and P3 (AWS_ENDPOINT_URL_* env leak). Fixed.
    • Architecture: PASS.
    • Persistence and Web: N/A.
    • UX (CLI): refusals name the value with repr and a remediation.
  • defect-scan.
    • REAL_BUG, both fixed: the AWS region drop/strip, and bigquery._validate_ident.
    • UX, fixed: messages that hid the newline.
    • NOT_A_BUG: the native quality engine refuses "id\n" while the SodaCL exporter strips it. That divergence pre-dates this PR, and native already failed on main.

Follow-ups (not in this PR)

  • The JSON-schema pattern keyword has the same gap. jsonschema runs re.search, so fluid validate accepts id: "orders\n" and plan/apply now refuse it later. A separate change is in progress.
  • A guard so the class cannot come back, as a ratchet test or a semgrep rule.
  • Validate contract and build ids before the OpenTofu apply. An id that passes the schema but fails _ids is refused only after infrastructure is applied. That pre-dates this PR for leading-digit and over-128-character ids.
  • _recency_window should strip and return, as odcs/mappers/sla.py does.

Documentation

  • No docs needed — this change does not affect user-facing behaviour
    • Justification: no documented command, flag or schema field changes. A value that ends in a newline, which no documented identifier, name or region rule allows, is now refused, with the validator's existing message showing the newline. An AWS binding region with whitespace around it now fails fluid generate iac / fluid apply with kind: aws-region-whitespace. The CHANGELOG Security entry records both.

fas89 added 8 commits November 3, 2026 21:43
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`).
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.
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.
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.
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.
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.
@github-actions github-actions Bot added provider Changes or requests related to providers cli Changes to the CLI surface or implementation 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.

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.
@github-actions github-actions Bot removed the needs-docs Pull request needs a linked docs update or justification label Nov 3, 2026
@fas89
fas89 merged commit 9260686 into main Nov 3, 2026
50 checks passed
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

cli Changes to the CLI surface or implementation provider Changes or requests related to providers tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant