AIで作ったSVG素材を販売前に検査する|grepだけでは6件中5件の不備を見逃した

grep -q "<script"で 7 ファイルを検査すると、PASS が 6 件、FAIL が 1 件でした。PASS の 6 件のうち、5 件は外部へ通信したり、スクリプトを走らせたりする仕込みを含んでいます。
SVG は、アイコンやロゴ素材として売りやすい形式です。AI に頼むと、数十個のアイコンが数分で出てきます。ただし SVG は画像であると同時に、XML のテキストです。<script>も外部画像への参照も、中に書けてしまいます。
2026 年 10 月 6 日、販売前の検査をどこまで簡単にできるかを試しました。先に断っておくと、今回の 7 ファイルは、混ざりうるパターンを私が手書きした見本です。AI の出力そのものではありません。実際の AI 出力に、こうした仕込みがどのくらいの頻度で入るのか。その数字は測っていません。
前回完了時と今回完了後
10 月 3 日の回では、HTML テンプレートのsrc・href・actionの外部参照と、script の有無を検査しました。そのとき、CSS の中から出ていく通信は残課題のままでした。SVG は画像素材として売れる形式なのに、<style>の中に同じ種類の参照を書けてしまいます。今回は、その SVG を販売前に検査します。
| 時点 | 検査できること | 見えていないこと |
|---|---|---|
| 前回完了時(10/3 HTMLテンプレート回まで) | HTMLのsrc・href・actionの外部参照とscriptの有無。ZIPのファイル名(10/5) | CSS内・JavaScript内の通信。画像素材の中身 |
| 今回完了後 | 新規: SVGのイベント属性、xlink:href、<style>内のurl()と@importの検査、および除去 | ブラウザで単体で開いたときの実挙動。<!DOCTYPE>外部実体やdata:URLなど試していない記法 |
飛ばせる章: 結果だけ見たい人は「XMLとして読む検査に作り直す」の 2 回目の出力へ。SVG に script を書けると知っている人は、「なぜ画像なのに検査が要るのか」を飛ばして「grepだけの検査を試す」へ進めます。除去手順だけが目的なら「取り除いて見た目を比べる」へ。
なぜ画像なのに検査が要るのか
MDN によると、<img>などの画像として使う場合、一部のブラウザでは JavaScript が無効になり、外部の画像やスタイルシートも読み込まれないことがあります。画像として表示する限り、多くの仕込みは働かない可能性が高いです。ただし、どこまで制限されるかはブラウザ次第です。
ところが購入者は、SVG を画像としてだけ使うとは限りません。HTML へ直接貼る人もいますし、SVG ファイルだけをブラウザへ読み込む人もいます。MDN も、この制限は画像として使う場合に限られ、SVG を単体で開く場合や、<iframe>・<object>・<embed>で文書として埋め込む場合には当てはまらないと書いています。この使い方では、<script>が実行されうる文脈になります。売り手が「画像素材だから安全」と考えても、買い手の使い方までは決められません。
もう 1 つの理由は、AI の出力を人間が目で確かめにくいことです。丸や線の見た目が正しくても、見えない要素は画面に出ません。見た目の確認では、仕込みを見つけられません。
[AIがSVG素材を生成]
│
▼
[見た目を確認]── 見えない要素は分からない
│
▼
[検査] ── script / on属性 / 外部参照 / CSSのurl・@import
│
┌────┴────┐
PASS FAIL
│ │
▼ ▼
[販売候補] [取り除く → 再検査]
用語
| 用語 | この回での意味 |
|---|---|
| SVG | 図形を XML のテキストで書く画像形式です。拡大しても粗くなりません |
| XML | タグで文書を構造化する書式です。Python 標準のxml.etree.ElementTreeで読めます |
| 名前空間 | XML のタグが属する語彙を示す印です。SVG はhttp://www.w3.org/2000/svgを使います |
| イベント属性 | onloadのように、onで始まるSVG要素の属性です。値のスクリプトが実行されます |
href / xlink:href | 要素が参照する先を示す属性です。xlink:hrefは古い書き方で、現在はhrefが使われます |
@import | CSS から別のスタイルシートを読み込む記法です。<style>の中に書けます |
| grep | ファイルの中から文字列を探すコマンドです |
| 正規表現 | 文字列のパターンを書く記法です。ここでは Python のreで使います |
foreignObject | SVG の中に HTML など別の語彙の要素を埋め込む要素です。今回は見本に入れていませんが、検査対象にしました |
javascript:リンク | リンク先の代わりにスクリプトを書く記法です。クリックで実行されます |
<!DOCTYPE>の外部実体 | XML の文書型宣言で外部のファイルを取り込む仕組みです。今回は試していません |
data:URL | データそのものを URL の中に埋め込む書き方です |
| 偽陰性 | 不備があるのに PASS と判定することです。今回の grep 検査が起こしました |
| ImageMagick | 画像を変換、比較するコマンドです。ここでは SVG を PNG にして比べます |
7つの見本を用意する
実験用のフォルダを作り、環境を確認しました。
mkdir hibanas-svg-lab
cd hibanas-svg-lab
python3 --version
magick -version | head -1
Python 3.13.5
Version: ImageMagick 7.1.1-43 Q16 aarch64 22550 https://imagemagick.org
見本は、問題のない丸が 1 つと、そこへ 1 種類ずつ仕込みを足した 6 つです。make_svgs.pyの全文です。
from pathlib import Path
HEAD = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 64 64"%s>\n'
BODY = ' <circle cx="32" cy="32" r="24" fill="#2563eb"/>\n'
files = {
"a-clean.svg": HEAD % "" + BODY + "</svg>\n",
"b-script.svg": HEAD % "" + BODY + " <script>alert(1)</script>\n</svg>\n",
"c-onload.svg": HEAD % ' ONLOAD="alert(1)"' + BODY + "</svg>\n",
"d-remote-image.svg": HEAD % "" + BODY + ' <image xlink:href="https://example.com/track.png" width="1" height="1"/>\n</svg>\n',
"e-js-link.svg": HEAD % "" + BODY + ' <a xlink:href="javascript:alert(1)"><rect width="64" height="64" fill="none"/></a>\n</svg>\n',
"f-style-url.svg": HEAD % "" + BODY + " <style>.x{fill:url(https://example.com/p.svg#g)}</style>\n</svg>\n",
"g-import.svg": HEAD % "" + BODY + ' <style>@import "https://example.com/p.css";</style>\n</svg>\n',
}
for name, text in files.items():
Path(name).write_text(text, encoding="utf-8")
print("wrote", name)
python3 make_svgs.py
wrote a-clean.svg
wrote b-script.svg
wrote c-onload.svg
wrote d-remote-image.svg
wrote e-js-link.svg
wrote f-style-url.svg
wrote g-import.svg
c のONLOADだけ大文字にしたのは、大文字小文字の違いを検査が拾えるかも見たかったからです。6 つの仕込みは次のとおりです。
- b:
<script>要素を置いたもの - c: 大文字の
ONLOADイベント属性を付けたもの - d: 1 ピクセルの外部画像です。SVG を単体で開くなど、外部画像が読み込まれる使い方では、開いた人の IP アドレスなどが相手のサーバーに届きえます
- e:
<a>のxlink:hrefにjavascript:リンクを入れ、クリックするとスクリプトが走る形にしたもの(中身は透明のrect) - f:
<style>の中の CSS でurl()を使い、外部の SVG を塗りとして参照させたもの - g:
<style>の中の@importで外部のスタイルシートを読ませるもの
grepだけの検査を試す
最初に書いたのは、ほとんど検査と呼べないものです。<scriptが入っているかを grep するだけのfirst_check.shです。
#!/bin/sh
for f in "$@"; do
if grep -q "<script" "$f"; then echo "$f: FAIL"; else echo "$f: PASS"; fi
done
sh first_check.sh *.svg
a-clean.svg: PASS
b-script.svg: FAIL
c-onload.svg: PASS
d-remote-image.svg: PASS
e-js-link.svg: PASS
f-style-url.svg: PASS
g-import.svg: PASS
不備のある 6 件のうち、止まったのは b だけです。残る 5 件が PASS になりました。<scriptという文字列は、script 要素の目印にはなります。ただ、イベント属性も外部参照も、別の書き方です。
XMLとして読む検査に作り直す
文字列を探す方法をやめて、SVG を XML として読み、要素と属性を 1 つずつ見る方法にしました。check_svg.pyの最初の版です。
import re
import sys
import xml.etree.ElementTree as ET
URL_IN_CSS = re.compile(r"url\(\s*['\"]?\s*([^)'\"\s]+)", re.I)
def local(tag):
return tag.rsplit("}", 1)[-1].lower()
def check(path):
problems = []
root = ET.parse(path).getroot()
for el in root.iter():
tag = local(el.tag)
if tag in ("script", "foreignobject"):
problems.append(f"禁止要素 <{tag}>")
for key, value in el.attrib.items():
k = local(key)
if k.startswith("on"):
problems.append(f"イベント属性 {k}")
if k == "href" and not value.strip().startswith("#"):
problems.append(f"外部または実行系のhref {value[:40]}")
css = [el.attrib.get("style", "")]
if tag == "style":
css.append(el.text or "")
for chunk in css:
for url in URL_IN_CSS.findall(chunk):
if not url.startswith("#"):
problems.append(f"CSSの外部url() {url[:40]}")
return problems
bad = 0
for path in sys.argv[1:]:
problems = check(path)
print(f"{path}: {'FAIL' if problems else 'PASS'} ({len(problems)}件)")
for p in problems:
print(" -", p)
bad += bool(problems)
sys.exit(1 if bad else 0)
local()は、{http://www.w3.org/2000/svg}scriptのような名前空間付きのタグから、末尾のscriptだけを小文字で取り出します。これでONLOADのような大文字もonで始まる属性として拾えます。hrefは、#で始まる同じファイル内の参照だけを許可します。
python3 check_svg.py *.svg
echo "exit=$?"
a-clean.svg: PASS (0件)
b-script.svg: FAIL (1件)
- 禁止要素 <script>
c-onload.svg: FAIL (1件)
- イベント属性 onload
d-remote-image.svg: FAIL (1件)
- 外部または実行系のhref https://example.com/track.png
e-js-link.svg: FAIL (1件)
- 外部または実行系のhref javascript:alert(1)
f-style-url.svg: FAIL (1件)
- CSSの外部url() https://example.com/p.svg#g
g-import.svg: PASS (0件)
exit=1
6 件中 5 件を止められました。それでも g の@importは PASS のままです。url(を探す正規表現は、@import "https://..."のようにurl(を使わない書き方に当たりません。grep の穴を直したつもりで、同じ種類の穴を別の場所に残していました。
@importを探す正規表現を足しました。変更は、定義 1 行と、検査の末尾 2 行です。
IMPORT_IN_CSS = re.compile(r"@import\s+(?:url\(\s*)?['\"]?\s*([^)'\";\s]+)", re.I)
for url in IMPORT_IN_CSS.findall(chunk):
problems.append(f"CSSの@import {url[:40]}")
2 つ目は、for url in URL_IN_CSS.findall(chunk):のブロックの直後に置きます。@importは同じファイル内の参照にできないため、見つけたらすべて問題にします。
python3 check_svg.py *.svg
echo "exit=$?"
a-clean.svg: PASS (0件)
b-script.svg: FAIL (1件)
- 禁止要素 <script>
c-onload.svg: FAIL (1件)
- イベント属性 onload
d-remote-image.svg: FAIL (1件)
- 外部または実行系のhref https://example.com/track.png
e-js-link.svg: FAIL (1件)
- 外部または実行系のhref javascript:alert(1)
f-style-url.svg: FAIL (1件)
- CSSの外部url() https://example.com/p.svg#g
g-import.svg: FAIL (1件)
- CSSの@import https://example.com/p.css
exit=1
問題のない a だけが PASS で、不備のある 6 件はすべて FAIL になりました。
取り除いて見た目を比べる
検査で止めたあと、作り直しを AI に頼む手もあります。ただ、毎回頼むと時間がかかります。そこで、問題の要素と属性を機械的に取り除くclean_svg.pyも用意しました。最初の版です。
import re
import sys
import xml.etree.ElementTree as ET
URL_IN_CSS = re.compile(r"url\(\s*['\"]?\s*([^)'\"\s]+)", re.I)
IMPORT_IN_CSS = re.compile(r"@import", re.I)
def local(tag):
return tag.rsplit("}", 1)[-1].lower()
def unsafe_css(text):
text = text or ""
return bool(IMPORT_IN_CSS.search(text)) or any(
not u.startswith("#") for u in URL_IN_CSS.findall(text)
)
def clean(src, dst):
tree = ET.parse(src)
root = tree.getroot()
removed = 0
for parent in list(root.iter()):
for child in list(parent):
tag = local(child.tag)
href = next((v for k, v in child.attrib.items() if local(k) == "href"), "")
if (
tag in ("script", "foreignobject")
or (tag == "style" and unsafe_css(child.text))
or (href and not href.strip().startswith("#"))
):
parent.remove(child)
removed += 1
for el in root.iter():
for key in list(el.attrib):
if local(key).startswith("on"):
del el.attrib[key]
removed += 1
tree.write(dst, encoding="utf-8", xml_declaration=False)
return removed
src, dst = sys.argv[1], sys.argv[2]
print(f"{src}: {clean(src, dst)}件を取り除きました")
mkdir out
for f in b-script c-onload d-remote-image e-js-link f-style-url g-import; do python3 clean_svg.py $f.svg out/$f.svg; done
head -c 200 out/d-remote-image.svg
echo
headの直後のechoは、書き出した SVG が改行なしで終わるために入れています。入れないと、次の行の表示が</svg>につながってしまいます。
b-script.svg: 1件を取り除きました
c-onload.svg: 1件を取り除きました
d-remote-image.svg: 1件を取り除きました
e-js-link.svg: 1件を取り除きました
f-style-url.svg: 1件を取り除きました
g-import.svg: 1件を取り除きました
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ns0:circle cx="32" cy="32" r="24" fill="#2563eb" />
</ns0:svg>
検査は PASS しました。ただ、出力のns0:が気になります。ElementTreeは、名前空間の接頭辞を登録していないと、ns0のような仮の名前を勝手に付けて書き出します。XML としては正しく、ImageMagick でも同じ見た目で描画されました。それでも、販売するファイルの中身が元と違う書き方になるのは避けたいです。
接頭辞を、元と同じ空の名前とxlinkに登録します。importの直後に、次の 2 行を足しました。
ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
足した 2 行が、URL_IN_CSS = ...の直前に入ります。出力し直して、問題のない a と、除去後の 6 件を PNG に変換して、画素の差を数えました。
rm -rf out && mkdir out
for f in b-script c-onload d-remote-image e-js-link f-style-url g-import; do python3 clean_svg.py $f.svg out/$f.svg; done
head -c 200 out/d-remote-image.svg
echo
magick -background none a-clean.svg out/a.png
for f in b-script c-onload d-remote-image e-js-link f-style-url g-import; do magick -background none out/$f.svg out/$f.png; echo -n "$f 差分ピクセル="; magick compare -metric AE out/a.png out/$f.png null: 2>&1; echo; done
python3 check_svg.py out/*.svg
echo "exit=$?"
b-script.svg: 1件を取り除きました
c-onload.svg: 1件を取り除きました
d-remote-image.svg: 1件を取り除きました
e-js-link.svg: 1件を取り除きました
f-style-url.svg: 1件を取り除きました
g-import.svg: 1件を取り除きました
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="24" fill="#2563eb" />
</svg>
b-script 差分ピクセル=0
c-onload 差分ピクセル=0
d-remote-image 差分ピクセル=0
e-js-link 差分ピクセル=0
f-style-url 差分ピクセル=0
g-import 差分ピクセル=0
out/b-script.svg: PASS (0件)
out/c-onload.svg: PASS (0件)
out/d-remote-image.svg: PASS (0件)
out/e-js-link.svg: PASS (0件)
out/f-style-url.svg: PASS (0件)
out/g-import.svg: PASS (0件)
exit=0
ns0:は消え、接頭辞は元と同じになりましたが、閉じタグの字下げやxmlns:xlinkの宣言など、細かい書き方は元と違います。元の d と比べた違いは 4 点です。
</svg>の前に 2 文字の字下げが残りますxmlns:xlinkの宣言が消えています。ElementTreeは実際に使われている名前空間しか書かず、唯一のxlink属性は<image>要素ごと消えたためです/>の前に半角スペースが入ります- 末尾の改行がありません
字下げが残るのは、ElementTreeが各要素の直後の空白を、その要素のtailとして持つためです。<image>を消すと、そのtailにあたる</svg>直前の改行も一緒に消え、circleのtail(改行と字下げ 2 文字)が</svg>の直前にそのまま残ります。除去後の 6 件は、問題のない a と 1 ピクセルも違わず、再検査も 6 件すべて PASS だったので、今回はこの書き方の違いを受け入れました。整えたい場合は、書き出し前にET.indent()で整形し直す手がありますが、今回の実験では試していません。
直らなかったこと
この実験で言えるのは、手書きした 7 つの見本について、検査と除去が期待どおり動いたところまでです。
- 見本は私が書いたものです。実際の AI が出す SVG に、どんな仕込みがどのくらい入るかは測っていません。
- 見た目が同じになったのは、見本の仕込みが元々見えない要素だったからです。本物のロゴに正当な外部リンクの
<a href="https://...">があれば、除去でそのリンクも消えます。除去は「安全側に倒す」処理で、「意図を保つ」処理ではありません。 - 除去は要素ごと消します。e のように
<a>の中に図形があった場合、子の図形も一緒に消えます。この見本は子が透明のrectだったため、見た目が変わりませんでした。 <!DOCTYPE>の外部実体、<animate>でhrefを書き換える記法、data:URL の中のスクリプトは試していません。- ブラウザで SVG を単体で開いたときの実際の挙動は、この環境では確認していません。
AI にアイコン素材を作らせて売る場合、私は検査を通すだけでなく、販売ページに「画像として使う前提」と書いておくのが現実的だと思っています。単体で開かれる使い方まで保証するのは、売り手には重すぎます。
よくあるエラー
| 症状 | 原因 | 対処 |
|---|---|---|
grepで<scriptが見つからないのにPASSが危ない | イベント属性や外部参照はscript要素ではありません | XMLとして読み、要素と属性を個別に検査します |
ONLOADのような大文字を見逃す | 属性名の大文字小文字を区別して比べています | local()で小文字にそろえてからonで始まるかを見ます |
@importだけPASSする | url(を探す正規表現に当たりません | @import用の正規表現を別に持ちます |
出力のタグがns0:svgになる | ElementTreeが名前空間の接頭辞を自動で付けます | register_namespaceで元の接頭辞を登録します |
ParseErrorで検査が止まる | SVGがXMLとして壊れています | 検査の失敗として扱い、AIに作り直させます |
| 除去後にロゴの外部リンクが消えた | hrefが#以外の要素をすべて除去しています | 許可するドメインを決めて、検査側で例外にします |
除去後に</svg>の前に余分な字下げが残る | 消した要素のtail(直後の改行)も一緒に消え、前の要素のtailが残ります | 見た目には影響しません。整えたい場合は書き出し前に整形します |
一次情報
| 資料 | 確認した内容 |
|---|---|
| SVG as an image(MDN) | 画像として使うSVGでは、一部のブラウザでJavaScriptの無効化や外部リソースの読み込み制限がかかりうること。単体で開く場合や<iframe>・<object>・<embed>では、その制限がかからないこと |
<script>(MDN SVG) | SVGにscript要素を置けて、hrefで外部スクリプトを指せること |
href(MDN SVG) | hrefを使える要素の一覧と、xlink:hrefが古い書き方であること |


