AIで作ったCSV商品を販売前に検査する|数式扱いになる4セルを止めた

AIで作ったCSV商品を販売前に検査する|数式扱いになる4セルを止めた

電話番号の+81、返金額の-500、担当者名の@owner。CSV では普通の文字列に見えます。ところが、表計算ソフトへ渡す商品では、先頭記号が数式の入口になります。

2026 年 9 月 30 日、AI で下書きした販売管理テンプレートを検査しました。対象は 4 商品、合計 25 セルです。最初の検査では 4 セルが不合格でした。文字列として扱う印を付け、全項目を引用符で囲んだ後は 0 件です。

今回の失敗は、露骨なHYPERLINKだけではありません。電話番号や返金額も同じ検査へ引っかかりました。危険な式を探すだけでは、購入者が入力した実データを守れません。

前回完了時から変えたこと

前回の実験では、Markdown 商品のローカルリンクを調べました。リンク先ファイルを足すと、欠落は 2 件から 0 件になりました。今回は、その商品へ同梱する CSV の中身を見ます。

時点確認できること残る問題
前回完了時商品フォルダ内のリンク先が存在しますCSVを開いた時のセル解釈は見ません
今回完了後数式として始まる候補を販売前に止めます表計算ソフトごとの再保存動作は別に確認します

読み飛ばせる章: CSVインジェクションを知っている場合は、「検査用CSVに4種類の失敗を入れる」から進めます。

Why: CSVは文字列のまま開かれるとは限らない

CSV は値をカンマで区切る単純な形式です。ただし、開いた後の扱いはアプリに委ねられます。OWASP は、Excel や LibreOffice Calc が=で始まるセルを式として解釈すると説明しています。

検査対象は=だけではありません。OWASP と MITRE CWE-1236 は、+、-、@も注意すべき先頭文字に挙げています。タブ、改行、全角の=などを扱う環境もあります。

この問題は、AI が悪意ある式を作った時だけ起きるものではありません。

  • 国際電話番号が+81で始まります
  • 返金額を-500と書きます
  • 担当者名を@ownerと書きます
  • 操作リンクを式で作ろうとして=HYPERLINK(...)を出します

最初の 3 件は、入力者に悪意がなくても生まれます。そこで、危険な式の単語ではなく、各セルの先頭を調べます。

検査の置き場所

生成直後だけを調べると、人が後から足した電話番号を見落とします。販売用ファイルを確定する直前に、完成した CSV を入力へ渡します。

[AIでCSV商品を下書き]
          │
          ▼
[人が項目と値を調整]
          │
          ▼
[販売候補CSVを検査]
     ┌────┴────┐
     ▼         ▼
 risky=0     risky>0
     │         └── 値の用途を確認して修正
     ▼
[対象アプリで表示確認]
          │
          ▼
[配布用ファイルへ固定]

検査は販売許可ではありません。数式の入口が 0 件だと確認するだけです。商品内容、文字化け、列幅、再保存後の挙動は、人が対象アプリで確認します。

用語をそろえる

用語この回での意味
CSV商品表計算ソフトで使うテンプレートや台帳をCSVで配る商品です
CSVインジェクションセルの値が式として解釈される状態です
危険な先頭文字=、+、-、@、制御文字、対応環境の全角記号です
エスケープ値を文字列として扱わせるため、先頭や引用符を変換する処理です
CSV dialect区切り文字や引用符など、アプリごとに異なるCSVの規則です
終了コード合格を0、不合格を0以外で伝える値です

検査用CSVに4種類の失敗を入れる

実験場所は次のディレクトリです。Python 3.13.5 で実行しました。

mkdir -p hibanas-csv-formula-lab
cd hibanas-csv-formula-lab
python3 --version
Python 3.13.5

offers-unsafe.csvには 4 商品を入れました。

id,product_name,contact,price_note,action
offer-01,請求書テンプレート,+81-90-1234-5678,1200,PDFを開く
offer-02,売上集計シート,販売者@example.com,-500,差額を確認
offer-03,相談記録テンプレート,@owner,800,内容を確認
offer-04,見積書セット,窓口@example.com,1500,"=HYPERLINK(""https://example.com"",""確認"")"

危険な値は 4 種類です。+81は電話番号、-500は返金額、@ownerは担当名です。最後のセルだけが明示的な式です。

ここで式らしい文字列だけを禁止すると、電話番号を見落とします。値の意味を推測せず、先頭文字だけを同じ規則で調べます。

How: Python標準のcsvモジュールで全セルを調べる

検査コードをcheck_csv_formula.pyとして保存しました。外部パッケージは使いません。

from __future__ import annotations

import argparse
import csv
from pathlib import Path

DANGEROUS_PREFIXES = (
    "=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@"
)


def is_risky(value: str) -> bool:
    return value.startswith(DANGEROUS_PREFIXES)


def inspect(rows: list[list[str]]) -> list[tuple[int, int, str]]:
    findings: list[tuple[int, int, str]] = []
    for row_number, row in enumerate(rows, start=1):
        for column_number, value in enumerate(row, start=1):
            if is_risky(value):
                findings.append((row_number, column_number, value))
    return findings


def write_safe(rows: list[list[str]], path: Path) -> None:
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.writer(handle, quoting=csv.QUOTE_ALL)
        for row in rows:
            writer.writerow([f"'{value}" if is_risky(value) else value for value in row])


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("csv_path", type=Path)
    parser.add_argument("--write-safe", type=Path)
    args = parser.parse_args()

    with args.csv_path.open(encoding="utf-8", newline="") as handle:
        rows = list(csv.reader(handle))

    findings = inspect(rows)
    for row_number, column_number, value in findings:
        print(f"RISK row={row_number} column={column_number} value={value!r}")

    if args.write_safe:
        write_safe(rows, args.write_safe)
        print(f"WROTE path={args.write_safe} escaped={len(findings)}")
        return 0

    print(f"result={'FAIL' if findings else 'PASS'} risky_cells={len(findings)}")
    return 1 if findings else 0


if __name__ == "__main__":
    raise SystemExit(main())

csv.readerを使う理由は、カンマや二重引用符を自前で分割しないためです。Python の公式文書も、readerが CSV の各行を文字列の一覧として返すと説明しています。

修正版を書き出す時は、危険なセルの先頭へ単一引用符を付けます。さらにcsv.QUOTE_ALLで全セルを二重引用符で囲みます。OWASP が示す対策の 1 つに合わせた形です。

ただし、これは万能な変換ではありません。OWASP は、Excel で保存して再度開くと、引用符やエスケープが外れて式が有効になる場合があると注意しています。今回は販売候補を作る段階の検査として使い、対象アプリでの再保存テストを残します。

初稿を検査して4セルを止める

最初の CSV を検査し、終了コードも表示しました。

python3 check_csv_formula.py offers-unsafe.csv
printf 'exit=%s\n' "$?"
RISK row=2 column=3 value='+81-90-1234-5678'
RISK row=3 column=4 value='-500'
RISK row=4 column=3 value='@owner'
RISK row=5 column=5 value='=HYPERLINK("https://example.com","確認")'
result=FAIL risky_cells=4
exit=1

検査は 4 セルを別々の行と列で示しました。明示的な式は 1 件ですが、文字列の用途で使っていた値も 3 件あります。

ここで=だけを検査対象にすると、結果は 1 件に減ります。数値が良くなるだけで、3 件の入口は残ります。検査条件を弱めず、値の表現を変えます。

引用符と先頭の印を付けて再検査する

--write-safeへ出力先を渡し、販売候補のoffers-safe.csvを作りました。

python3 check_csv_formula.py offers-unsafe.csv --write-safe offers-safe.csv
printf 'exit=%s\n' "$?"
RISK row=2 column=3 value='+81-90-1234-5678'
RISK row=3 column=4 value='-500'
RISK row=4 column=3 value='@owner'
RISK row=5 column=5 value='=HYPERLINK("https://example.com","確認")'
WROTE path=offers-safe.csv escaped=4
exit=0

書き出し時のescaped=4は、4 件を見つけた後に変換した数です。この終了コード0は、変換ファイルを書けたことを表します。元の CSV が安全になった意味ではありません。

続けて、変換後のファイルを同じ検査へ通しました。

python3 check_csv_formula.py offers-safe.csv
printf 'exit=%s\n' "$?"
result=PASS risky_cells=0
exit=0

同じ規則で 4 件から 0 件へ変わりました。検査条件は変更していません。

検査した版をハッシュで固定する

配布候補と検査コードを取り違えないように、3 ファイルの SHA-256 を記録しました。

sha256sum offers-unsafe.csv offers-safe.csv check_csv_formula.py
edfdba7d790b1b7f67fa7fe38e404283274a9e811e7f32cb1682430602ab301c  offers-unsafe.csv
f73fa995a3b74cdb2456d2fe5d170ff8dd172cab41054bb2220b302f62ccdebc  offers-safe.csv
05cea3cc82f650a30c407e0bd6a826fb344a151985c05c279cd7cb704aa41564  check_csv_formula.py

ハッシュは、同じファイルかどうかを確かめる値です。安全性や商品価値を証明しません。販売候補を更新したら値も変わるため、検査結果と同じ版に残します。

4件すべてを自動変換しない選択もある

今回のコードは、危険な先頭文字へ一律で単一引用符を付けました。実際の商品では、4 件を同じ直し方にしない場合があります。

値自動変換後別の直し方
+81-90-1234-5678'+81-90-1234-5678電話番号列を文字列専用にします
-500'-500500円返金のように用途を明示します
@owner'@owner担当者IDの記号を別列へ分けます
=HYPERLINK(...)'=HYPERLINK(...)式を削り、URL列と表示名を分けます

明示的な式は削除する方が分かりやすいです。電話番号は+を消すと国番号の意味が変わります。返金額は、後の集計で数値として使うなら文字列化が不都合です。

そのため、検査は自動修正より先に置きます。まず行と列を止め、人が列の用途を見て直します。今回の--write-safeは、文字列商品として配る場合の候補を作る機能です。

引用符だけでは止まらない

CSV の二重引用符は、カンマや改行を 1 つのセルへ入れるための記法です。数式を無効にする記号ではありません。

たとえば、次の値は CSV として正しく引用されています。

"=HYPERLINK(""https://example.com"",""確認"")"

CSV パーサーが引用符を外すと、セルの中身は=HYPERLINK(...)です。表計算ソフトは、その後に式として解釈できます。RFC 4180 の形式に合うことと、表計算ソフトで安全に開けることは別の条件です。

OWASP は、区切り文字や引用符を使って新しいセルを始める入力にも注意を促しています。実運用では、文字列を連結して CSV を作らず、今回のcsv.writerのようなライブラリへ値を渡します。

よくあるエラー

症状原因対応
+81が不合格になります電話番号も危険な先頭文字で始まります値を消さず、文字データ専用の列として扱います
引用符で囲んだのにRISKが出ますCSVの引用符は式を無効にしませんパース後のセル先頭を検査します
--write-safe後も対象アプリで式になりますアプリが保存時にエスケープを外しています対象アプリで開く、保存する、再度開くまで試します
全角の=を見落としますASCII記号だけを調べています利用環境に合わせて全角記号も検査します
行数がずれます引用されたセル内に改行があります物理行ではなくcsv.reader後のレコード番号を使います
数値計算ができなくなります負数を文字列へ変換しました金額列は数値専用にし、配布形式をXLSXへ変える案も比べます
PASSでも不安が残りますアプリごとの解釈は一致しませんExcelやCalcなど、購入者が使うアプリで再保存まで確認します

0件は「どの表計算ソフトでも安全」の証明ではない

今回のPASS risky_cells=0が示すのは、定義した 11 種類の先頭文字が変換後 CSV にないことです。途中の区切り文字を悪用する入力や、表計算ソフト固有の挙動まで証明していません。

OWASP は、すべての表計算ソフトと後続処理に通用する普遍的な無害化方法はないと説明しています。MITRE も、製品ごとに動作が異なるため完全な対策はないと記載しています。

私は販売前の条件を 3 段に分けます。

  1. csv.readerで完成ファイルを読み、先頭文字を機械検査します
  2. 列の用途を見て、文字列化かデータ設計の変更を選びます
  3. 購入者が使う表計算ソフトで、開く、保存する、再度開くまで確認します

AI へ「安全な CSV を作って」と頼むだけでは、合格条件が残りません。4 件を行と列で出せる検査なら、モデルやプロンプトを替えた後も同じ物差しを使えます。

今回の失敗で効いたのは、式の名前を覚えることではありませんでした。電話番号、返金額、担当者名を、露骨な式と同じ入口で止めたことです。販売物を作る速度が上がるほど、完成ファイルに対する小さな検査が必要になります。

一次情報

資料確認した内容
OWASP: CSV Injection危険な先頭文字、引用と単一引用符による対策、再保存時の注意
MITRE CWE-1236CSV内の式要素を無害化しない弱点、製品差、対策の限界
Python 3.14: csvcsv.readerとcsv.writer、newline=''、CSV dialectの扱い
RFC 4180text/csvの形式、二重引用符と改行、実装差に関する記述
この記事をシェア