svg_sentinel 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.
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require_relative "rules"
5
+ require_relative "policy"
6
+ require_relative "structure_listener"
7
+ require_relative "finding"
8
+ require_relative "result"
9
+ require_relative "errors"
10
+
11
+ module SvgSentinel
12
+ # The scanner. It reads an SVG string and reports what makes it unsafe.
13
+ #
14
+ # The rules come from two public sources: general SVG cross-site-scripting
15
+ # knowledge (no scripts, no event handlers, no javascript: URIs, no foreign
16
+ # objects, no XXE) and the BIMI "SVG Tiny 1.2 Portable/Secure" profile used
17
+ # for brand-mark logos (no scripting, no animation, no external references,
18
+ # no raster images). Nothing here is fetched over the network.
19
+ #
20
+ # It is built to survive hostile input: input is normalised to valid UTF-8
21
+ # first, a hard byte cap and streaming structural limits refuse oversized or
22
+ # pathologically shaped payloads before the tree parser runs, DOCTYPE /
23
+ # ENTITY declarations are refused so the parser is never exposed to
24
+ # entity-expansion or external-entity attacks, and every parse failure
25
+ # becomes a finding rather than an exception.
26
+ #
27
+ # Raw findings are re-rated by a Policy for the rendering context and any
28
+ # configured severity overrides before they reach the Result.
29
+ class Scanner
30
+ include Rules
31
+
32
+ DEFAULT_MAX_BYTES = 32_768 # BIMI recommends brand-mark SVGs stay small.
33
+ DEFAULT_HARD_MAX_BYTES = 5_242_880 # 5 MiB: refuse to parse anything larger.
34
+
35
+ DEFAULT_MAX_DEPTH = 100
36
+ DEFAULT_MAX_NODES = 200_000
37
+ DEFAULT_MAX_ATTRIBUTES = 512
38
+
39
+ # Nested <use> can expand exponentially ("SVG use bomb"). If the fully
40
+ # expanded node count would exceed this, the SVG is a render-DoS risk.
41
+ USE_EXPANSION_LIMIT = 1_000_000
42
+
43
+ def initialize(profile: :strict, allow_external: false, allow_data_uri: false,
44
+ max_bytes: DEFAULT_MAX_BYTES, hard_max_bytes: DEFAULT_HARD_MAX_BYTES,
45
+ max_depth: DEFAULT_MAX_DEPTH, max_nodes: DEFAULT_MAX_NODES,
46
+ max_attributes: DEFAULT_MAX_ATTRIBUTES,
47
+ context: :inline, severity_overrides: {}, disabled: [])
48
+ @profile = profile
49
+ @allow_external = allow_external
50
+ @allow_data_uri = allow_data_uri
51
+ # A limit of 0 (or negative, or nil) means "disabled" - the single place
52
+ # this is decided, so config, CLI, and the library agree.
53
+ @max_bytes = limit(max_bytes)
54
+ @hard_max_bytes = limit(hard_max_bytes)
55
+ @max_depth = limit(max_depth)
56
+ @max_nodes = limit(max_nodes)
57
+ @max_attributes = limit(max_attributes)
58
+ @policy = Policy.new(context: context, overrides: severity_overrides, disabled: disabled)
59
+ @findings = []
60
+ @current_path = nil
61
+ end
62
+
63
+ def scan(svg)
64
+ @findings = []
65
+ @current_path = nil
66
+ collect(svg)
67
+ Result.new(@policy.apply(@findings))
68
+ rescue SystemStackError
69
+ # Recursion (parser or tree walk) overflowed the stack on pathologically
70
+ # nested input. Only reachable when structural limits are disabled; the
71
+ # contract is still that scan returns a Result, never raises.
72
+ @current_path = nil
73
+ add_security(:too_deep, :critical, "SVG nesting overflowed the stack")
74
+ Result.new(@policy.apply(@findings))
75
+ end
76
+
77
+ private
78
+
79
+ def limit(value)
80
+ value if value&.positive?
81
+ end
82
+
83
+ # Populates @findings with raw (un-re-rated) findings. Returns early at the
84
+ # first check that makes further inspection pointless or unsafe.
85
+ def collect(svg)
86
+ return add_security(:empty_input, :critical, "SVG is nil or empty") if svg.nil?
87
+
88
+ svg = normalize_encoding(svg)
89
+ return if svg.nil? # encoding already flagged
90
+ return add_security(:empty_input, :critical, "SVG is nil or empty") if svg.strip.empty?
91
+
92
+ return unless check_size(svg)
93
+ return unless check_no_doctype(svg)
94
+ return unless check_structure(svg)
95
+
96
+ doc = parse(svg)
97
+ return if doc.nil?
98
+
99
+ check_root(doc)
100
+ id_map = {}
101
+ saw_use = false
102
+ each_element(doc) do |element, path|
103
+ id = element.attributes["id"]
104
+ id_map[id] = element if id
105
+ saw_use = true if element.name.casecmp?("use")
106
+ inspect_element(element, path)
107
+ end
108
+ check_use_bomb(doc, id_map) if saw_use
109
+ end
110
+
111
+ def normalize_encoding(svg)
112
+ status, utf8 = Rules.to_utf8(svg)
113
+ case status
114
+ when :ok then utf8
115
+ when :bad_utf16
116
+ add_security(:malformed, :critical, "SVG declares a UTF-16 BOM but is not valid UTF-16")
117
+ nil
118
+ else
119
+ add_security(:malformed, :critical, "SVG is not valid UTF-8")
120
+ nil
121
+ end
122
+ end
123
+
124
+ # A hard cap refuses oversized input before REXML sees it. The soft cap is
125
+ # a brand-mark house rule and only warns. Returns false when scanning must
126
+ # stop.
127
+ def check_size(svg)
128
+ bytes = svg.bytesize
129
+ if @hard_max_bytes && bytes > @hard_max_bytes
130
+ add_security(:too_large, :critical,
131
+ "SVG exceeds hard limit of #{@hard_max_bytes} bytes; refusing to parse", "#{bytes} bytes")
132
+ return false
133
+ end
134
+ if @max_bytes && bytes > @max_bytes
135
+ add_policy(:oversize, :warning, "SVG exceeds #{@max_bytes} bytes", "#{bytes} bytes")
136
+ end
137
+ true
138
+ end
139
+
140
+ # Refuse DTDs outright. A DOCTYPE can define entities (billion-laughs) or
141
+ # pull in external ones (XXE). Comments and CDATA are stripped first so a
142
+ # harmless "<!DOCTYPE" mentioned there is not a false positive; neither
143
+ # construct can nest or contain its own terminator, so the strip is safe.
144
+ def check_no_doctype(svg)
145
+ markup = svg.gsub(/<!--.*?-->/m, "").gsub(/<!\[CDATA\[.*?\]\]>/m, "")
146
+ if markup.match?(/<!DOCTYPE/i) || markup.match?(/<!ENTITY/i)
147
+ add_security(:doctype, :critical, "SVG contains a DOCTYPE or ENTITY declaration (XXE risk)")
148
+ return false
149
+ end
150
+ true
151
+ end
152
+
153
+ # Bound the document's shape on a streaming pass before building a DOM. A
154
+ # limit breach is fatal; a parse error is left for the tree parser to
155
+ # report as :malformed.
156
+ def check_structure(svg)
157
+ listener = StructureListener.new(
158
+ max_depth: @max_depth, max_nodes: @max_nodes, max_attributes: @max_attributes
159
+ )
160
+ REXML::Parsers::StreamParser.new(svg, listener).parse
161
+ true
162
+ rescue StructureListener::LimitExceeded
163
+ add_security(listener.breach.code, :critical, listener.breach.message)
164
+ false
165
+ rescue
166
+ true
167
+ end
168
+
169
+ # Any parse failure becomes a finding, never an exception - including a
170
+ # stack overflow from deeply nested input when the depth limit is off.
171
+ def parse(svg)
172
+ REXML::Document.new(svg)
173
+ rescue StandardError, SystemStackError => e
174
+ add_security(:malformed, :critical, "SVG could not be parsed safely", e.message.to_s[0, 200])
175
+ nil
176
+ end
177
+
178
+ def check_root(doc)
179
+ root = doc.root
180
+ if root.nil?
181
+ add_security(:no_root, :critical, "SVG has no root element")
182
+ elsif !root.name.casecmp?("svg")
183
+ add_policy(:not_svg, :warning, "Root element is <#{root.name}>, not <svg>")
184
+ end
185
+ end
186
+
187
+ def inspect_element(element, path)
188
+ @current_path = path.join("/")
189
+ name = element.name.downcase
190
+
191
+ inspect_element_name(element, name)
192
+ inspect_animation_target(element, name)
193
+ inspect_attributes(element)
194
+ inspect_inline_style(element)
195
+ end
196
+
197
+ # A SMIL <animate>/<set> that targets an event handler, href, or style can
198
+ # inject script or a URI whatever its `to` value is - the value-scheme
199
+ # check alone misses `to="alert(1)"` written into onclick.
200
+ def inspect_animation_target(element, name)
201
+ return unless animation_element?(name)
202
+
203
+ target = element.attributes["attributeName"]
204
+ return if target.nil? || !sensitive_animation_target?(target)
205
+
206
+ add_security(:animated_attribute, :critical,
207
+ "Animation targets sensitive attribute #{target}", target.to_s[0, 60])
208
+ end
209
+
210
+ def inspect_element_name(element, name)
211
+ if script_element?(name)
212
+ add_security(:script_element, :critical, "Scripting element <#{element.name}>")
213
+ elsif foreign_element?(name)
214
+ add_security(:foreign_element, :critical, "Foreign-content element <#{element.name}>")
215
+ elsif name == "image"
216
+ add_policy(:raster_image, :warning, "Raster <image> element (disallowed by strict profile)") if strict?
217
+ elsif animation_element?(name)
218
+ add_policy(:animation, :warning, "Animation element <#{element.name}> (disallowed by strict profile)") if strict?
219
+ elsif strict? && !allowed_element?(name)
220
+ add_policy(:disallowed_element, :warning, "Element <#{element.name}> is not in the strict allowlist")
221
+ end
222
+ end
223
+
224
+ def inspect_attributes(element)
225
+ element.attributes.each do |attr_name, value|
226
+ lname = attr_name.downcase
227
+
228
+ if event_handler_attr?(lname)
229
+ add_security(:event_handler, :critical, "Event-handler attribute #{attr_name}", value.to_s[0, 120])
230
+ next
231
+ end
232
+
233
+ inspect_uri_attribute(attr_name, lname, value)
234
+ inspect_attribute_urls(attr_name, lname, value)
235
+ end
236
+ end
237
+
238
+ # Presentation attributes (fill, stroke, filter, mask, clip-path,
239
+ # marker-*, ...) carry url(...) references that reach the network or a
240
+ # scheme just like href does. The style attribute is handled separately by
241
+ # the CSS pass.
242
+ def inspect_attribute_urls(attr_name, lname, value)
243
+ return if lname == "style"
244
+
245
+ css_reference_targets(value.to_s) do |target|
246
+ inspect_reference(target, "#{attr_name} attribute")
247
+ end
248
+ end
249
+
250
+ def inspect_uri_attribute(attr_name, lname, value)
251
+ return unless uri_attribute?(lname)
252
+
253
+ scheme = uri_scheme(value)
254
+ return if scheme.nil? # relative refs and #fragments are fine
255
+
256
+ if dangerous_scheme?(scheme)
257
+ add_security(:script_uri, :critical, "#{scheme}: URI in #{attr_name}", value.to_s[0, 120])
258
+ elsif scheme == "data"
259
+ inspect_data_uri(value, attr_name)
260
+ elsif external_scheme?(scheme) && !@allow_external
261
+ add_security(:external_ref, :warning, "External reference in #{attr_name}", value.to_s[0, 120])
262
+ end
263
+ end
264
+
265
+ def inspect_data_uri(value, where)
266
+ media = data_uri_media_type(value)
267
+ if scriptable_data?(media)
268
+ add_security(:data_uri, :critical, "Scriptable data: URI (#{media}) in #{where}", value.to_s[0, 60])
269
+ elsif !@allow_data_uri
270
+ add_security(:data_uri, :critical, "data: URI in #{where}", value.to_s[0, 60])
271
+ end
272
+ end
273
+
274
+ def inspect_inline_style(element)
275
+ style = element.attributes["style"]
276
+ scan_css(style, "style attribute") if style
277
+
278
+ return unless element.name.casecmp?("style")
279
+
280
+ css = element.texts.join
281
+ scan_css(css, "<style> element")
282
+ end
283
+
284
+ def scan_css(css, where)
285
+ raw = strip_css_comments(css.to_s)
286
+ compact = decontrol(raw)
287
+
288
+ add_security(:css_script, :critical, "Scriptable CSS in #{where}", "expression(") if compact.match?(/expression\s*\(/i)
289
+ DANGEROUS_SCHEMES.each do |scheme|
290
+ add_security(:css_script, :critical, "#{scheme}: in #{where}") if compact.match?(/#{scheme}\s*:/i)
291
+ end
292
+
293
+ css_reference_targets(raw) do |target|
294
+ inspect_reference(target, where)
295
+ end
296
+
297
+ add_policy(:css_import, :warning, "@import in #{where}") if strict? && compact.match?(/@import/i)
298
+ end
299
+
300
+ # A url(...) or @import target reaches the network just like a href does,
301
+ # and can also carry javascript:/data: schemes. Attribute-level URI checks
302
+ # miss these, so CSS and presentation attributes both route through here.
303
+ def inspect_reference(target, where)
304
+ scheme = uri_scheme(target)
305
+ return if scheme.nil?
306
+
307
+ if dangerous_scheme?(scheme)
308
+ add_security(:css_script, :critical, "#{scheme}: URL in #{where}", target.to_s[0, 120])
309
+ elsif scheme == "data"
310
+ inspect_data_uri(target, where)
311
+ elsif external_scheme?(scheme) && !@allow_external
312
+ add_security(:external_ref, :warning, "External reference in #{where}", target.to_s[0, 120])
313
+ end
314
+ end
315
+
316
+ # Walk <use> references and compute the fully expanded node count with
317
+ # memoisation (so the linter itself stays linear even on a use bomb). A
318
+ # reference cycle is reported separately; expansion past the limit is a
319
+ # render-DoS.
320
+ def check_use_bomb(doc, id_map)
321
+ @current_path = nil
322
+ memo = {}
323
+ visiting = {}
324
+ cyclic = false
325
+ bombed = false
326
+
327
+ expand = lambda do |element|
328
+ return 0 if element.nil?
329
+
330
+ oid = element.object_id
331
+ return memo[oid] if memo.key?(oid)
332
+ if visiting[oid]
333
+ cyclic = true
334
+ return 0
335
+ end
336
+ visiting[oid] = true
337
+
338
+ total = 1
339
+ element.elements.each do |child|
340
+ total += expand.call(child)
341
+ break if total > USE_EXPANSION_LIMIT
342
+ end
343
+
344
+ if element.name.casecmp?("use") && total <= USE_EXPANSION_LIMIT
345
+ target = id_map[use_target_id(element)]
346
+ total += expand.call(target) if target
347
+ end
348
+
349
+ visiting.delete(oid)
350
+ bombed = true if total > USE_EXPANSION_LIMIT
351
+ memo[oid] = total
352
+ total
353
+ end
354
+
355
+ expand.call(doc.root)
356
+
357
+ add_security(:use_cycle, :warning, "Recursive <use> reference (rendering may loop)") if cyclic
358
+ if bombed
359
+ add_security(:use_bomb, :critical,
360
+ "Nested <use> expands to over #{USE_EXPANSION_LIMIT} nodes (render DoS)")
361
+ end
362
+ end
363
+
364
+ def use_target_id(element)
365
+ href = element.attributes["href"] || element.attributes["xlink:href"]
366
+ return nil if href.nil?
367
+
368
+ match = href.to_s.strip.match(/\A#(.+)\z/m)
369
+ match && match[1]
370
+ end
371
+
372
+ def each_element(node, path = [], &block)
373
+ node.elements.each do |element|
374
+ current = path + [element.name]
375
+ block.call(element, current)
376
+ each_element(element, current, &block)
377
+ end
378
+ end
379
+
380
+ def strict?
381
+ @profile == :strict
382
+ end
383
+
384
+ def add(code, severity, category, message, detail = nil)
385
+ @findings << Finding.new(
386
+ code: code, severity: severity, category: category,
387
+ message: message, detail: detail, path: @current_path
388
+ )
389
+ nil
390
+ end
391
+
392
+ def add_security(code, severity, message, detail = nil)
393
+ add(code, severity, :security, message, detail)
394
+ end
395
+
396
+ def add_policy(code, severity, message, detail = nil)
397
+ add(code, severity, :policy, message, detail)
398
+ end
399
+ end
400
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/streamlistener"
4
+ require "rexml/parsers/streamparser"
5
+ require "rexml/parsers/baseparser"
6
+
7
+ module SvgSentinel
8
+ # A streaming (SAX) listener that bounds the *shape* of the document -
9
+ # nesting depth, total element count, per-element attribute count - without
10
+ # ever building a DOM. It runs before the tree parser so a pathologically
11
+ # deep or wide payload is refused early, on a memory-bounded pass, instead of
12
+ # being fully materialised. The byte cap limits raw size; this limits the
13
+ # structural cost that a small byte count can still hide (deep nesting,
14
+ # attribute floods).
15
+ class StructureListener
16
+ include REXML::StreamListener
17
+
18
+ LimitExceeded = Class.new(StandardError)
19
+
20
+ Breach = Struct.new(:code, :message)
21
+
22
+ attr_reader :breach
23
+
24
+ def initialize(max_depth:, max_nodes:, max_attributes:)
25
+ @max_depth = max_depth
26
+ @max_nodes = max_nodes
27
+ @max_attributes = max_attributes
28
+ @depth = 0
29
+ @nodes = 0
30
+ @breach = nil
31
+ end
32
+
33
+ def tag_start(_name, attributes)
34
+ @nodes += 1
35
+ @depth += 1
36
+
37
+ if @max_nodes && @nodes > @max_nodes
38
+ breach!(:too_many_nodes, "document has more than #{@max_nodes} elements")
39
+ end
40
+ if @max_depth && @depth > @max_depth
41
+ breach!(:too_deep, "element nesting is deeper than #{@max_depth} levels")
42
+ end
43
+ if @max_attributes && attributes && attributes.size > @max_attributes
44
+ breach!(:too_many_attributes, "an element has more than #{@max_attributes} attributes")
45
+ end
46
+ end
47
+
48
+ def tag_end(_name)
49
+ @depth -= 1
50
+ end
51
+
52
+ private
53
+
54
+ def breach!(code, message)
55
+ @breach = Breach.new(code, message)
56
+ raise LimitExceeded
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgSentinel
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "svg_sentinel/version"
4
+ require_relative "svg_sentinel/errors"
5
+ require_relative "svg_sentinel/rules"
6
+ require_relative "svg_sentinel/finding"
7
+ require_relative "svg_sentinel/result"
8
+ require_relative "svg_sentinel/policy"
9
+ require_relative "svg_sentinel/scanner"
10
+ require_relative "svg_sentinel/sanitizer"
11
+
12
+ # SvgSentinel lints untrusted SVG before you render or embed it. It flags the
13
+ # constructs that make SVG dangerous - scripts, event handlers, javascript:
14
+ # URIs, foreign objects, external references, and XXE via DOCTYPE or ENTITY -
15
+ # and, in its strict profile, the extras that a brand-mark logo (BIMI-style)
16
+ # must not contain, such as animation and raster images.
17
+ #
18
+ # result = SvgSentinel.scan(svg_string)
19
+ # result.safe? # => false
20
+ # result.findings # => [#<Finding code=:script_element severity=:critical ...>]
21
+ #
22
+ # Findings are re-rated for a rendering context (:inline by default, or :img /
23
+ # :css_background where a browser runs SVG without scripting). To clean rather
24
+ # than reject, use SvgSentinel.sanitize, which rewrites the SVG or returns nil
25
+ # when the threat is structural and cannot be safely rewritten.
26
+ module SvgSentinel
27
+ module_function
28
+
29
+ # Scan an SVG string and return a Result. Options are passed through to
30
+ # Scanner: profile (:strict or :general), allow_external, allow_data_uri,
31
+ # max_bytes (soft brand-mark warning), hard_max_bytes (hard refuse-to-parse
32
+ # cap), max_depth / max_nodes / max_attributes (structural limits), context
33
+ # (:inline, :standalone, :img, :css_background), severity_overrides, disabled.
34
+ def scan(svg, **options)
35
+ Scanner.new(**options).scan(svg)
36
+ end
37
+
38
+ # Convenience predicate: true when the SVG has no critical findings.
39
+ def safe?(svg, **options)
40
+ scan(svg, **options).safe?
41
+ end
42
+
43
+ # Rewrite an SVG into a safe one. Returns cleaned SVG as a String, or nil
44
+ # when the input carries a structural threat that cannot be rewritten
45
+ # (DTD/XXE, use bomb, oversize, malformed, non-<svg> root). Options: profile,
46
+ # allow_external, allow_data_uri, and the size/structural limits.
47
+ def sanitize(svg, **options)
48
+ Sanitizer.new(**options).sanitize(svg)
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: svg_sentinel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Suleyman Musayev
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rexml
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.2'
27
+ description: 'A small, dependency-light Ruby tool for untrusted SVG. It flags the
28
+ things that make SVG dangerous to render or embed: scripts, event handlers, javascript:
29
+ URIs, external references (including inside CSS), scriptable data: URIs, foreign
30
+ objects, XXE via DOCTYPE or ENTITY declarations, and nested-<use> render bombs.
31
+ It can also sanitize - rewrite an SVG into a safe one - and re-rates findings for
32
+ the rendering context (inline vs <img>). Normalises encoding, caps size, and bounds
33
+ structural shape on a streaming pass so it is safe to point at hostile input. Ships
34
+ a strict allowlist profile for brand-mark logos (BIMI-style), a general profile,
35
+ YAML config, and a CLI with CI-friendly exit codes and SARIF output. Pure Ruby,
36
+ parses with REXML, no native extensions.'
37
+ email:
38
+ - slmusayev@gmail.com
39
+ executables:
40
+ - svg_sentinel
41
+ extensions: []
42
+ extra_rdoc_files: []
43
+ files:
44
+ - CHANGELOG.md
45
+ - LICENSE
46
+ - README.md
47
+ - exe/svg_sentinel
48
+ - lib/svg_sentinel.rb
49
+ - lib/svg_sentinel/cli.rb
50
+ - lib/svg_sentinel/config.rb
51
+ - lib/svg_sentinel/errors.rb
52
+ - lib/svg_sentinel/finding.rb
53
+ - lib/svg_sentinel/policy.rb
54
+ - lib/svg_sentinel/result.rb
55
+ - lib/svg_sentinel/rules.rb
56
+ - lib/svg_sentinel/sanitizer.rb
57
+ - lib/svg_sentinel/sarif.rb
58
+ - lib/svg_sentinel/scanner.rb
59
+ - lib/svg_sentinel/structure_listener.rb
60
+ - lib/svg_sentinel/version.rb
61
+ homepage: https://github.com/msuliq/svg_sentinel
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ rubygems_mfa_required: 'true'
66
+ homepage_uri: https://github.com/msuliq/svg_sentinel
67
+ source_code_uri: https://github.com/msuliq/svg_sentinel
68
+ changelog_uri: https://github.com/msuliq/svg_sentinel/blob/main/CHANGELOG.md
69
+ bug_tracker_uri: https://github.com/msuliq/svg_sentinel/issues
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.1.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.22
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Lint or sanitize untrusted SVG for scripts, external references, and other
89
+ unsafe content
90
+ test_files: []