「CSVを表計算へ安全に渡す設計|数式注入・先頭ゼロ・型崩れ・Python」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

CSVを表計算へ安全に渡す設計|数式注入・先頭ゼロ・型崩れ・Python

CSVをExcelやGoogle Sheetsへ安全に渡す設計を解説。数式注入、先頭ゼロ、長い管理番号、日時・NULLの型崩れ、Pythonによる型別export、Sheets APIのRAW指定、再実行、CAD・BOM連携まで整理します。

DOC NO.EKP-00-1216
DATE
REVB
READ約 13 分
目次

CSVの構文が正しくても表計算では壊れる

CSVは、行とfieldを区切って表形式のdataを交換するための単純な形式です。しかし、CSVをExcelやGoogle Sheetsで開くと、各cellは単なる文字列として保持されるとは限りません。先頭が=の文字列は数式、00127は数値、2026-10-28は日付として解釈される可能性があります。

この違いは二つの問題を生みます。一つは、部品IDの先頭ゼロ、長い管理番号、日時表記などが変わる「型崩れ」です。もう一つは、外部入力が数式として実行される「CSV Injection」または「Formula Injection」です。

CSVの引用符やdelimiterを正しく処理することと、表計算softwareで安全に開けることは別の契約です。対策の中心は、原本data、machine向けCSV、表計算向けexportを分離し、fieldごとの型と出力policyを先に決めることです。

CSVにはcellの型が入っていない

RFC 4180は、record、field、comma、改行、double quoteなどCSVの基本構造を文書化しています。一方、各fieldがID、数値、日付、真偽値、NULLのどれであるかはCSVだけでは表せません。

次の1行は構文上は単純ですが、表計算softwareがどう解釈するかはfieldごとに異なります。

part_id,description,quantity,updated_at
00127,=1+1,-2.5,2026-10-28T01:30:00Z
  • 00127は数値化されると127になる
  • =1+1は文字列ではなく数式になる可能性がある
  • -2.5は負の数値として扱うのが正しい
  • UTC日時はlocaleや表示形式によって別表記へ変換される可能性がある

先頭文字だけを見てすべての-を危険扱いすると、正当な負数まで文字列へ変わります。逆に、すべてのfieldを文字列として書き出すと、数量や価格を計算しにくくなります。必要なのはrecord全体へ一律の処理をかけることではなく、schemaに基づいてfieldごとに処理することです。

原本と表計算向けexportを分ける

安全な構成では、DBやJSONに保持する原本dataを変更しません。出力時に、利用先ごとのprofileを適用します。

出力profile主な利用先方針
canonicalDB、API、再処理型と元の値を保持する
machine CSVETL、Python、別systemschemaを別途共有し、値を改変しない
spreadsheet CSVExcel、LibreOffice数式解釈と自動型変換を抑える
Sheets APIGoogle SheetsRAWと型付きvalueを使う

表計算向けにsingle quoteを付けた値を原本へ書き戻してはいけません。次のexportで再びsingle quoteを付けると、'=1+1、''=1+1のように値が変わり続けます。必ずraw recordから毎回生成すれば、再実行しても同じ出力になります。

flowchart TD
  A[DBまたはJSONの原本] --> B[型と必須fieldを検証]
  B --> C{出力先}
  C -->|machine| D[値を保ったCSV]
  C -->|spreadsheet| E[数式と型変換を抑えたCSV]
  C -->|Sheets API| F[RAW指定でcellへ書込]

数式として解釈される入力を分離する

OWASPは、外部入力をCSVへ埋め込み、表計算softwareが数式として解釈する問題をCSV Injectionとして説明しています。代表的な危険prefixには=、+、-、@、tab、改行、null文字などがあります。

ただし、防御対象は「文字列field」です。数量のようにschema上の数値であり、Decimalとしてvalidation済みの-2.5までsingle quoteで逃がす必要はありません。処理を次の順序に分けます。

  • 入力recordをschemaに従って型検証する
  • ID、名称、説明などtext fieldだけを数式対策の対象にする
  • 数値は許容範囲を確認し、正規化した数値文字列を出力する
  • CSVのdelimiter、quote、改行はCSV libraryに処理させる
  • 出力後のfileを再入力にして再度escapeしない

double quoteでcellを囲むだけでは数式対策になりません。CSV parserが引用符を外した後、cellの内容が=から始まれば、表計算softwareは数式として扱えるからです。

ID・日時・NULLの型崩れを設計する

IDと長い番号

部品ID、郵便番号、管理番号、電話番号は、数字だけで構成されていても計算対象ではありません。先頭ゼロや全桁を保持する必要があるためtext型です。Excelでは長い数値が指数表記になったり、一定桁より後が丸められたりする可能性があります。

表計算向けCSVではsingle quoteを付ける方法がありますが、これはexport用の表現です。受け手がimport操作を選べるなら、Excelの「テキストまたはCSVから」で対象columnをtextとして指定する方が、元の値を変えずに扱えます。

日時

日時はRFC 3339形式のUTC文字列など、system間の契約を固定します。ただし、表計算softwareが自動で日時へ変換すると、表示上のtimezoneや精度が変わることがあります。表示・計算が目的なら日時cellへ明示変換し、監査用timestampや外部IDとして保持するならtextとして渡します。

NULLと空文字

空のCSV fieldだけでは、NULL、空文字、未入力を区別できません。null_tokenとして予約値を決める、value_present列を追加する、sidecar schemaを添付するなど、受け手と契約します。文字列NULLを無条件に欠損扱いすると、本当にNULLという名称を持つdataを失います。

真偽値

true、false、1、0、yesを混在させず、出力値を固定します。表計算で論理値として使うならTRUEとFALSE、machine連携ならschemaが定めた表現を使います。

Pythonで表計算向けCSVを生成する

次の例は、JSONやDBから取得した部品recordを検証し、表計算向けCSVへ変換します。IDはtextとして保持し、説明文の危険prefixをsingle quoteで中和します。数量はDecimalとして検証するため、正当な負数と未検証の文字列を区別できます。

from __future__ import annotations

import csv
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Iterable


FORMULA_PREFIXES = ("=", "+", "-", "@")
LEADING_CONTROLS = " \t\r\n\0"


def spreadsheet_text(value: str, *, force_text: bool = False) -> str:
    if "\0" in value:
        raise ValueError("NUL is not allowed in spreadsheet export")

    probe = value.lstrip(LEADING_CONTROLS)
    if force_text or probe.startswith(FORMULA_PREFIXES):
        return "'" + value
    return value


def decimal_text(value: Any) -> str:
    try:
        number = Decimal(str(value))
    except (InvalidOperation, ValueError) as error:
        raise ValueError(f"invalid decimal: {value!r}") from error

    if not number.is_finite():
        raise ValueError("NaN and Infinity are not allowed")
    return format(number, "f")


def export_parts(rows: Iterable[dict[str, Any]], path: Path) -> None:
    fieldnames = [
        "part_id",
        "description",
        "quantity",
        "unit",
        "released",
        "updated_at",
    ]

    with path.open("w", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(
            stream,
            fieldnames=fieldnames,
            extrasaction="raise",
            lineterminator="\r\n",
        )
        writer.writeheader()

        for row in rows:
            part_id = str(row["part_id"])
            description = str(row["description"])
            unit = str(row["unit"])
            updated_at = str(row["updated_at"])

            if unit not in {"pcs", "kg", "m"}:
                raise ValueError(f"unknown unit: {unit}")
            if not isinstance(row["released"], bool):
                raise ValueError("released must be boolean")

            writer.writerow(
                {
                    "part_id": spreadsheet_text(part_id, force_text=True),
                    "description": spreadsheet_text(description),
                    "quantity": decimal_text(row["quantity"]),
                    "unit": spreadsheet_text(unit),
                    "released": "TRUE" if row["released"] else "FALSE",
                    "updated_at": spreadsheet_text(
                        updated_at, force_text=True
                    ),
                }
            )

csv.DictWriterはcomma、double quote、改行をCSVの規則に従って処理します。spreadsheet_textは別の責務として、表計算softwareの解釈を抑えます。この二つを混同しないことが重要です。

utf-8-sigは表計算softwareとの互換性を重視した出力profileの選択です。machine向けCSVまで同じencodingへ固定する必要はありません。encoding、改行、delimiter、NULL表現はprofileのmetadataとして記録します。

Google Sheets APIではRAWを選ぶ

Google Sheets APIでcellへ直接書き込む場合、valueInputOptionが入力の解釈を決めます。USER_ENTEREDはUIへ入力した場合と同様に文字列を数式、日付、数値などへ変換します。一方、RAWは入力を解析せず保存します。

外部dataを文字列として渡す処理では、原則としてRAWを使用します。数量はJSON number、真偽値はJSON boolean、IDや日時識別子はstringとして送れば、CSVのsingle quoteに依存せず型を分けられます。数式を意図的に登録する処理だけを別interfaceにし、一般data取込と同じendpointや関数へ混ぜません。

正常系と異常系をtestする

test input期待する結果
00127の部品ID先頭ゼロを保持したtext
=1+1の説明数式として実行されないtext
-2.5の数量負の数値として保持
16桁以上の管理番号丸めずtextとして保持
commaとdouble quoteを含む説明1cellとして復元できる
空文字とNULL契約した規則で区別できる
未知のunitexportを停止してerror化
同じ原本から再実行byte単位で同じ結果になる

生成したCSVは、Pythonで読み戻すtestと、実際の対象softwareで開くtestの両方が必要です。Pythonで正しく復元できても、ExcelやSheetsの自動変換までは検証できません。特にID、日時、長い番号、数式prefixをfixtureとして固定します。

セキュリティと運用上の注意

CSVへaccess token、password、個人情報、非公開URLを安易に含めないようにします。CSVはmail添付、download folder、共有driveなどへcopyされやすく、DBのaccess controlから外れた後も残ります。

数式対策をしても、CSV file自体の公開範囲や保存期間は別問題です。export jobには実行者、対象条件、schema version、record件数、hash、生成時刻を記録し、本文dataとは分離したaudit logへ残します。

また、single quote方式は表計算向けの互換策であり、すべてのsoftwareで同じ表示になるとは限りません。型保持を厳密に要求する場合は、CSVではなく、型付きのXLSX、Parquet、API、Google Sheets APIなどを選びます。

DB・API・CAD連携へ再利用する

CAD属性やBOMでは、部品ID、図番、revision、数量、単位、更新日時が同じ行に並びます。見た目が数字でも、図番と数量は異なる型です。schemaでtextとnumberを分離し、表計算向けexportだけへ解釈対策を適用すれば、原本dataをAPIやDBへ再利用できます。

将来の連携では、CSV fileと一緒にschema version、column名、type、NULL規則、unit、timezone、export profileをmetadataとして管理します。これにより、Python取込ではmachine profile、手作業確認ではspreadsheet profileを選択でき、同じ原本から用途別の表現を再生成できます。

まとめ

CSVを安全に表計算へ渡すには、CSV構文のescapeだけでは足りません。数式注入、自動型変換、先頭ゼロ、長い番号、日時、NULLという「cell解釈」の契約が必要です。

原本dataを保持し、schemaでfieldの型を確定し、machine向けCSVと表計算向けCSVを分けます。文字列fieldだけに数式対策を適用し、数値は型検証して出力します。Google Sheets APIではRAWを使い、再実行時は常に原本からexportすることで、重複escapeとdata変形を防げます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次