rubocop-neeto 0.1.19 → 0.1.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a114d1fda0a6640c867ce32d0882d0c804d1e0b4f273d9bfcf13f35d77e8cda7
4
- data.tar.gz: 0f27ddfd9cfce7a6e6499716441730003bf5f4657b650020866ba811bc7966ac
3
+ metadata.gz: 8f79480fcc020ba036f101838a8970627d0d6cee99fdf7db819c0eaf55430e8f
4
+ data.tar.gz: 510ea55903e4e085be783f0bfc618e5e28f725ac399c83d992ea03473b030dff
5
5
  SHA512:
6
- metadata.gz: 8c02805a3712d669826caa86ec47a886347bc3657e8d1d72901f3f5465babcdd9c666e76e63374b3e6ac17c52f0511fe65b2bfb4be4bc1f0eb97ea800fef6f19
7
- data.tar.gz: 726009349f00e1615c5b6e4752bc4cd5be629e3b142af7fb6b3f3bd2d801203a172b70c48a05b4f252063802db1ca001526d0c54a727b676d77177ddca2b29ae
6
+ metadata.gz: 8e93ef6aac8079e2a95f8994e63d34f826a3f5f56041d7285695323bcb3a1910c16027c6a1278c2a81c15d52454366f7a65e89619a14ef7c2592afcdbb499344
7
+ data.tar.gz: 266c32b005d5d7bfb3933f936dc27895e0d388db5e74dfbc4bcf52cfe3683ef58158c8a1f6240d5a7dbf680bd2631eea6fdac5ea949690de839c68a342295ef0
data/README.md CHANGED
@@ -7,6 +7,12 @@
7
7
  3. [Neeto/DirectEnvAccess](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/DirectEnvAccess)
8
8
  4. [Neeto/DeprecatedJobBaseClass](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/DeprecatedJobBaseClass)
9
9
  5. [Neeto/FaradayOverHttparty](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/FaradayOverHttparty)
10
+ 6. [Neeto/HtmlSafeOnDynamicString](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/HtmlSafeOnDynamicString)
11
+ 7. [Neeto/HtmlInStringLiteral](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/HtmlInStringLiteral)
12
+ 8. [Neeto/DirectLiquidRender](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/DirectLiquidRender)
13
+ 9. [Neeto/HtmlTranslationInterpolation](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/HtmlTranslationInterpolation)
14
+
15
+ The HTML-safety cops (6 to 9) report at `Severity: refactor`, like the other Neeto cops, so their offenses fail RuboCop and erb_lint at the default fail level.
10
16
 
11
17
  ## Installation
12
18
 
data/config/default.yml CHANGED
@@ -64,3 +64,69 @@ Neeto/FaradayOverHttparty:
64
64
  Include:
65
65
  - '**/Gemfile'
66
66
  - '**/Gemfile.common.rb'
67
+
68
+ Neeto/HtmlSafeOnDynamicString:
69
+ Description: >-
70
+ `html_safe`, `raw` and `safe_concat` mark a string as already safe, so
71
+ Rails emits it without escaping. Calling them on dynamic values
72
+ (interpolation, method results, variables) turns off output escaping for
73
+ that value. Build markup with `tag`, `content_tag`, `link_to`, `mail_to`
74
+ or `safe_join` instead, or annotate a reviewed call with
75
+ `# neeto:html-safe-reviewed <reason>`.
76
+ Enabled: true
77
+ Severity: refactor
78
+ VersionAdded: '0.1'
79
+ Include:
80
+ - app/**/*.rb
81
+ - lib/**/*.rb
82
+ - app/views/**/*.erb
83
+ - app/views/**/*.inky
84
+
85
+ Neeto/HtmlInStringLiteral:
86
+ Description: >-
87
+ Plain Ruby strings never escape what is interpolated into them. Build
88
+ markup outside views with `tag`, `content_tag`, `link_to`, `mail_to`,
89
+ `safe_join` or a partial instead of string literals and heredocs.
90
+ Enabled: true
91
+ Severity: refactor
92
+ VersionAdded: '0.1'
93
+ Include:
94
+ - app/**/*.rb
95
+ - lib/**/*.rb
96
+ Exclude:
97
+ - app/views/**/*
98
+ - lib/tasks/**/*
99
+
100
+ Neeto/DirectLiquidRender:
101
+ Description: >-
102
+ `Liquid::Template#render` does not escape its output. Build and render
103
+ Liquid templates only through `NeetoCommonsBackend::LiquidRenderer`, which
104
+ escapes every output and requires an explicit format (`:html` or `:text`).
105
+ Check a template's syntax with `NeetoCommonsBackend::LiquidRenderer.validate!`.
106
+ Enabled: true
107
+ Severity: refactor
108
+ VersionAdded: '0.1'
109
+ Include:
110
+ - app/**/*.rb
111
+ - lib/**/*.rb
112
+ - app/**/*.jbuilder
113
+
114
+ Neeto/HtmlTranslationInterpolation:
115
+ Description: >-
116
+ `I18n.t` does not escape interpolated values, even for `_html` keys. A
117
+ translation that contains HTML must not interpolate unescaped values.
118
+ Escape each value with `ERB::Util.html_escape`, or keep the markup out of
119
+ the translation and build it with `tag` or `content_tag`.
120
+ Enabled: true
121
+ Severity: refactor
122
+ VersionAdded: '0.1'
123
+ LocaleFiles:
124
+ - config/locales/**/en.yml
125
+ - config/locales/**/*.en.yml
126
+ Include:
127
+ - app/**/*.rb
128
+ - lib/**/*.rb
129
+ - app/views/**/*.erb
130
+ - app/views/**/*.inky
131
+ Exclude:
132
+ - lib/tasks/**/*
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Neeto
6
+ # `Liquid::Template#render` does not escape its output, so every variable
7
+ # reaches the page or email as-is. Render Liquid through the shared
8
+ # renderer, which escapes every output and requires an explicit format.
9
+ # Code that only checks a template's syntax uses `validate!`, which raises
10
+ # `Liquid::SyntaxError` like `Liquid::Template.parse` does.
11
+ #
12
+ # @example DirectLiquidRender: true (default)
13
+ # # bad
14
+ # Liquid::Template.parse(template.body).render(variables)
15
+ #
16
+ # # good
17
+ # NeetoCommonsBackend::LiquidRenderer.render(template.body, variables, format: :html)
18
+ #
19
+ # # good
20
+ # NeetoCommonsBackend::LiquidRenderer.render(template.subject, variables, format: :text)
21
+ #
22
+ # # bad
23
+ # Liquid::Template.parse(metadata["body"])
24
+ #
25
+ # # good
26
+ # NeetoCommonsBackend::LiquidRenderer.validate!(metadata["body"])
27
+ #
28
+ class DirectLiquidRender < Base
29
+ MSG = "Do not use `Liquid::Template.%<method>s` directly. Render with " \
30
+ "`NeetoCommonsBackend::LiquidRenderer.render(template, variables, format: :html)`, which escapes " \
31
+ "every output, or check syntax with `NeetoCommonsBackend::LiquidRenderer.validate!(template)`."
32
+
33
+ RESTRICT_ON_SEND = %i[parse new].freeze
34
+
35
+ def_node_matcher :liquid_template_build?, <<~PATTERN
36
+ (send (const (const {nil? cbase} :Liquid) :Template) {:parse :new} ...)
37
+ PATTERN
38
+
39
+ def on_send(node)
40
+ return unless liquid_template_build?(node)
41
+
42
+ add_offense(node, message: format(MSG, method: node.method_name))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Neeto
6
+ # Ruby strings never escape what is interpolated into them, so HTML built
7
+ # in a string or heredoc outside views is one missed escape away from an
8
+ # injection. Build markup with `tag`, `content_tag`, `link_to`,
9
+ # `mail_to`, `safe_join` or a partial, which escape their arguments.
10
+ #
11
+ # @example HtmlInStringLiteral: true (default)
12
+ # # bad
13
+ # "<a href='#{url}'>#{name}</a>"
14
+ #
15
+ # # bad
16
+ # <<~HTML
17
+ # <p>#{description}</p>
18
+ # HTML
19
+ #
20
+ # # good
21
+ # link_to(name, url)
22
+ #
23
+ # # good
24
+ # tag.p(description)
25
+ #
26
+ # # good (searching for markup is not building it)
27
+ # body.include?("<table")
28
+ #
29
+ class HtmlInStringLiteral < Base
30
+ MSG = "Do not build HTML in Ruby strings. Use `tag`, `content_tag`, `link_to`, `mail_to`, `safe_join` " \
31
+ "or a partial, which escape their arguments."
32
+
33
+ HTML_TAG = %r{<(?:[a-z][a-z0-9]*[\s/>]|/[a-z][a-z0-9]*\s*>)}i
34
+ FORMAT_REFERENCE = /%<\w+>/
35
+
36
+ ENCLOSING_LITERAL_TYPES = %i[dstr dsym xstr regexp].freeze
37
+
38
+ SEARCH_METHODS = %i[
39
+ include? start_with? end_with? index rindex match match? split scan count delete
40
+ gsub gsub! sub sub! == != eql?
41
+ ].freeze
42
+
43
+ def on_str(node)
44
+ return if part_of_another_literal?(node)
45
+
46
+ check(node, node.value)
47
+ end
48
+
49
+ def on_dstr(node)
50
+ return if part_of_another_literal?(node)
51
+
52
+ check(node, literal_text(node))
53
+ end
54
+
55
+ private
56
+
57
+ def check(node, value)
58
+ return unless value.valid_encoding? && HTML_TAG.match?(value.gsub(FORMAT_REFERENCE, ""))
59
+ return if search_argument?(node)
60
+
61
+ add_offense(node)
62
+ end
63
+
64
+ def literal_text(node)
65
+ node.each_child_node(:str, :dstr).map { |child| child.str_type? ? child.value : literal_text(child) }.join
66
+ end
67
+
68
+ def part_of_another_literal?(node)
69
+ ENCLOSING_LITERAL_TYPES.include?(node.parent&.type)
70
+ end
71
+
72
+ def search_argument?(node)
73
+ parent = node.parent
74
+ return false unless parent&.call_type? && SEARCH_METHODS.include?(parent.method_name)
75
+
76
+ parent.first_argument.equal?(node) || (parent.comparison_method? && parent.receiver.equal?(node))
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Neeto
6
+ # `html_safe`, `raw` and `safe_concat` mark a string as already safe, so
7
+ # Rails prints it without escaping. Calling them on a dynamic value
8
+ # (interpolation, a method result, a variable) turns escaping off for
9
+ # whatever that value holds, which lets user input inject markup. Build
10
+ # markup with `tag`, `content_tag`, `link_to`, `mail_to` or `safe_join`
11
+ # instead. A call that has been reviewed can be kept by adding
12
+ # `# neeto:html-safe-reviewed <reason>` on or above it.
13
+ #
14
+ # @example HtmlSafeOnDynamicString: true (default)
15
+ # # bad
16
+ # "<b>#{booking.name}</b>".html_safe
17
+ #
18
+ # # bad
19
+ # raw(message.body)
20
+ #
21
+ # # good
22
+ # tag.b(booking.name)
23
+ #
24
+ # # good
25
+ # "<br>".html_safe
26
+ #
27
+ # # good
28
+ # # neeto:html-safe-reviewed admin-authored rich text
29
+ # meeting.cancellation_policy.html_safe
30
+ #
31
+ class HtmlSafeOnDynamicString < Base
32
+ MSG = "Do not call `%<method>s` on a dynamic value. Build the markup with `tag`, `content_tag`, " \
33
+ "`link_to`, `mail_to` or `safe_join`, or add `# neeto:html-safe-reviewed <reason>` if the value " \
34
+ "is known to be safe."
35
+
36
+ REVIEWED_ANNOTATION = /#\s*neeto:html-safe-reviewed\s+\S/
37
+
38
+ RESTRICT_ON_SEND = %i[html_safe raw safe_concat].freeze
39
+
40
+ def_node_matcher :view_helpers?, <<~PATTERN
41
+ (send _ {:helpers :view_context})
42
+ PATTERN
43
+
44
+ def on_send(node)
45
+ value = trusted_value(node)
46
+ return if value.nil? || static_string?(value) || reviewed?(node)
47
+
48
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
49
+ end
50
+ alias on_csend on_send
51
+
52
+ private
53
+
54
+ def trusted_value(node)
55
+ case node.method_name
56
+ when :html_safe
57
+ node.receiver if node.arguments.none?
58
+ when :raw
59
+ node.first_argument if (node.receiver.nil? || view_helpers?(node.receiver)) && node.arguments.one?
60
+ when :safe_concat
61
+ node.first_argument if node.arguments.one?
62
+ end
63
+ end
64
+
65
+ def static_string?(node)
66
+ node.str_type? || (node.dstr_type? && node.children.all? { |child| static_string?(child) })
67
+ end
68
+
69
+ def reviewed?(node)
70
+ lines = (node.first_line - 1)..node.last_line
71
+
72
+ processed_source.comments.any? do |comment|
73
+ lines.cover?(comment.loc.line) && REVIEWED_ANNOTATION.match?(comment.text)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Neeto
8
+ # `I18n.t` does not escape interpolated values, even for `_html` keys
9
+ # (only the view helper `t` does). A translation that contains HTML and
10
+ # interpolates an unescaped value lets that value inject markup. In views,
11
+ # the `t` helper escapes the values of `_html` keys, so only those are
12
+ # allowed there. The cop reads the translations from `LocaleFiles`.
13
+ #
14
+ # @example HtmlTranslationInterpolation: true (default)
15
+ # # en.yml
16
+ # # seed:
17
+ # # description: "<p>Schedule a meeting with %{name}</p>"
18
+ #
19
+ # # bad
20
+ # I18n.t("seed.description", name: user.name)
21
+ #
22
+ # # good
23
+ # I18n.t("seed.description", name: ERB::Util.html_escape(user.name))
24
+ #
25
+ # # good
26
+ # tag.p(I18n.t("seed.description_text", name: user.name))
27
+ #
28
+ # # bad (in a view)
29
+ # <%= sanitize t("seed.description", name: @name) %>
30
+ #
31
+ # # good (in a view)
32
+ # <%= t("seed.description_html", name: @name) %>
33
+ #
34
+ class HtmlTranslationInterpolation < Base
35
+ MSG = "Translation `%<key>s` contains HTML and interpolates %<variables>s, which `I18n.t` does not " \
36
+ "escape. Escape each value with `ERB::Util.html_escape`, or keep the markup out of the translation " \
37
+ "and build it with `tag` or `content_tag`."
38
+
39
+ RESTRICT_ON_SEND = %i[t translate].freeze
40
+ OPTIONS = %i[locale scope default raise throw count].freeze
41
+ VIEW_FILE = %r{(?:\A|/)app/views/}
42
+
43
+ def_node_matcher :translation_call, <<~PATTERN
44
+ (send {nil? (const {nil? cbase} :I18n)} {:t :translate} ${str sym array} $...)
45
+ PATTERN
46
+
47
+ def_node_matcher :escaped_value?, <<~PATTERN
48
+ {
49
+ (send {nil? (const (const {nil? cbase} :ERB) :Util)} {:html_escape :h} _)
50
+ (send (const {nil? cbase} :CGI) :escapeHTML _)
51
+ (send _ {:link_to :content_tag :mail_to :safe_join} ...)
52
+ (send (send _ :tag) _ ...)
53
+ }
54
+ PATTERN
55
+
56
+ def self.translations_cache
57
+ @_translations_cache ||= {}
58
+ end
59
+
60
+ def on_send(node)
61
+ translation_call(node) do |key_node, arguments|
62
+ options = arguments.last
63
+ next unless options&.hash_type?
64
+
65
+ key = translation_key(key_node, options)
66
+ next if key.nil? || escaped_by_view_helper?(node, key)
67
+
68
+ translation = translations[key]
69
+ next unless translation.is_a?(String) && HtmlInStringLiteral::HTML_TAG.match?(translation)
70
+
71
+ variables = unescaped_variables(options).select { |name| translation.include?("%{#{name}}") }
72
+ next if variables.empty?
73
+
74
+ add_offense(node, message: format(MSG, key:, variables: variables.map { |name| "%{#{name}}" }.join(", ")))
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def escaped_by_view_helper?(node, key)
81
+ node.receiver.nil? && VIEW_FILE.match?(processed_source.file_path.to_s) &&
82
+ (key.end_with?("_html") || key.split(".").last == "html")
83
+ end
84
+
85
+ def translation_key(key_node, options)
86
+ key = literal_path(key_node)
87
+ scope = options.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :scope }&.value
88
+ return key if scope.nil? || key.nil?
89
+
90
+ scope_path = literal_path(scope)
91
+ "#{scope_path}.#{key}" if scope_path
92
+ end
93
+
94
+ def literal_path(node)
95
+ parts = node.array_type? ? node.values : [node]
96
+ return unless parts.all? { |part| %i[str sym].include?(part.type) }
97
+
98
+ parts.map { |part| part.value.to_s }.join(".")
99
+ end
100
+
101
+ def unescaped_variables(options)
102
+ options.pairs.filter_map do |pair|
103
+ next unless pair.key.sym_type?
104
+ next if OPTIONS.include?(pair.key.value) || escaped_value?(pair.value)
105
+
106
+ pair.key.value.to_s
107
+ end
108
+ end
109
+
110
+ def translations
111
+ self.class.translations_cache[locale_files] ||= locale_files.each_with_object({}) do |path, result|
112
+ YAML.safe_load_file(path, aliases: true)&.each_value { |tree| flatten(tree, nil, result) }
113
+ end
114
+ end
115
+
116
+ def locale_files
117
+ Array(cop_config["LocaleFiles"]).flat_map do |pattern|
118
+ Dir.glob(File.expand_path(pattern, config.base_dir_for_path_parameters))
119
+ end.sort
120
+ end
121
+
122
+ def flatten(tree, prefix, result)
123
+ return result[prefix] = tree unless tree.is_a?(Hash)
124
+
125
+ tree.each { |key, value| flatten(value, [prefix, key].compact.join("."), result) }
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -5,3 +5,7 @@ require_relative "neeto/unsafe_column_deletion"
5
5
  require_relative "neeto/direct_env_access"
6
6
  require_relative "neeto/deprecated_job_base_class"
7
7
  require_relative "neeto/faraday_over_httparty"
8
+ require_relative "neeto/html_safe_on_dynamic_string"
9
+ require_relative "neeto/html_in_string_literal"
10
+ require_relative "neeto/direct_liquid_render"
11
+ require_relative "neeto/html_translation_interpolation"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Neeto
5
- VERSION = "0.1.19"
5
+ VERSION = "0.1.21"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-neeto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.19
4
+ version: 0.1.21
5
5
  platform: ruby
6
6
  authors:
7
- - Yedhin Kizhakkethara
7
+ - Neeraj Singh
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-02 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubocop
@@ -26,7 +26,7 @@ dependencies:
26
26
  version: '0'
27
27
  description:
28
28
  email:
29
- - yedhin@bigbinary.com
29
+ - neeraj@neeto.com
30
30
  executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
@@ -37,7 +37,11 @@ files:
37
37
  - lib/rubocop-neeto.rb
38
38
  - lib/rubocop/cop/neeto/deprecated_job_base_class.rb
39
39
  - lib/rubocop/cop/neeto/direct_env_access.rb
40
+ - lib/rubocop/cop/neeto/direct_liquid_render.rb
40
41
  - lib/rubocop/cop/neeto/faraday_over_httparty.rb
42
+ - lib/rubocop/cop/neeto/html_in_string_literal.rb
43
+ - lib/rubocop/cop/neeto/html_safe_on_dynamic_string.rb
44
+ - lib/rubocop/cop/neeto/html_translation_interpolation.rb
41
45
  - lib/rubocop/cop/neeto/unsafe_column_deletion.rb
42
46
  - lib/rubocop/cop/neeto/unsafe_table_deletion.rb
43
47
  - lib/rubocop/cop/neeto_cops.rb