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
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,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
|