|
| 1 | +# FT176: decimal モジュール |
| 2 | + |
| 3 | +**日付**: 2026-05-21 |
| 4 | +**テーマ**: `decimal.Decimal` による精度の高い十進数演算・金融計算・丸め制御 |
| 5 | +**セキュリティ診断**: なし(FT177 で実施) |
| 6 | +**クラッカーペンテスト**: **あり**(FT176: 172 + 4 = 176) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 概要 |
| 11 | + |
| 12 | +Python 標準ライブラリの `decimal` モジュールを検証する。 |
| 13 | +`float` の浮動小数点誤差(`0.1 + 0.2 != 0.3`)を回避し、 |
| 14 | +金融計算で必要な「正確な十進数演算」を `Decimal` 型で実装する。 |
| 15 | + |
| 16 | +このFTで確認する点: |
| 17 | +- `float` と `Decimal` の精度差(`0.1 + 0.2 == 0.3` の違い) |
| 18 | +- `quantize()` による丸めモードの制御(ROUND_HALF_UP, ROUND_HALF_EVEN 等) |
| 19 | +- 税計算・割引計算・割り勘といった金融計算パターン |
| 20 | +- `Infinity`, `NaN`, 空文字列等の不正入力への防御 |
| 21 | +- `parse_decimal_safe()` によるバリデーション(`is_finite()` によるInf/NaN ブロック) |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## 実装したサンプルアプリ |
| 26 | + |
| 27 | +**場所**: `/home/xi/docker/nene2-python-FT/ft176-decimal/` |
| 28 | + |
| 29 | +### 主要機能 |
| 30 | + |
| 31 | +| 関数/クラス | 概要 | |
| 32 | +|---|---| |
| 33 | +| `decimal_add/sub/mul/div(a, b)` | 基本四則演算(ゼロ除算は `None`) | |
| 34 | +| `round_decimal(value, places, mode)` | 指定モードで丸める | |
| 35 | +| `truncate_decimal(value, places)` | `ROUND_FLOOR` で切り捨て | |
| 36 | +| `ceil_decimal(value, places)` | `ROUND_CEILING` で切り上げ | |
| 37 | +| `ROUNDING_MODES` | 6種の丸めモード辞書 | |
| 38 | +| `calculate_tax(price, tax_rate)` | 税計算(ROUND_HALF_UP) | |
| 39 | +| `calculate_discount(price, discount_percent)` | 割引計算 | |
| 40 | +| `split_bill(total, num_people)` | 割り勘(ROUND_CEILING)| |
| 41 | +| `float_precision_demo()` | float vs Decimal 精度比較 | |
| 42 | +| `parse_decimal_safe(value)` | Infinity/NaN/長すぎる文字列をブロック | |
| 43 | +| `is_valid_decimal(value)` | バリデーション bool | |
| 44 | +| `compare_decimals(a, b)` | 大小比較(-1/0/1) | |
| 45 | + |
| 46 | +### HTTP エンドポイント |
| 47 | + |
| 48 | +| メソッド | パス | 概要 | |
| 49 | +|---|---|---| |
| 50 | +| POST | `/decimal/add` | 加算 | |
| 51 | +| POST | `/decimal/sub` | 減算 | |
| 52 | +| POST | `/decimal/mul` | 乗算 | |
| 53 | +| POST | `/decimal/div` | 除算(ゼロ除算 422) | |
| 54 | +| POST | `/decimal/round` | 丸め(truncated, ceiling も返す) | |
| 55 | +| POST | `/decimal/tax` | 税計算 | |
| 56 | +| POST | `/decimal/discount` | 割引計算 | |
| 57 | +| POST | `/decimal/split-bill` | 割り勘 | |
| 58 | +| GET | `/decimal/float-demo` | float 精度比較デモ | |
| 59 | +| GET | `/decimal/validate` | Decimal バリデーション | |
| 60 | +| POST | `/decimal/compare` | 大小比較 | |
| 61 | +| GET | `/decimal/precision` | 現在の計算精度 | |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## テスト結果 |
| 66 | + |
| 67 | +**42 passed** |
| 68 | + |
| 69 | +``` |
| 70 | +42 passed in 0.37s |
| 71 | +``` |
| 72 | + |
| 73 | +--- |
| 74 | + |
| 75 | +## 摩擦ポイント |
| 76 | + |
| 77 | +### F-1: `ROUND_HALF_EVEN`(銀行家丸め)の挙動が直感と異なる(深刻度: 低) |
| 78 | + |
| 79 | +**事象**: `round_decimal("2.5", 0, "ROUND_HALF_EVEN")` → `"2"`(偶数方向)。 |
| 80 | +Python の組み込み `round(2.5)` も `2` を返すが(banker's rounding)、 |
| 81 | +多くの現場では「4捨5入」を期待して `ROUND_HALF_UP` を使う。 |
| 82 | + |
| 83 | +**原因**: `ROUND_HALF_EVEN` は統計的偏りを最小化するため偶数方向に丸める。 |
| 84 | +**対応**: ドキュメントに丸めモードの違いを表で説明し、金融計算ではデフォルトを `ROUND_HALF_UP` に設定した。 |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## 観察点 |
| 89 | + |
| 90 | +### 観察1: `float` vs `Decimal` の精度差 |
| 91 | + |
| 92 | +```python |
| 93 | +0.1 + 0.2 # 0.30000000000000004 |
| 94 | +Decimal("0.1") + Decimal("0.2") # 0.3 |
| 95 | + |
| 96 | +0.1 + 0.2 == 0.3 # False |
| 97 | +Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True |
| 98 | +``` |
| 99 | + |
| 100 | +`Decimal` は文字列から初期化する必要がある。`Decimal(0.1)` は float の誤差を引き継ぐ。 |
| 101 | + |
| 102 | +### 観察2: `quantize()` による金融計算の標準パターン |
| 103 | + |
| 104 | +```python |
| 105 | +tax = (price * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) |
| 106 | +``` |
| 107 | + |
| 108 | +`quantize(Decimal("0.01"))` が「小数点以下2桁」を指定する慣用表現。 |
| 109 | +`Decimal(10) ** -2` と等価。`quantize` なしで演算すると桁数が増加する。 |
| 110 | + |
| 111 | +### 観察3: `Decimal("Infinity")` と `is_finite()` の組み合わせ |
| 112 | + |
| 113 | +```python |
| 114 | +def parse_decimal_safe(value: str) -> Decimal | None: |
| 115 | + result = Decimal(value) |
| 116 | + if not result.is_finite(): # Infinity / -Infinity / NaN を拒否 |
| 117 | + return None |
| 118 | + return result |
| 119 | +``` |
| 120 | + |
| 121 | +`Decimal("Infinity")`, `Decimal("NaN")`, `Decimal("sNaN")` は `InvalidOperation` を |
| 122 | +投げずに正常に生成される。`is_finite()` チェックが必要な理由がここにある。 |
| 123 | + |
| 124 | +### 観察4: `split_bill` の ROUND_CEILING で全員が必ず払える金額に |
| 125 | + |
| 126 | +```python |
| 127 | +# 1000 / 3 = 333.333... |
| 128 | +# ROUND_CEILING で 333.34 に切り上げ → 全員が333.34払うと 1000.02 になるが |
| 129 | +# これは「端数は最初の人が多く払う」設計ではなく「全員同額で超えたら少し多い」設計 |
| 130 | +per_person = (total / num_people).quantize(Decimal("0.01"), rounding=ROUND_CEILING) |
| 131 | +``` |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +## nene2-python フレームワークとの統合 |
| 136 | + |
| 137 | +- `BinaryOpBody`, `RoundBody`, `TaxBody` 等の Pydantic モデルで `max_length=30` を設定 |
| 138 | +- `_validate_decimal()` ヘルパーが `parse_decimal_safe()` を呼び出し、不正入力に一貫した 422 を返す |
| 139 | +- セキュリティヘッダーとリクエストIDが全レスポンスに付与されている |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## Developer Experience (DX) Review |
| 144 | + |
| 145 | +### ペルソナ1: 初心者(Python 歴1年・独学中・女性・バックエンド志望) |
| 146 | + |
| 147 | +`Decimal` のコンストラクターに **文字列**を渡す必要がある点は最初のつまずき。 |
| 148 | + |
| 149 | +**ドキュメント理解**: `Decimal("0.1")` vs `Decimal(0.1)` の違いを説明する必要がある。 |
| 150 | +**事故リスク**: 高。`Decimal(0.1)` で float 誤差を引き継ぐコードを書きがち。 |
| 151 | +**規約の使いやすさ**: `parse_decimal_safe()` のファクトリ関数パターンは使いやすい。 |
| 152 | + |
| 153 | +### ペルソナ2: ロースキル経験者(Python 歴3-4年・スクリプト系・男性・SES) |
| 154 | + |
| 155 | +既存コードの `float` を `Decimal` に置換するとき `str()` 経由が必要なことを知らない。 |
| 156 | + |
| 157 | +**コピペ可能性**: `calculate_tax()` パターンはそのままコピーして使える。 |
| 158 | +**拡張時の罠**: `quantize()` の `places` と `Decimal("0.01")` の関係が初見でわかりにくい。 |
| 159 | +**セキュリティ的な事故リスク**: 中。負の価格・税率のバリデーション欠如(ペンテストで発見)。 |
| 160 | + |
| 161 | +### ペルソナ3: フロントエンド寄り経験者(React/TS 歴4年・バックエンド転向中・ノンバイナリ) |
| 162 | + |
| 163 | +JS の `number` 型が浮動小数点演算なので、バックエンドが `Decimal` で正確に計算することの重要性を理解できる。 |
| 164 | + |
| 165 | +**エラーレスポンスの質**: 422 に `field_name` が含まれるため、フロント側のフォームバリデーションと対応しやすい。 |
| 166 | +**Python 固有概念の学習コスト**: `quantize` は JS には直接対応物がないが、「N桁に揃える」と説明すればわかる。 |
| 167 | +**事故リスク**: 低。HTTP 境界で `max_length` と `is_finite()` が守っている。 |
| 168 | + |
| 169 | +### ペルソナ4: バックエンド経験者(Django/FastAPI 歴5-6年・男性・リードエンジニア) |
| 170 | + |
| 171 | +金融システムで `Decimal` は必須。`quantize(ROUND_HALF_UP)` パターンを見れば即理解できる。 |
| 172 | + |
| 173 | +**他フレームワークとの差異**: Django の `DecimalField` はモデル側で `decimal_places` を指定するが、 |
| 174 | +このFTでは演算ごとに `quantize()` を呼ぶ明示的スタイル。どちらも正しい。 |
| 175 | +**nene2-python の薄さへの評価**: ドメインロジック(金融計算)が HTTP 層から完全に独立している点が評価できる。 |
| 176 | +**本番投入可能性**: ビジネスロジックバリデーション(範囲チェック)を追加すれば本番品質。 |
| 177 | + |
| 178 | +### ペルソナ5: シニアエンジニア(設計・コードレビュー担当・女性・10-12年) |
| 179 | + |
| 180 | +ペンテストで発見されたビジネスロジック欠如(負の価格・100%超割引)は Issue 化が必要。 |
| 181 | + |
| 182 | +**コードレビューチェックポイント**: |
| 183 | +- [x] `Infinity`, `NaN` が `is_finite()` でブロックされているか — OK |
| 184 | +- [x] ゼロ除算が安全に処理されているか — `decimal_div()` で None 返却 ✅ |
| 185 | +- [ ] `calculate_tax()` の `tax_rate` に範囲制限がない — `0 <= tax_rate <= 2` 程度のバリデーションが必要 |
| 186 | +- [ ] `calculate_discount()` の `discount_percent` が 0〜100 のチェックがない |
| 187 | +- [ ] Unicode 全角数字(`'123'`)が通過する — Decimal コンストラクターが Unicode digit を受け入れる |
| 188 | + |
| 189 | +**チームでの安全な共有パターン**: `parse_decimal_safe()` を必ず経由するルールをチーム内で徹底することが必要。 |
| 190 | +**ツール追加の必要性**: ruff には `Decimal(float)` を禁止するルールがないため、コードレビューで手動確認が必要。 |
| 191 | + |
| 192 | +### ペルソナ6: 設計者・ポリシー照合(nene2-python 設計ポリシー目線) |
| 193 | + |
| 194 | +CLAUDE.md の「数値フィールドに `ge` / `le` / `gt` / `lt` 範囲制限があるか」ポリシーに対して、 |
| 195 | +`Decimal` 型は文字列で受け取るため Pydantic の数値制限が適用されない点が設計的な空白。 |
| 196 | + |
| 197 | +**ポリシー達成度**: 中(Pydantic で数値範囲制限できない文字列 Decimal の扱いが未定義) |
| 198 | +**「初心者でも安全な API」達成度**: 中(Infinity/NaN は守られているがビジネスルール違反は通過) |
| 199 | +**設計上の負債**: 文字列 Decimal の範囲バリデーションパターンをフレームワークに追加する必要がある |
| 200 | +**Follow-up Issue 候補**: `Pydantic Annotated` で `DecimalStr` 型エイリアスを定義して範囲制限を組み込む |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## クラッカーペンテスト(FT176: 172 + 4 = 176) |
| 205 | + |
| 206 | +> **実施方針**: 金融計算 API は「数値の正確さ」と「ビジネスロジックの整合性」の両面から攻撃できる。 |
| 207 | +> クラッカーは価格をマイナスにして不正な返金を引き出したり、税率を異常値にして計算を崩したりする。 |
| 208 | +
|
| 209 | +### フェーズ1: 構造推測(攻撃者の視点) |
| 210 | + |
| 211 | +- **OpenAPI から推測できる内部構造**: |
| 212 | + - 全フィールドが `str` 型 → `Decimal(str)` を内部で使っていると推測 |
| 213 | + - `max_length=30` → 入力サイズ制限が文字数ベース(桁数ではない) |
| 214 | + - `num_people: int` に `ge=1, le=1000` → Pydantic 数値制限あり |
| 215 | + - `price`, `tax_rate` に数値範囲制限なし → バリデーション欠如の可能性 |
| 216 | + |
| 217 | +- **攻撃ベクターの仮説**: |
| 218 | + 1. `Infinity`, `NaN` を渡してランタイムエラーを引き起こす |
| 219 | + 2. 負の価格・100%超の税率でビジネスロジックを崩す |
| 220 | + 3. Unicode 文字を数値として送り込む |
| 221 | + 4. 科学表記(`1e100`)で予期しない巨大数を計算させる |
| 222 | + 5. 精度の高い計算を大量に送ってCPUを枯渇させる |
| 223 | + |
| 224 | +### フェーズ2: 攻撃実行ログ |
| 225 | + |
| 226 | +#### A. Pydantic バイパス・型強制攻撃 |
| 227 | + |
| 228 | +``` |
| 229 | +a='Infinity': 422 Invalid decimal: a='Infinity' ← ブロック ✅ |
| 230 | +a='-Infinity': 422 ← ブロック ✅ |
| 231 | +a='NaN': 422 ← ブロック ✅ |
| 232 | +a='sNaN': 422 ← ブロック ✅ |
| 233 | +a='inf': 422 ← ブロック ✅ |
| 234 | +a='-inf': 422 ← ブロック ✅ |
| 235 | +a='nan': 422 ← ブロック ✅ |
| 236 | +
|
| 237 | +a='1e10': 200 result=10000000000 ← 通過(有限値として正当) |
| 238 | +a='1E308': 200 result=1.000...E+308 ← 通過(有限値として正当) |
| 239 | +a='1e100': 200 result=1.000...E+100 ← 通過(有限値として正当) |
| 240 | +
|
| 241 | +a=123 (int type): 422 string_type error ← Pydantic が str を要求 ✅ |
| 242 | +``` |
| 243 | + |
| 244 | +**結果**: Infinity/NaN は全7種類ブロック。科学表記は有限値として通過(許容動作)。 |
| 245 | + |
| 246 | +#### B. ビジネスロジック攻撃 |
| 247 | + |
| 248 | +``` |
| 249 | +tax_rate=2.0 (200%超): 200 tax=2000.00, total=3000.00 ← 突破 ⚠️ |
| 250 | +price=-1000 (負の価格): 200 tax=-100.00, total=-1100.00 ← 突破 ⚠️ |
| 251 | +discount_percent=-10 (負割引): 200 → 値上がり ← 突破 ⚠️ |
| 252 | +discount_percent=150 (100%超): 200 discounted=-500.00 ← 突破 ⚠️ |
| 253 | +div by zero: 422 ← ブロック ✅ |
| 254 | +``` |
| 255 | + |
| 256 | +**結果**: 数値範囲バリデーションが未実装のため、負の価格・異常税率が通過。 |
| 257 | +金融 API として使う場合はビジネスロジックレベルの制約が必要。 |
| 258 | + |
| 259 | +#### C. 境界値・エッジケース攻撃 |
| 260 | + |
| 261 | +``` |
| 262 | +a="" (空文字): 422 Invalid decimal ← ブロック ✅ |
| 263 | +len=30 (上限ちょうど): 200 ← 通過(正常) ✅ |
| 264 | +len=31 (上限超え): 422 string_too_long ← Pydantic ブロック ✅ |
| 265 | +28桁 all-9s + 1: 200 result=1E+28 ← 通過(正常) ✅ |
| 266 | +全角数字 '123' + 1: 200 result='124' ← 通過(予期しない動作)⚠️ |
| 267 | +``` |
| 268 | + |
| 269 | +**発見**: Python の `Decimal` コンストラクターは Unicode の全角数字(`'123'`)を受け付け、 |
| 270 | +`123` と同じ値として扱う。`parse_decimal_safe()` は `InvalidOperation` が発生しないため通過する。 |
| 271 | +金融 API でユーザーが全角数字を入力した場合、期待通りに動作するが、 |
| 272 | +入力形式の正規化なしに通過することに開発者が気づいていない可能性がある。 |
| 273 | + |
| 274 | +#### D. 情報収集攻撃 |
| 275 | + |
| 276 | +``` |
| 277 | +Invalid mode 'HACKED': 422 Unknown rounding mode: 'HACKED' ← 安全なエラーメッセージ ✅ |
| 278 | +不正入力のエラー: 内部パス・スタックトレースなし ✅ |
| 279 | +``` |
| 280 | + |
| 281 | +**結果**: エラーメッセージは適切に制御されている。 |
| 282 | + |
| 283 | +#### E. DoS 試み |
| 284 | + |
| 285 | +``` |
| 286 | +100回 div(1/3): 0.321s (3.2ms/req) ← 正常速度 ✅ |
| 287 | +50回 mul(28桁×28桁): 0.163s (3.3ms/req) ← 正常速度 ✅ |
| 288 | +攻撃後の精度: 28 (不変) ← グローバル状態汚染なし ✅ |
| 289 | +``` |
| 290 | + |
| 291 | +**結果**: `1e100 + 1e100` のような巨大数演算も正常速度。 |
| 292 | +`decimal.getcontext().prec` はスレッドローカルなため、攻撃による変更は他スレッドに影響しない。 |
| 293 | + |
| 294 | +### フェーズ3: 攻撃まとめ |
| 295 | + |
| 296 | +| 攻撃カテゴリ | 試みた攻撃数 | 突破 | 耐えた | 予期しない動作 | |
| 297 | +|---|---|---|---|---| |
| 298 | +| Pydantic バイパス(Inf/NaN) | 7 | 0 | 7 | 0 | |
| 299 | +| 型強制(int型フィールド) | 1 | 0 | 1 | 0 | |
| 300 | +| ビジネスロジック(範囲) | 4 | 4 | 0 | 0 | |
| 301 | +| 境界値(長さ・文字種) | 5 | 0 | 4 | 1 | |
| 302 | +| 情報収集(エラー解析) | 2 | 0 | 2 | 0 | |
| 303 | +| DoS(大量・高精度計算) | 3 | 0 | 3 | 0 | |
| 304 | + |
| 305 | +**攻撃耐性評価**: 軽微な問題あり(ビジネスロジックバリデーション欠如) |
| 306 | + |
| 307 | +**発見した弱点**: |
| 308 | +1. **MEDIUM**: `calculate_tax()`, `calculate_discount()` に価格・税率・割引率の範囲制限なし |
| 309 | + - 負の価格(`-1000`)→ 負の税額 |
| 310 | + - 200%の税率(`2.0`)→ 元価格の3倍 |
| 311 | + - 150%の割引(`150`)→ マイナス価格 |
| 312 | + |
| 313 | +2. **LOW**: Unicode 全角数字(`'123'`)が `Decimal` に通過する |
| 314 | + - `parse_decimal_safe` は `is_finite()` で判定するが、Unicode digit は有限値なので通過 |
| 315 | + - 機能的には正しく動作するが、予期しない入力形式 |
| 316 | + |
| 317 | +--- |
| 318 | + |
| 319 | +## Follow-up Issues |
| 320 | + |
| 321 | +| 優先度 | タイトル | 種別 | |
| 322 | +|---|---|---| |
| 323 | +| 高 | `calculate_tax()`, `calculate_discount()` に価格・税率・割引率の範囲バリデーションを追加 | fix | |
| 324 | +| 中 | `parse_decimal_safe()` に ASCII 数字のみ許可するオプションを追加(Unicode digit の予期しない受け入れを防ぐ) | feat | |
| 325 | +| 中 | 文字列 Decimal の `Annotated` 型エイリアス(`PositiveDecimalStr`, `TaxRateStr`)をフレームワークに追加 | feat | |
| 326 | +| 低 | `Decimal(0.1)` を禁止するカスタム ruff ルールの検討 | chore | |
| 327 | + |
| 328 | +--- |
| 329 | + |
| 330 | +## まとめ |
| 331 | + |
| 332 | +FT176 では `decimal.Decimal` による精度の高い金融計算を実装した。 |
| 333 | +`float` との精度差(`0.1 + 0.2 != 0.3` 問題)、`quantize()` による丸め制御、 |
| 334 | +`is_finite()` による `Infinity`/`NaN` ブロックを確認した。 |
| 335 | + |
| 336 | +クラッカーペンテストでは Infinity/NaN の全種類が正常にブロックされたが、 |
| 337 | +ビジネスロジックレベルのバリデーション(負の価格・100%超の税率)が欠如していることを発見した。 |
| 338 | +金融 API では「計算として正しい値」と「ビジネスとして許容できる値」の区別が重要で、 |
| 339 | +`parse_decimal_safe()` の技術的バリデーションだけでは不十分であることが確認された。 |
| 340 | + |
| 341 | +次の FT177 は 177 % 3 = 0 → セキュリティ診断が必要。 |
0 commit comments