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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 701c3e02059142d834510ee665c65a97dde302d8b6fc021b8bc9c4306a0495be
4
+ data.tar.gz: e29d8642b130f24120d831fd3b9f94501e1125aa1a05479125be4a06d8f0669e
5
+ SHA512:
6
+ metadata.gz: 490617c8ecd277a6948d0148a831559b9f1eb8c872a1877da99ca68b963f259d44e0a05c17722ea08f32031137b58685fbb0a642205ac82817e99e873aba601c
7
+ data.tar.gz: c8b676b761459e0057d2df615054a941adab0897d4216c60e2b4f2e48111398ba432cf536bc55096647592bf6c6316341a23d47207b84eadc5baea0a73f31b0f
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - `SvgSentinel.scan` and `SvgSentinel.safe?`.
12
+ - `SvgSentinel.sanitize` rewrites an SVG into a safe one (allowlist-based),
13
+ returning the cleaned String, or nil when the threat is structural and
14
+ cannot be rewritten (DTD, use bomb, oversize, malformed, non-<svg> root).
15
+ - Detection of scripts, event handlers, javascript:/vbscript:/data: URIs,
16
+ foreign objects, scriptable `data:` media types, nested-`<use>` render
17
+ bombs, and SMIL `<animate>`/`<set>` that target a sensitive attribute
18
+ (an `on*` handler, `href`, or `style`).
19
+ - External-reference detection across URI attributes, presentation-attribute
20
+ `url()` (fill, filter, mask, ...), and CSS `url()` and `@import` (both the
21
+ `url()` and string forms).
22
+ - DOCTYPE / ENTITY refusal to prevent XXE and entity-expansion attacks.
23
+ - Strict profile is an allowlist: elements outside a conservative static-SVG
24
+ set are reported (`:disallowed_element`). A general profile keeps every
25
+ security check but drops the cosmetic ones.
26
+ - Findings carry a `severity` (`:critical` / `:warning`), a `category`
27
+ (`:security` / `:policy`), and the element `path` where they occurred.
28
+ `Result#security_findings` / `Result#policy_findings` filter by category.
29
+ - Rendering context: `scan(svg, context:)` re-rates findings for how the SVG
30
+ will be used. `:inline` (default) and `:standalone` keep full severity;
31
+ `:img` and `:css_background` downgrade scripting/external findings that a
32
+ browser runs inert in secure-static mode. XXE, use bombs, and size limits
33
+ are never downgraded.
34
+ - Hostile-input hardening: input is normalised to valid UTF-8 (invalid bytes
35
+ become a `:malformed` finding, never an exception); a hard byte cap
36
+ (`hard_max_bytes`) and streaming structural limits (`max_depth`, `max_nodes`,
37
+ `max_attributes`) refuse pathological payloads before the tree parser runs.
38
+ - Configuration via `.svg_sentinel.yml` (auto-discovered, or `--config PATH`):
39
+ profile, context, limits, per-code `severity` overrides, and `disabled`
40
+ codes.
41
+ - `svg_sentinel` command-line tool with CI-friendly exit codes (0 safe,
42
+ 1 unsafe, 2 error), STDIN support, `--sanitize`, `--context`, and
43
+ `--format text|json|sarif` (SARIF 2.1.0 for GitHub code scanning).
44
+ - A YAML payload corpus (`spec/fixtures/payloads.yml`) of known XSS/XXE
45
+ vectors and benign controls, plus a differential-oracle harness that
46
+ cross-checks against a real renderer when `SVG_SENTINEL_ORACLE` is set.
47
+
48
+ ### Robustness
49
+
50
+ - A size/structural limit of `0` (or negative) means "disabled" everywhere,
51
+ decided in one place, so config, CLI, and library agree.
52
+ - `scan` and `sanitize` convert a stack overflow from pathologically nested
53
+ input into a finding / nil instead of raising, even with limits disabled.
54
+ - A malformed or non-mapping `.svg_sentinel.yml` raises `SvgSentinel::ConfigError`
55
+ (CLI: a clean message and exit 2) instead of leaking a Psych stack trace.
56
+ - CLI gained `--max-attributes`; config discovery from the working directory
57
+ is documented as a trust boundary for security-gate use.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleyman Musayev
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,245 @@
1
+ # SvgSentinel
2
+
3
+ Lint or sanitize untrusted SVG before you render or embed it.
4
+
5
+ SVG is XML that a browser will execute. It can carry `<script>`, `onload=`
6
+ handlers, `javascript:` URIs, `<foreignObject>` full of HTML, references to
7
+ remote servers, `<!ENTITY>` payloads that expand until your process falls over,
8
+ and nested `<use>` elements that expand into millions of nodes. If you accept
9
+ SVG from users - avatars, brand logos, uploaded icons - you need to know what is
10
+ inside before you serve it.
11
+
12
+ SvgSentinel reads an SVG and tells you what makes it dangerous, and can rewrite
13
+ it into a safe one. It reports; you decide - or you hand it `sanitize` and let
14
+ it clean.
15
+
16
+ ## Why it exists
17
+
18
+ - **Catches the real risks.** Scripts, event handlers, `javascript:` and
19
+ scriptable `data:` URIs (including whitespace-obfuscated ones), foreign
20
+ objects, external references (in attributes **and** CSS `url()`), XXE via
21
+ `DOCTYPE`/`ENTITY`, and `<use>` expansion bombs.
22
+ - **Safe to run on hostile input.** Input is normalised to valid UTF-8 first
23
+ (a stray byte can never crash the scanner), a hard byte cap and a streaming
24
+ structural pass (depth / node / attribute limits) refuse pathological
25
+ payloads before the tree parser runs, DTDs are refused so the parser can't be
26
+ attacked with entity expansion, and every parse failure becomes a finding
27
+ rather than an exception. Nothing is ever fetched over the network.
28
+ - **Context-aware.** The same SVG is dangerous differently depending on how a
29
+ browser runs it. `scan(svg, context: :img)` knows that an SVG loaded through
30
+ `<img>` or CSS runs without scripting, and re-rates findings accordingly.
31
+ - **Cleans, not just complains.** `SvgSentinel.sanitize` rewrites an SVG into a
32
+ safe one using the same allowlist, or returns `nil` when the threat is
33
+ structural and can't be rewritten.
34
+ - **Strict profile is an allowlist.** `:strict` permits a conservative set of
35
+ static SVG elements and flags everything else - so a new dangerous element is
36
+ caught for simply not being on the list. It matches what a brand-mark logo
37
+ (BIMI "SVG Tiny 1.2 Portable/Secure") is allowed to contain. `:general` keeps
38
+ every security check but drops the cosmetic ones.
39
+ - **Dependency-light.** Pure Ruby, parses with REXML. No native extensions.
40
+
41
+ ## Installation
42
+
43
+ ```ruby
44
+ gem "svg_sentinel"
45
+ ```
46
+
47
+ Then `bundle install`, or `gem install svg_sentinel`.
48
+
49
+ ## Usage
50
+
51
+ ```ruby
52
+ require "svg_sentinel"
53
+
54
+ result = SvgSentinel.scan(svg_string)
55
+
56
+ result.safe? # => false (no critical findings means safe)
57
+ result.critical? # => true
58
+ result.criticals # => [#<Finding code=:script_element severity=:critical ...>]
59
+ result.warnings # => [...]
60
+ result.security_findings # => real attack surface (any severity)
61
+ result.policy_findings # => strict-profile house rules (any severity)
62
+ result.to_h # => { safe: false, findings: [...] }
63
+
64
+ # Quick predicate
65
+ SvgSentinel.safe?(svg_string) # => true / false
66
+ ```
67
+
68
+ Each finding carries a `code`, a `severity` (`:critical` or `:warning`), a
69
+ `category` (`:security` or `:policy`), a human `message`, an optional `detail`,
70
+ and the element `path` where it occurred:
71
+
72
+ ```ruby
73
+ SvgSentinel.scan('<svg onload="x()"><script>y()</script></svg>').findings.map(&:to_h)
74
+ # => [
75
+ # { code: :event_handler, severity: :critical, category: :security,
76
+ # message: "Event-handler attribute onload", detail: "x()", path: "svg" },
77
+ # { code: :script_element, severity: :critical, category: :security,
78
+ # message: "Scripting element <script>", detail: nil, path: "svg/script" }
79
+ # ]
80
+ ```
81
+
82
+ `severity` and `category` are orthogonal: use `severity` (or `safe?`) to decide
83
+ whether to render, and `category` to separate genuine security signals from
84
+ strict-profile compliance noise.
85
+
86
+ ## Sanitizing
87
+
88
+ `sanitize` returns cleaned SVG, or `nil` when the input carries a structural
89
+ threat that can't be rewritten (DTD/XXE, use bomb, oversize, malformed, a
90
+ non-`<svg>` root). Fixable problems - scripts, handlers, dangerous URIs,
91
+ dangerous CSS - are removed.
92
+
93
+ ```ruby
94
+ clean = SvgSentinel.sanitize(untrusted_svg)
95
+ if clean
96
+ store(clean) # safe to serve
97
+ else
98
+ reject! # nothing safe could be produced
99
+ end
100
+ ```
101
+
102
+ Sanitizing removes; it does not repair. A `<style>` block or `style` attribute
103
+ that contains anything dangerous is dropped whole rather than partially
104
+ rewritten - partial CSS rewriting is where sanitizers leak.
105
+
106
+ ## Rendering context
107
+
108
+ An SVG in `<img src>` or a CSS `background` runs in the browser's secure-static
109
+ mode: no scripting, no external subresources. Findings that only matter when
110
+ the SVG can script or fetch are downgraded to warnings there, so they don't
111
+ make the SVG "unsafe" on their own. Parser-level and denial-of-service findings
112
+ (XXE, use bombs, oversize) are never downgraded.
113
+
114
+ ```ruby
115
+ svg = '<svg onload="track()">...</svg>'
116
+ SvgSentinel.safe?(svg) # => false (context: :inline, default)
117
+ SvgSentinel.safe?(svg, context: :img) # => true (onload never fires in <img>)
118
+ ```
119
+
120
+ Contexts: `:inline` (default), `:standalone`, `:img`, `:css_background`.
121
+
122
+ ## Command line
123
+
124
+ ```bash
125
+ svg_sentinel logo.svg # exit 0 if safe, 1 if unsafe
126
+ cat logo.svg | svg_sentinel # read from STDIN
127
+ svg_sentinel --context img icons/*.svg
128
+ svg_sentinel --format sarif *.svg # SARIF 2.1.0 for code scanning
129
+ svg_sentinel --sanitize logo.svg # write cleaned SVG to stdout
130
+ ```
131
+
132
+ Exit codes: **0** every input is safe, **1** at least one input is unsafe
133
+ (so CI fails), **2** a usage/config error or an unreadable file. Run
134
+ `svg_sentinel --help` for all options.
135
+
136
+ ## Configuration
137
+
138
+ Settings can live in `.svg_sentinel.yml` (auto-discovered in the working
139
+ directory, or `--config PATH`). Command-line flags override the file.
140
+
141
+ ```yaml
142
+ profile: strict
143
+ context: img
144
+ allow_external: false
145
+ severity:
146
+ external_ref: error # treat as critical in this repo
147
+ raster_image: ignore # we accept raster logos
148
+ disabled:
149
+ - css_import
150
+ ```
151
+
152
+ The same overrides work in code:
153
+
154
+ ```ruby
155
+ SvgSentinel.scan(svg, severity_overrides: {external_ref: :error}, disabled: [:css_import])
156
+ ```
157
+
158
+ **Security note on config discovery.** The CLI auto-discovers `.svg_sentinel.yml`
159
+ from the working directory, and that file can `disable` checks or downgrade
160
+ their `severity`. Like other linters this is convenient, but it means a config
161
+ dropped into a directory can weaken the gate. When you run SvgSentinel as a
162
+ **security gate over untrusted content** (e.g. scanning an upload directory or
163
+ an untrusted checkout), pass an explicit `--config` you control and do not run
164
+ it from a directory whose contents you don't trust.
165
+
166
+ ## What counts as critical vs warning
167
+
168
+ **Critical** (unsafe to render or embed as-is):
169
+
170
+ - `script`, `handler`, `listener` elements
171
+ - `on*` event-handler attributes
172
+ - SMIL `<animate>`/`<set>` targeting a sensitive attribute (`attributeName` of
173
+ an `on*` handler, `href`, or `style`)
174
+ - `javascript:`, `vbscript:`, and similar URIs (whitespace/control-char
175
+ obfuscation is stripped before the check), in attributes and in CSS
176
+ - `data:` URIs (unless `allow_data_uri: true`); scriptable `data:` media types
177
+ (`image/svg+xml`, `text/html`, `*/xml`) are refused **even with**
178
+ `allow_data_uri: true`
179
+ - `foreignObject`, `iframe`, `embed`, `object`, `audio`, `video`, `canvas`
180
+ - `DOCTYPE` or `ENTITY` declarations (XXE, entity expansion)
181
+ - `expression(...)` or script URIs inside CSS
182
+ - nested `<use>` that expands past ~1,000,000 nodes (render DoS)
183
+ - input past the hard byte cap, too deeply nested, too many elements, too many
184
+ attributes on one element, malformed, empty, or not valid UTF-8
185
+
186
+ **Warning** (safe to render, but disallowed by the strict brand-mark profile,
187
+ or otherwise worth a look):
188
+
189
+ - external `http(s)`/`ftp` references (unless `allow_external: true`), in
190
+ URI attributes, presentation-attribute `url()` (fill, filter, mask, ...),
191
+ and CSS `url()` / `@import`
192
+ - raster `<image>` elements
193
+ - animation elements (`animate`, `set`, ...)
194
+ - any element outside the strict allowlist (`:disallowed_element`)
195
+ - `@import` in CSS
196
+ - recursive `<use>` references
197
+ - oversize input (soft threshold, default 32 KB)
198
+
199
+ ## Options
200
+
201
+ ```ruby
202
+ SvgSentinel.scan(svg,
203
+ profile: :strict, # :strict (default) or :general
204
+ context: :inline, # :inline (default), :standalone, :img, :css_background
205
+ allow_external: false, # permit http(s)/ftp references
206
+ allow_data_uri: false, # permit non-scriptable data: URIs
207
+ max_bytes: 32_768, # soft size-warning threshold; nil to disable
208
+ hard_max_bytes: 5_242_880, # hard refuse-to-parse cap; nil to disable
209
+ max_depth: 100, # max element nesting; nil to disable
210
+ max_nodes: 200_000, # max element count; nil to disable
211
+ max_attributes: 512, # max attributes on one element; nil to disable
212
+ severity_overrides: {}, # code => :error / :warning / :ignore
213
+ disabled: []) # codes to drop entirely
214
+ ```
215
+
216
+ `sanitize` accepts the same profile, `allow_external`, `allow_data_uri`, and
217
+ size/structural options.
218
+
219
+ ## Scope and limits
220
+
221
+ SvgSentinel is a static analyzer. No static analyzer can perfectly mirror a
222
+ browser's HTML/SVG parser, so for genuinely hostile input the defence-in-depth
223
+ answers still apply: serve untrusted SVG from a sandboxed origin under CSP, or
224
+ rasterize it server-side. REXML, like any XML parser, has its own parsing-DoS
225
+ history; the hard byte cap, the streaming structural limits, and DTD refusal
226
+ are the mitigations - size them to your workload.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ bundle install
232
+ bundle exec rspec
233
+ ```
234
+
235
+ The vector corpus lives in `spec/fixtures/payloads.yml`. To cross-check against
236
+ a real renderer, point `SVG_SENTINEL_ORACLE` at a command that reads an SVG on
237
+ stdin and prints `executes` or `inert`:
238
+
239
+ ```bash
240
+ SVG_SENTINEL_ORACLE='node spec/support/dompurify_probe.js' bundle exec rspec
241
+ ```
242
+
243
+ ## License
244
+
245
+ MIT. See [LICENSE](LICENSE).
data/exe/svg_sentinel ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "svg_sentinel/cli"
5
+
6
+ exit SvgSentinel::CLI.run(ARGV)
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require_relative "../svg_sentinel"
6
+ require_relative "config"
7
+ require_relative "sarif"
8
+
9
+ module SvgSentinel
10
+ # Command-line entry point. Scans one or more SVG files (or STDIN) and exits
11
+ # non-zero when any input is unsafe, so it drops into CI and pre-commit hooks.
12
+ #
13
+ # svg_sentinel logo.svg # exit 0 if safe, 1 if unsafe
14
+ # cat logo.svg | svg_sentinel # read from STDIN
15
+ # svg_sentinel --format sarif *.svg # SARIF for code scanning
16
+ # svg_sentinel --context img icons/*.svg
17
+ #
18
+ # Settings come from `.svg_sentinel.yml` (auto-discovered in the working
19
+ # directory, or --config PATH); command-line flags override the file.
20
+ #
21
+ # Exit codes:
22
+ # 0 every input scanned and safe
23
+ # 1 at least one input scanned as unsafe (takes precedence, so CI fails)
24
+ # 2 a usage/config error, or an input could not be read
25
+ class CLI
26
+ EXIT_SAFE = 0
27
+ EXIT_UNSAFE = 1
28
+ EXIT_ERROR = 2
29
+
30
+ # Options that Sanitizer accepts (context/overrides are scan-only).
31
+ SANITIZE_KEYS = %i[
32
+ profile allow_external allow_data_uri
33
+ max_bytes hard_max_bytes max_depth max_nodes max_attributes
34
+ ].freeze
35
+
36
+ def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
37
+ new(stdin: stdin, stdout: stdout, stderr: stderr).run(argv)
38
+ end
39
+
40
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
41
+ @stdin = stdin
42
+ @stdout = stdout
43
+ @stderr = stderr
44
+ @done = false
45
+ end
46
+
47
+ def run(argv)
48
+ flags = {}
49
+ meta = {format: :text, config: nil, sanitize: false}
50
+ paths = parse!(argv, flags, meta)
51
+ return EXIT_SAFE if @done # --help / --version already printed
52
+
53
+ scan_opts = load_config(meta[:config]).to_scan_options.merge(flags)
54
+ inputs = collect_inputs(paths)
55
+
56
+ return run_sanitize(inputs, scan_opts) if meta[:sanitize]
57
+
58
+ run_scan(inputs, scan_opts, meta[:format])
59
+ rescue OptionParser::ParseError, ConfigError => e
60
+ @stderr.puts("svg_sentinel: #{e.message}")
61
+ EXIT_ERROR
62
+ end
63
+
64
+ private
65
+
66
+ def run_scan(inputs, scan_opts, format)
67
+ results = []
68
+ had_error = false
69
+
70
+ inputs.each do |label, source|
71
+ if source.nil?
72
+ had_error = true
73
+ next
74
+ end
75
+ results << [label, SvgSentinel.scan(source, **scan_opts)]
76
+ end
77
+
78
+ emit(results, format)
79
+
80
+ return EXIT_UNSAFE if results.any? { |_, r| !r.safe? }
81
+ return EXIT_ERROR if had_error
82
+
83
+ EXIT_SAFE
84
+ end
85
+
86
+ # Writes cleaned SVG to stdout for each input; an input that cannot be made
87
+ # safe is rejected (stderr note) and makes the run exit non-zero.
88
+ def run_sanitize(inputs, scan_opts)
89
+ san_opts = scan_opts.slice(*SANITIZE_KEYS)
90
+ had_error = false
91
+ rejected = false
92
+
93
+ inputs.each do |label, source|
94
+ if source.nil?
95
+ had_error = true
96
+ next
97
+ end
98
+ clean = SvgSentinel.sanitize(source, **san_opts)
99
+ if clean
100
+ @stdout.puts(clean)
101
+ else
102
+ @stderr.puts("svg_sentinel: #{label}: cannot be sanitized safely; rejected")
103
+ rejected = true
104
+ end
105
+ end
106
+
107
+ return EXIT_UNSAFE if rejected
108
+ return EXIT_ERROR if had_error
109
+
110
+ EXIT_SAFE
111
+ end
112
+
113
+ def parse!(argv, flags, meta)
114
+ parser = OptionParser.new do |o|
115
+ o.banner = "Usage: svg_sentinel [options] [file ...]"
116
+ o.separator("")
117
+ o.separator("Reads SVG files (or STDIN when no files are given, or for '-').")
118
+ o.separator("")
119
+ o.on("--profile PROFILE", %w[strict general], "Profile: strict (default) or general") do |v|
120
+ flags[:profile] = v.to_sym
121
+ end
122
+ o.on("--context CONTEXT", %w[inline standalone img css_background],
123
+ "Rendering context (default inline)") { |v| flags[:context] = v.to_sym }
124
+ o.on("--[no-]allow-external", "Permit http(s)/ftp references") { |v| flags[:allow_external] = v }
125
+ o.on("--[no-]allow-data-uri", "Permit non-scriptable data: URIs") { |v| flags[:allow_data_uri] = v }
126
+ o.on("--max-bytes N", Integer, "Soft size-warning threshold (0 disables)") do |v|
127
+ flags[:max_bytes] = v
128
+ end
129
+ o.on("--hard-max-bytes N", Integer, "Hard cap; refuse to parse above it (0 disables)") do |v|
130
+ flags[:hard_max_bytes] = v
131
+ end
132
+ o.on("--max-depth N", Integer, "Max element nesting depth (0 disables)") { |v| flags[:max_depth] = v }
133
+ o.on("--max-nodes N", Integer, "Max element count (0 disables)") { |v| flags[:max_nodes] = v }
134
+ o.on("--max-attributes N", Integer, "Max attributes on one element (0 disables)") do |v|
135
+ flags[:max_attributes] = v
136
+ end
137
+ o.on("--format FORMAT", %w[text json sarif], "Output format (default text)") do |v|
138
+ meta[:format] = v.to_sym
139
+ end
140
+ o.on("--json", "Alias for --format json") { meta[:format] = :json }
141
+ o.on("--config PATH", "Load settings from PATH (default .svg_sentinel.yml)") do |v|
142
+ meta[:config] = v
143
+ end
144
+ o.on("--sanitize", "Write cleaned SVG to stdout instead of scanning") { meta[:sanitize] = true }
145
+ o.on("-h", "--help", "Show this help") do
146
+ @stdout.puts(o)
147
+ @done = true
148
+ end
149
+ o.on("-v", "--version", "Show version") do
150
+ @stdout.puts(SvgSentinel::VERSION)
151
+ @done = true
152
+ end
153
+ end
154
+ parser.parse(argv)
155
+ end
156
+
157
+ def load_config(path)
158
+ return Config.load_file(path) if path
159
+
160
+ Config.discover(Dir.pwd)
161
+ rescue SystemCallError => e
162
+ raise ConfigError, "config: #{e.message}"
163
+ end
164
+
165
+ def collect_inputs(paths)
166
+ return [["(stdin)", read_stdin]] if paths.empty?
167
+
168
+ paths.map do |path|
169
+ (path == "-") ? ["(stdin)", read_stdin] : [path, read_file(path)]
170
+ end
171
+ end
172
+
173
+ def read_stdin
174
+ @stdin.binmode if @stdin.respond_to?(:binmode)
175
+ @stdin.read
176
+ end
177
+
178
+ def read_file(path)
179
+ File.binread(path)
180
+ rescue SystemCallError => e
181
+ @stderr.puts("svg_sentinel: #{path}: #{e.message}")
182
+ nil
183
+ end
184
+
185
+ def emit(results, format)
186
+ case format
187
+ when :json then emit_json(results)
188
+ when :sarif then emit_sarif(results)
189
+ else emit_text(results)
190
+ end
191
+ end
192
+
193
+ def emit_text(results)
194
+ results.each do |label, result|
195
+ status = result.safe? ? "SAFE" : "UNSAFE"
196
+ @stdout.puts("#{label}: #{status} (#{result.criticals.size} critical, #{result.warnings.size} warning)")
197
+ result.findings.each do |f|
198
+ location = (f.path && !f.path.empty?) ? " (#{f.path})" : ""
199
+ detail = f.detail ? " - #{f.detail}" : ""
200
+ @stdout.puts(" [#{f.severity}/#{f.category}] #{f.code}: #{f.message}#{detail}#{location}")
201
+ end
202
+ end
203
+ end
204
+
205
+ def emit_json(results)
206
+ payload = results.map { |label, result| {file: label}.merge(result.to_h) }
207
+ @stdout.puts(JSON.pretty_generate(payload))
208
+ end
209
+
210
+ def emit_sarif(results)
211
+ @stdout.puts(JSON.pretty_generate(Sarif.document(results)))
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "errors"
5
+
6
+ module SvgSentinel
7
+ # Loads scan settings from a `.svg_sentinel.yml` file (or a plain Hash) so a
8
+ # team can pin a profile, a rendering context, size limits, and - most
9
+ # usefully - per-finding severity, without forking the source.
10
+ #
11
+ # # .svg_sentinel.yml
12
+ # profile: strict
13
+ # context: img
14
+ # allow_external: false
15
+ # severity:
16
+ # external_ref: error # treat as critical in this repo
17
+ # raster_image: ignore # we accept raster logos
18
+ # disabled:
19
+ # - css_import
20
+ #
21
+ # `to_scan_options` returns a kwargs Hash ready to splat into
22
+ # SvgSentinel.scan. Only keys present in the config are returned, so caller
23
+ # (e.g. CLI) flags can be merged on top and win.
24
+ class Config
25
+ OPTION_KEYS = %i[
26
+ profile context allow_external allow_data_uri
27
+ max_bytes hard_max_bytes max_depth max_nodes max_attributes
28
+ ].freeze
29
+
30
+ SYMBOL_KEYS = %i[profile context].freeze
31
+
32
+ FILENAME = ".svg_sentinel.yml"
33
+
34
+ def self.load_file(path)
35
+ data = YAML.safe_load_file(path) || {}
36
+ raise ConfigError, "#{path}: expected a mapping" unless data.is_a?(Hash)
37
+
38
+ new(data)
39
+ rescue Psych::SyntaxError => e
40
+ raise ConfigError, "#{path}: invalid YAML (#{e.message})"
41
+ end
42
+
43
+ def self.discover(dir = Dir.pwd)
44
+ path = File.join(dir, FILENAME)
45
+ File.file?(path) ? load_file(path) : new({})
46
+ end
47
+
48
+ def initialize(data = {})
49
+ @data = symbolize_keys(data)
50
+ end
51
+
52
+ def to_scan_options
53
+ opts = {}
54
+ OPTION_KEYS.each do |key|
55
+ next unless @data.key?(key)
56
+
57
+ value = @data[key]
58
+ opts[key] = (SYMBOL_KEYS.include?(key) && value) ? value.to_sym : value
59
+ end
60
+ opts[:severity_overrides] = symbolize_keys(@data[:severity]) if @data.key?(:severity)
61
+ opts[:disabled] = Array(@data[:disabled]).map(&:to_sym) if @data.key?(:disabled)
62
+ opts
63
+ end
64
+
65
+ private
66
+
67
+ def symbolize_keys(hash)
68
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgSentinel
4
+ # Base class for all errors this library raises deliberately.
5
+ class Error < StandardError; end
6
+
7
+ # Raised for a bad configuration or option: an unparseable config file, a
8
+ # config that is not a mapping, or an unknown severity override. Distinct
9
+ # from a programming ArgumentError so the CLI can report config problems
10
+ # cleanly (exit 2) without swallowing genuine internal errors.
11
+ class ConfigError < Error; end
12
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgSentinel
4
+ # One thing the scanner noticed.
5
+ #
6
+ # Two orthogonal axes describe a finding:
7
+ #
8
+ # * severity - :critical (unsafe to render or embed as-is) or :warning
9
+ # (worth a second look, but not on its own a reason to reject).
10
+ # * category - :security (an actual attack surface: scripts, XXE, script
11
+ # URIs, external references) or :policy (a brand-mark / strict-profile
12
+ # house rule such as animation, raster images, or size).
13
+ #
14
+ # `safe?` keys off severity; the category lets a caller separate genuine
15
+ # security signals from profile-compliance noise.
16
+ #
17
+ # `path` is the slash-joined element path where the finding occurred
18
+ # (e.g. "svg/g/script"), or nil for document-level findings.
19
+ Finding = Struct.new(:code, :severity, :category, :message, :detail, :path, keyword_init: true) do
20
+ def critical?
21
+ severity == :critical
22
+ end
23
+
24
+ def warning?
25
+ severity == :warning
26
+ end
27
+
28
+ def security?
29
+ category == :security
30
+ end
31
+
32
+ def policy?
33
+ category == :policy
34
+ end
35
+
36
+ def to_h
37
+ {code: code, severity: severity, category: category, message: message, detail: detail, path: path}
38
+ end
39
+ end
40
+ end