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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +245 -0
- data/exe/svg_sentinel +6 -0
- data/lib/svg_sentinel/cli.rb +214 -0
- data/lib/svg_sentinel/config.rb +71 -0
- data/lib/svg_sentinel/errors.rb +12 -0
- data/lib/svg_sentinel/finding.rb +40 -0
- data/lib/svg_sentinel/policy.rb +86 -0
- data/lib/svg_sentinel/result.rb +49 -0
- data/lib/svg_sentinel/rules.rb +192 -0
- data/lib/svg_sentinel/sanitizer.rb +158 -0
- data/lib/svg_sentinel/sarif.rb +80 -0
- data/lib/svg_sentinel/scanner.rb +400 -0
- data/lib/svg_sentinel/structure_listener.rb +59 -0
- data/lib/svg_sentinel/version.rb +5 -0
- data/lib/svg_sentinel.rb +50 -0
- metadata +90 -0
|
@@ -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
|