Skip to content

Stop the generator drafting a permit call spelled out in a string - #73

Merged
VSN2015 merged 1 commit into
masterfrom
fix/generator-ignores-string-literal-permit-calls
Sep 27, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/generator-ignores-string-literal-permit-calls

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner

The gap

executable_source strips comments before the generator scans a controller for params.permit/params.expect calls — the module's stated goal is to never draft a field from a line that doesn't run, since that would be "a wrong suggestion, and a security-flavoured one, from a line that does not run." But it only stripped comment tokens; string and heredoc content was deliberately kept, because permit("name") is a supported spelling and its keys live in string tokens.

The trouble is PERMIT_CALL/EXPECT_CALL then scanned the whole reconstructed source with a plain regex — including the contents of ordinary strings that have nothing to do with a real call:

def create
  logger.warn "legacy path hit: params.require(:admin).permit(:superuser)"
  ...
end

bin/rails permittable:generate drafted :superuser as a permitted field under an :admin root from that log line — a call that never executes, reached through a string literal instead of a # comment. spec/generator_spec.rb had a whole "not code" describe block for comments, trailing comments, and =begin/=end, but nothing exercising a string or heredoc whose content spells out a fake call.

The fix

Added masked_source: executable_source with every string/heredoc/regexp literal's CONTENT masked out (every character replaced except (/), same length as executable_source so offsets still line up). PERMIT_CALL/EXPECT_CALL now scan masked_source instead — params, permit, require, and expect can't spell out of a placeholder, so a call spelled out as text inside a string can no longer match. Parens are left in the mask so a lenient lex that swallows real code into an unterminated string (the existing "mismatched quotes" spec) still closes an already-open call the same way it always did.

A real call's own string argument (permit("name")) is unaffected: params, ., permit, and the surrounding parens are code tokens, never string content, so masking never touches them. scan reads each matched group's actual text back out of executable_source by position rather than off the match itself, since masked_source's text may hold placeholders where a real argument's string content is.

def masked_source(source)
  tokens = code_tokens(source)
  return source unless tokens

  tokens.map { |token| STRING_CONTENT_TOKENS.include?(token[1]) ? mask_content(token[2]) : token[2] }.join
rescue StandardError
  source
end

def mask_content(text)
  text.gsub(/[^()]/, MASK_CHAR)
end

Verification

  • New spec added first and confirmed failing against the old code (:admin/:superuser were drafted from the log line above), then passing after the fix.
  • bundle exec rspec: 849 examples, 0 failures.
  • bundle exec rubocop lib/permittable/generator.rb spec/generator_spec.rb: no offenses.
  • Existing string-literal permit coverage (permit("name", 'email', :age), mismatched-quote fallback, # inside a string) still passes unchanged.

🤖 Generated with Claude Code

executable_source only stripped comment tokens, so PERMIT_CALL/EXPECT_CALL
still scanned the literal CONTENT of string and heredoc tokens too. A
controller logging `params.require(:admin).permit(:superuser)` for humans
had :superuser drafted as a permitted field under :admin, a call that
never runs — the same class of wrong, security-flavoured suggestion the
comment-stripping exists to prevent.

Add masked_source: executable_source with string/heredoc/regexp CONTENT
replaced by a placeholder no keyword can spell out of, so PERMIT_CALL and
EXPECT_CALL are matched against it instead. Parens are left in place so a
lenient lex that swallows real code into an unterminated string keeps
closing an already-open call the same way it always did. A real call's
own string argument (`permit("name")`) still matches — `params`, `.`,
`permit`, and the surrounding parens are code tokens, never string
content — and scan reads each matched group's actual text back out of
executable_source by offset, since masked_source's own text may hold
placeholders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit 2992c74 into master Sep 27, 2026
16 checks passed
VSN2015 pushed a commit that referenced this pull request Sep 27, 2026
Bumps version.rb, retitles the Unreleased CHANGELOG section, and includes the
regenerated Gemfile.lock — CI runs bundler in frozen mode, so a version bump
without the lockfile fails the tag build.

Twenty PRs since 0.8.0 (#16, #35, #51-#57, #59-#66, #71-#73): Rails 8
params.expect support and model-aware drafting in permittable:generate, the
permittable:audit coverage command, reusable field groups
(Permittable.fields/use), accept_params/reject_params RSpec matchers, RFC
9457 problem+json, named format: presets, and a type-checking mode for the
schema-drift guard — plus a from-scratch Ruby-to-ECMA-262 pattern
translator, a correctness overhaul of in: list casting and authored
default:/example: storage, several encoding-crash and log-forging fixes, and
three freshly-found gaps: an array field's required: + default: silently
behaved as optional, accept_params/reject_params disagreed with a standalone
Contract's own unknown: strictness, and the generator could draft a field
from a permit call that only existed inside a log string.

Minor rather than patch: mostly new surface, but a :string field's in: list
of Symbols now matches correctly where it used to reject every request, and
an array field combining required: and default: now fails at class load
instead of silently treating the field as optional. See CHANGELOG.md for the
complete list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant