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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "finding"
4
+ require_relative "errors"
5
+
6
+ module SvgSentinel
7
+ # Re-rates raw findings for the situation they will be used in. Two layers,
8
+ # applied in order:
9
+ #
10
+ # 1. Rendering context. The same SVG is dangerous differently depending on
11
+ # how a browser will run it. Inline <svg> in the DOM has full script and
12
+ # network power; an SVG loaded through <img src> or CSS background runs in
13
+ # the browser's "secure static" mode - no scripting, no external
14
+ # subresources - so those findings, while still worth noting, are not on
15
+ # their own a reason to reject. Parser-level and denial-of-service
16
+ # findings (XXE, use bombs, oversize, malformed) are context-independent
17
+ # and never downgraded.
18
+ #
19
+ # 2. Explicit overrides. A team can force a code to a severity or drop it
20
+ # entirely from config; this wins over the context rating.
21
+ class Policy
22
+ CONTEXTS = %i[inline standalone img css_background].freeze
23
+
24
+ # Codes whose danger depends on the SVG actually scripting or fetching.
25
+ # Neutralised in a secure-static context (img / css_background).
26
+ CONTEXT_SENSITIVE_CODES = %i[
27
+ script_element event_handler script_uri css_script foreign_element
28
+ data_uri external_ref animation animated_attribute
29
+ ].freeze
30
+
31
+ SECURE_STATIC_CONTEXTS = %i[img css_background].freeze
32
+
33
+ def initialize(context: :inline, overrides: {}, disabled: [])
34
+ @context = context
35
+ @overrides = normalize_overrides(overrides)
36
+ @disabled = Array(disabled).map(&:to_sym)
37
+ end
38
+
39
+ def apply(findings)
40
+ findings.filter_map { |finding| rerate(finding) }
41
+ end
42
+
43
+ private
44
+
45
+ def rerate(finding)
46
+ return nil if @disabled.include?(finding.code)
47
+
48
+ severity = effective_severity(finding)
49
+ return nil if severity == :ignore
50
+ return finding if severity == finding.severity
51
+
52
+ Finding.new(**finding.to_h.merge(severity: severity))
53
+ end
54
+
55
+ # Explicit config override wins; otherwise the context decides.
56
+ def effective_severity(finding)
57
+ return @overrides[finding.code] if @overrides.key?(finding.code)
58
+
59
+ context_severity(finding)
60
+ end
61
+
62
+ def context_severity(finding)
63
+ return finding.severity unless SECURE_STATIC_CONTEXTS.include?(@context)
64
+ return finding.severity unless CONTEXT_SENSITIVE_CODES.include?(finding.code)
65
+
66
+ # Present but inert in a secure-static context: keep it visible, but do
67
+ # not let it make the SVG "unsafe" on its own.
68
+ finding.critical? ? :warning : finding.severity
69
+ end
70
+
71
+ def normalize_overrides(overrides)
72
+ (overrides || {}).each_with_object({}) do |(code, level), acc|
73
+ acc[code.to_sym] = normalize_level(level)
74
+ end
75
+ end
76
+
77
+ def normalize_level(level)
78
+ case level.to_s
79
+ when "error", "critical" then :critical
80
+ when "warning", "warn" then :warning
81
+ when "ignore", "off", "disabled" then :ignore
82
+ else raise ConfigError, "unknown severity override: #{level.inspect}"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgSentinel
4
+ # The outcome of a scan: the list of findings and a few convenience
5
+ # predicates. "Safe" means no critical findings; warnings do not, on their
6
+ # own, make an SVG unsafe to render.
7
+ class Result
8
+ attr_reader :findings
9
+
10
+ def initialize(findings)
11
+ @findings = findings
12
+ end
13
+
14
+ def safe?
15
+ criticals.empty?
16
+ end
17
+
18
+ def critical?
19
+ !criticals.empty?
20
+ end
21
+
22
+ def warnings
23
+ @findings.select(&:warning?)
24
+ end
25
+
26
+ def criticals
27
+ @findings.select(&:critical?)
28
+ end
29
+
30
+ # Genuine attack-surface findings (scripts, XXE, script/data URIs, external
31
+ # references), regardless of severity.
32
+ def security_findings
33
+ @findings.select(&:security?)
34
+ end
35
+
36
+ # Brand-mark / strict-profile house-rule findings (animation, raster
37
+ # images, size, elements outside the strict allowlist).
38
+ def policy_findings
39
+ @findings.select(&:policy?)
40
+ end
41
+
42
+ def to_h
43
+ {
44
+ safe: safe?,
45
+ findings: @findings.map(&:to_h)
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgSentinel
4
+ # The shared security vocabulary: which elements, attributes, and URI schemes
5
+ # are dangerous, plus the pure string helpers that read them. Both the
6
+ # Scanner (which reports) and the Sanitizer (which rewrites) mix this in, so
7
+ # the two can never drift out of agreement about what "dangerous" means.
8
+ module Rules
9
+ module_function
10
+
11
+ SCRIPT_ELEMENTS = %w[script handler listener].freeze
12
+ FOREIGN_ELEMENTS = %w[foreignobject iframe embed object audio video canvas].freeze
13
+ ANIMATION_ELEMENTS = %w[animate animatecolor animatemotion animatetransform set mpath].freeze
14
+
15
+ # The strict profile is an allowlist, not a blocklist: any element whose
16
+ # local name is not here (and is not already covered by a more specific
17
+ # rule) is reported. This fails closed - a new dangerous element is flagged
18
+ # simply for not being on the list. It is a conservative set of static,
19
+ # non-scripting, non-foreign SVG elements. Raster <image>, animation, and
20
+ # foreign/script elements are deliberately absent; they have their own,
21
+ # more descriptive findings.
22
+ STRICT_ALLOWED_ELEMENTS = %w[
23
+ svg g defs symbol use switch view
24
+ title desc metadata
25
+ path rect circle ellipse line polyline polygon
26
+ text tspan textpath tref altglyph glyphref
27
+ lineargradient radialgradient stop
28
+ solidcolor color-profile
29
+ clippath mask pattern marker
30
+ style font font-face missing-glyph glyph hkern vkern
31
+ filter feblend fecolormatrix fecomponenttransfer fecomposite
32
+ feconvolvematrix fediffuselighting fedisplacementmap fedropshadow
33
+ feflood fegaussianblur femerge femergenode femorphology feoffset
34
+ fespecularlighting fetile feturbulence fedistantlight fepointlight
35
+ fespotlight fefunca fefuncr fefuncg fefuncb
36
+ ].freeze
37
+
38
+ DANGEROUS_SCHEMES = %w[javascript vbscript livescript mocha].freeze
39
+ URI_ATTRIBUTES = %w[href src from to values by].freeze
40
+ EXTERNAL_SCHEMES = %w[http https ftp].freeze
41
+
42
+ # Attributes that a SMIL <animate>/<set> must not target: writing script
43
+ # into an event handler, or a URI into href, is a script-injection vector
44
+ # regardless of the animation's `to` value.
45
+ SENSITIVE_ANIMATED_ATTRIBUTES = %w[href xlink:href style].freeze
46
+
47
+ # data: media types that can carry executable markup. These are refused
48
+ # even when data URIs are otherwise allowed - "allow data URIs" means
49
+ # raster images, not an SVG or HTML document smuggled in as a data: URI.
50
+ SCRIPTABLE_DATA_MEDIA = %w[
51
+ image/svg+xml text/html application/xhtml+xml text/xml application/xml
52
+ ].freeze
53
+
54
+ def script_element?(name)
55
+ SCRIPT_ELEMENTS.include?(name)
56
+ end
57
+
58
+ def foreign_element?(name)
59
+ FOREIGN_ELEMENTS.include?(name)
60
+ end
61
+
62
+ def animation_element?(name)
63
+ ANIMATION_ELEMENTS.include?(name)
64
+ end
65
+
66
+ def allowed_element?(name)
67
+ STRICT_ALLOWED_ELEMENTS.include?(name)
68
+ end
69
+
70
+ def event_handler_attr?(lname)
71
+ lname.start_with?("on")
72
+ end
73
+
74
+ # True when a SMIL animation's attributeName points at an attribute that
75
+ # can execute or fetch: an on* handler, href, or style.
76
+ def sensitive_animation_target?(attribute_name)
77
+ target = decontrol(attribute_name.to_s).downcase
78
+ event_handler_attr?(target) || SENSITIVE_ANIMATED_ATTRIBUTES.include?(target)
79
+ end
80
+
81
+ def uri_attribute?(lname)
82
+ return true if URI_ATTRIBUTES.include?(lname)
83
+
84
+ lname.end_with?(":href")
85
+ end
86
+
87
+ def dangerous_scheme?(scheme)
88
+ DANGEROUS_SCHEMES.include?(scheme)
89
+ end
90
+
91
+ def external_scheme?(scheme)
92
+ EXTERNAL_SCHEMES.include?(scheme)
93
+ end
94
+
95
+ def scriptable_data?(media)
96
+ !media.nil? && SCRIPTABLE_DATA_MEDIA.include?(media)
97
+ end
98
+
99
+ # Strip whitespace and control characters before reading a scheme, so
100
+ # "java\tscript:" and " javascript:" cannot slip past.
101
+ def uri_scheme(value)
102
+ cleaned = decontrol(value.to_s)
103
+ match = cleaned.match(/\A([a-z][a-z0-9+.-]*):/i)
104
+ match && match[1].downcase
105
+ end
106
+
107
+ def data_uri_media_type(value)
108
+ cleaned = decontrol(value.to_s)
109
+ match = cleaned.match(/\Adata:([^;,]*)/i)
110
+ return nil unless match
111
+
112
+ type = match[1].downcase
113
+ type.empty? ? "text/plain" : type
114
+ end
115
+
116
+ # Remove whitespace and C0 control characters (NUL through space), a common
117
+ # trick for hiding "javascript:" inside an attribute value.
118
+ def decontrol(str)
119
+ str.gsub(/[[:cntrl:][:space:]]/, "")
120
+ end
121
+
122
+ def strip_css_comments(css)
123
+ css.gsub(%r{/\*.*?\*/}m, "")
124
+ end
125
+
126
+ # Every off-document reference target in a chunk of CSS: both `url(...)`
127
+ # and the string form of `@import "..."` (which `url(...)` scanning alone
128
+ # misses). Yields each raw target string.
129
+ def css_reference_targets(css)
130
+ return enum_for(:css_reference_targets, css) unless block_given?
131
+
132
+ css.scan(/url\(\s*(['"]?)(.*?)\1\s*\)/im) { |_quote, target| yield target }
133
+ css.scan(/@import\s+(['"])(.*?)\1/im) { |_quote, target| yield target }
134
+ end
135
+
136
+ # Classify a single reference target against the current allowances.
137
+ # Returns :dangerous (script/scriptable-data), :external, :data, or nil
138
+ # (relative/internal/allowed).
139
+ def classify_reference(target, allow_external: false, allow_data_uri: false)
140
+ scheme = uri_scheme(target)
141
+ return nil if scheme.nil?
142
+ return :dangerous if dangerous_scheme?(scheme)
143
+
144
+ if scheme == "data"
145
+ return :dangerous if scriptable_data?(data_uri_media_type(target))
146
+
147
+ return allow_data_uri ? nil : :data
148
+ end
149
+
150
+ return :external if external_scheme?(scheme) && !allow_external
151
+
152
+ nil
153
+ end
154
+
155
+ # Normalise arbitrary bytes to valid UTF-8 so downstream byte-level checks
156
+ # are meaningful and no encoding error can escape. Returns
157
+ # [:ok, string], [:bad_utf16, nil], or [:bad_utf8, nil].
158
+ def to_utf8(raw)
159
+ str = raw.to_s
160
+ bytes = str.b
161
+ head = bytes[0, 2]
162
+
163
+ if head == "\xFF\xFE".b || head == "\xFE\xFF".b
164
+ src = (head == "\xFF\xFE".b) ? Encoding::UTF_16LE : Encoding::UTF_16BE
165
+ begin
166
+ return [:ok, bytes[2..].force_encoding(src).encode(Encoding::UTF_8)]
167
+ rescue EncodingError
168
+ return [:bad_utf16, nil]
169
+ end
170
+ end
171
+
172
+ utf8 = str.dup.force_encoding(Encoding::UTF_8)
173
+ return [:ok, utf8] if utf8.valid_encoding?
174
+
175
+ [:bad_utf8, nil]
176
+ end
177
+
178
+ # True when this CSS text carries something executable or off-origin. Used
179
+ # by the Scanner to report and by the Sanitizer to decide whether to drop
180
+ # a style declaration wholesale.
181
+ def dangerous_css?(css, allow_external: false, allow_data_uri: false)
182
+ raw = strip_css_comments(css.to_s)
183
+ compact = decontrol(raw)
184
+ return true if compact.match?(/expression\s*\(/i)
185
+ return true if DANGEROUS_SCHEMES.any? { |s| compact.match?(/#{s}\s*:/i) }
186
+
187
+ css_reference_targets(raw).any? do |target|
188
+ !classify_reference(target, allow_external: allow_external, allow_data_uri: allow_data_uri).nil?
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require "rexml/formatters/default"
5
+ require_relative "rules"
6
+ require_relative "scanner"
7
+
8
+ module SvgSentinel
9
+ # Rewrites an SVG into a safe one, using the same allowlist and scheme rules
10
+ # the Scanner reports against (both mix in Rules, so they cannot disagree).
11
+ #
12
+ # It removes what it can fix - scripting and foreign elements, event-handler
13
+ # and dangerous-URI attributes, and dangerous CSS - and returns the cleaned
14
+ # SVG as a String. When the input carries a *structural* threat that cannot
15
+ # be rewritten safely (a DTD, a use bomb, oversize or malformed input, a
16
+ # non-<svg> root), it returns nil: the caller must reject, not serve.
17
+ #
18
+ # Removal, not repair, is the rule for CSS: a <style> element or style
19
+ # attribute containing anything dangerous is dropped whole rather than
20
+ # partially rewritten, because partial CSS rewriting is where sanitizers leak.
21
+ class Sanitizer
22
+ include Rules
23
+
24
+ # Raw finding codes that mean "do not attempt to clean this - reject it".
25
+ UNSANITIZABLE = %i[
26
+ empty_input malformed doctype too_large too_deep too_many_nodes
27
+ too_many_attributes no_root not_svg use_bomb use_cycle
28
+ ].freeze
29
+
30
+ def initialize(profile: :strict, allow_external: false, allow_data_uri: false,
31
+ max_bytes: Scanner::DEFAULT_MAX_BYTES, hard_max_bytes: Scanner::DEFAULT_HARD_MAX_BYTES,
32
+ max_depth: Scanner::DEFAULT_MAX_DEPTH, max_nodes: Scanner::DEFAULT_MAX_NODES,
33
+ max_attributes: Scanner::DEFAULT_MAX_ATTRIBUTES)
34
+ @profile = profile
35
+ @allow_external = allow_external
36
+ @allow_data_uri = allow_data_uri
37
+ @scanner_opts = {
38
+ profile: profile, allow_external: allow_external, allow_data_uri: allow_data_uri,
39
+ max_bytes: max_bytes, hard_max_bytes: hard_max_bytes,
40
+ max_depth: max_depth, max_nodes: max_nodes, max_attributes: max_attributes
41
+ }
42
+ end
43
+
44
+ # Returns cleaned SVG as a String, or nil when the input cannot be made
45
+ # safe by removal alone.
46
+ def sanitize(svg)
47
+ return nil unless sanitizable?(svg)
48
+
49
+ status, utf8 = Rules.to_utf8(svg)
50
+ return nil unless status == :ok
51
+
52
+ doc = safe_parse(utf8)
53
+ return nil if doc.nil?
54
+
55
+ root = doc.root
56
+ return nil if root.nil? || !root.name.casecmp?("svg")
57
+
58
+ strip!(root)
59
+ serialize(doc)
60
+ rescue SystemStackError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ # The Scanner is the single source of truth for "is this structurally
67
+ # unfixable". Run it with a neutral policy so the raw codes are visible.
68
+ def sanitizable?(svg)
69
+ codes = Scanner.new(**@scanner_opts).scan(svg).findings.map(&:code)
70
+ (codes & UNSANITIZABLE).empty?
71
+ end
72
+
73
+ def safe_parse(svg)
74
+ REXML::Document.new(svg)
75
+ rescue StandardError, SystemStackError
76
+ nil
77
+ end
78
+
79
+ def strip!(element)
80
+ removals = []
81
+ element.elements.each do |child|
82
+ if remove_element?(child)
83
+ removals << child
84
+ else
85
+ strip!(child)
86
+ end
87
+ end
88
+ removals.each { |child| element.delete_element(child) }
89
+
90
+ strip_attributes!(element)
91
+ end
92
+
93
+ def remove_element?(element)
94
+ name = element.name.downcase
95
+ return true if script_element?(name) || foreign_element?(name)
96
+ # A SMIL animation aimed at an event handler / href / style is a script
97
+ # injection regardless of profile.
98
+ if animation_element?(name) && sensitive_animation_target?(element.attributes["attributeName"].to_s)
99
+ return true
100
+ end
101
+ if strict? && (name == "image" || animation_element?(name) || !allowed_element?(name))
102
+ return true
103
+ end
104
+ return true if name == "style" && dangerous_style?(element_css(element))
105
+
106
+ false
107
+ end
108
+
109
+ def strip_attributes!(element)
110
+ removable = []
111
+ element.attributes.each do |name, value|
112
+ lname = name.downcase
113
+ if event_handler_attr?(lname)
114
+ removable << name
115
+ elsif lname == "style" && dangerous_style?(value)
116
+ removable << name
117
+ elsif uri_attribute?(lname) && dangerous_uri?(value)
118
+ removable << name
119
+ elsif lname != "style" && dangerous_style?(value)
120
+ # A url(...) or @import external/scheme reference hidden in a
121
+ # presentation attribute (fill, filter, mask, ...).
122
+ removable << name
123
+ end
124
+ end
125
+ removable.each { |name| element.delete_attribute(name) }
126
+ end
127
+
128
+ def dangerous_uri?(value)
129
+ scheme = uri_scheme(value)
130
+ return false if scheme.nil?
131
+ return true if dangerous_scheme?(scheme)
132
+
133
+ if scheme == "data"
134
+ return !@allow_data_uri || scriptable_data?(data_uri_media_type(value))
135
+ end
136
+
137
+ external_scheme?(scheme) && !@allow_external
138
+ end
139
+
140
+ def dangerous_style?(css)
141
+ Rules.dangerous_css?(css, allow_external: @allow_external, allow_data_uri: @allow_data_uri)
142
+ end
143
+
144
+ def element_css(element)
145
+ element.texts.join
146
+ end
147
+
148
+ def serialize(doc)
149
+ out = +""
150
+ REXML::Formatters::Default.new.write(doc, out)
151
+ out
152
+ end
153
+
154
+ def strict?
155
+ @profile == :strict
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module SvgSentinel
6
+ # Builds a SARIF 2.1.0 log from scan results so findings drop straight into
7
+ # GitHub code scanning and other SARIF-aware tooling. Severity maps to SARIF
8
+ # level (critical -> error, warning -> warning); the element path becomes a
9
+ # logical location.
10
+ module Sarif
11
+ module_function
12
+
13
+ SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
14
+ INFORMATION_URI = "https://github.com/msuliq/svg_sentinel"
15
+
16
+ LEVELS = {critical: "error", warning: "warning"}.freeze
17
+
18
+ # entries: an Array of [label, Result].
19
+ def document(entries)
20
+ results = []
21
+ rules = {}
22
+
23
+ entries.each do |label, result|
24
+ result.findings.each do |finding|
25
+ rules[finding.code] ||= rule_for(finding)
26
+ results << result_for(label, finding)
27
+ end
28
+ end
29
+
30
+ {
31
+ "version" => "2.1.0",
32
+ "$schema" => SCHEMA,
33
+ "runs" => [
34
+ {
35
+ "tool" => {
36
+ "driver" => {
37
+ "name" => "svg_sentinel",
38
+ "version" => SvgSentinel::VERSION,
39
+ "informationUri" => INFORMATION_URI,
40
+ "rules" => rules.values
41
+ }
42
+ },
43
+ "results" => results
44
+ }
45
+ ]
46
+ }
47
+ end
48
+
49
+ def rule_for(finding)
50
+ {
51
+ "id" => finding.code.to_s,
52
+ "name" => finding.code.to_s,
53
+ "shortDescription" => {"text" => finding.message},
54
+ "properties" => {"category" => finding.category.to_s}
55
+ }
56
+ end
57
+
58
+ def result_for(label, finding)
59
+ location = {
60
+ "physicalLocation" => {
61
+ "artifactLocation" => {"uri" => label}
62
+ }
63
+ }
64
+ if finding.path && !finding.path.empty?
65
+ location["logicalLocations"] = [{"fullyQualifiedName" => finding.path}]
66
+ end
67
+
68
+ {
69
+ "ruleId" => finding.code.to_s,
70
+ "level" => LEVELS.fetch(finding.severity, "note"),
71
+ "message" => {"text" => message_text(finding)},
72
+ "locations" => [location]
73
+ }
74
+ end
75
+
76
+ def message_text(finding)
77
+ finding.detail ? "#{finding.message} (#{finding.detail})" : finding.message
78
+ end
79
+ end
80
+ end