読書メモです。
- セキュア・バイ・デザイン - 安全なソフトウェア設計
- https://book.mynavi.jp/ec/products/detail/id=124056
プログラミングの質を高めセキュリティを向上させよう
プログラミングの質を高めることで、セキュリティを向上させることができる― 著者らの考えを様々な形で試し検証を行い、本書「セキュア・バイ・デザイン(Secure by Design)・安全なソフトウェア設計」にまとめました。
本書は Eric Evans 氏のドメイン駆動設計(Domain-Driven Design: DDD)に関する考えの影響を大きく受けています。設計の中心にセキュリティを取り込む考え、ドメイン駆動セキュリティ(Domain-Driven Security)という名のコンセプトを生み出しこの考えを実際に開発に導入し、発展させてきました。
対象読者はソフトウェア開発者(C 言語、Java や C#など基本的なプログラミング技術を習得済みの方)ですが、特定の言語やフレームワークに依存しすぎないよう、主にセキュリティにおいて重要だと思うものだけを含めるようにしています。全体的なプログラミング・スキルを向上したかったり、既存のプログラムをさらに「安全」なものにしなくてはならなかったりするのであれば、本書はまさにあなたにとっての一冊となることでしょう。
セキュリティの機能(feature)と心配事(concern) - P.8 #
セキュリティがどのように表現され特定されるのかについては、セキュリティの専門家である John Wilander 氏と Jens Gustavasson 氏による研究が参考になります。その研究では、公的資金を受けている様々な分野のプロジェクトの中からいくつかのプロジェクトを選択し、それらのプロジェクトについて調べた結果、セキュリティのことを語る際、その 78%がセキュリティの機能として直接分類されることを話していることが分かりました。
セキュリティを機能として考えることをやめなければなりません。そうではなく、セキュリティを横断的な心配事、つまり、様々な機能にまたがって懸念される心配事として見なければならないのです。
メソッドの引数に対する事前条件の確認 - P.143 #
CatNameList クラスの契約では、queueCatName メソッドを使って子猫の名前を登録する際に課せられるいくつかの制約があります。その成約とは、子猫の名前に null を指定できないこと、そして、登録する子猫の名前には「s」が含まれていることです。もし、呼び出し元が契約に従わない呼び出しを行ってしまうと、このシステムは破綻してしまいます。このようなことを避け、安全性を確保するためには、システムが制御できる方法で速やかに失敗させる(fail-fast)ようにします。
速やかな失敗を行うためには、メソッドで何らかの処理を始める前に事前条件が満たされていることを確認します。そして、もし、この事前条件が満たされていないのであれば、処理を失敗させるようにします。なぜなら、もし、事前条件が満たされていないのであれば、呼び出し元は呼び出したクラスを想定した使い方で使っていないことになるからです。
public void queueCatName(String name) {
if (name == null)
throw new NullPointerException();
if (!name.matches(".*s.*"))
throw new IllegalArgumentException("Must contain s");
if (catNames.contains(name))
throw new IllegalArgumentException("Already queued");
catNames.add(name);
}
事前条件を確認すること自体は簡単なのですが、契約全体を考えた場合、さらに考えなくてはならないことがあります。例えば、これらの確認はすべてのメソッドに対して行うべきなのでしょうか?私たち著者の経験から言えば、public なメソッドに対しては、契約を策定し、事前条件を確認することは間違いなく行う価値があることです。private なメソッドになると、その確認を行う価値はあまりありません。
Read-Once オブジェクト(一度しか読み込めないオブジェクト) - P.180 #
ソフトウェアのセキュリティに関する問題でよくあるものの1つに機密性の高いデータの流出があります。このような情報の流出は開発者の意図とは別に怒ってしまうか、もしくは、悪意ある第三者によって意図して引き起こされるかのどちらかです。流出の原因が何であれ、この問題への対策として使える設計のテクニックがあります。それは私たち著者が Read-Once オブジェクトと読んでいる設計パターンです。
- Read-Once オブジェクトの値は一度だけしか読み込めない
- 機密性の高いデータをシリアライズさせないようにする
- Read-Once オブジェクトはサブクラスを作成したり、拡張させたりをできなくなっている。
Read-Once オブジェクトは名前が示すように一度しか読み込めないように設計されたオブジェクトです。通常、このオブジェクトは対象のドメインにおいて機密性が高い(sensitive)と見なされる値や概念(例えば、パスポート番号、クレジット・カード番号)を表現するのに使われます。Read-Once オブジェクトの主な目的はカプセル化されたデータが意図しない方法で使われることを検出しやすくすることです。
例えばパスワードは機密性の高いデータとしてよく取り上げられるものの1つです。そのため、ユーザのパスワードが平文で様々な人がアクセスできる何らかのログ・ファイルやディスク上に残ってしまったり、エラー・メッセージとしてユーザのブラウザや運用チームが監視している画面に表示されたりすることは、絶対に起こらないようにしなければなりません。
// final をつけることで継承を禁止する
// Externalizable を実装することでシリアライズが行われる際の振る舞いを制御する
public final class SensitiveValue implements Externalizable {
private transient final AtomicReference<String> value; // シリアライズを不可
public SensitiveValue(final String value) {
validate(value);
this.value = new AtomicReference<>(value);
}
public String value() {
// 一度しか値を取り出せなくする
return notNull(value.getAndSet(null), "Sensitive value has already been comsumed");
}
@override
public String toString() {
// 文字列化したときに意図しない情報の漏洩を防ぐ
return "SensitiveValue{value=********}";
}
@override
public void writeExternal(final ObjectOutput out) {
// シリアライズされそうになると例外をすろーする
throw new UnsupportedOperationException("Not allowed");
}
@override
public void readExternal(final ObjectOutput out) {
// シリアライズされそうになると例外をすろーする
throw new UnsupportedOperationException("Not allowed");
}
}
処理が失敗したときの対応に使われる例外 - P.330 #
アプリケーションが例外をスローする主な原因は、ドメイン・ルールの違反、技術的なエラーに分けられます。これらの原因から発生した例外はすべて不正なアクションを起こせないという共通の目標を持ってはいるのですが、それぞれが対象としているものは異なっています。まず、**ビジネス例外(business exception)**はドメインの観点から不正であると見なされるアクション、例えば、銀行口座から残高を超えた額を引き出そうとすることや既に支払いが済んだ注文に対して新たな商品をその注文に追加しようとすることなどをできなくさせるための例外です。一方、**技術的例外(technical exception)**は、ドメイン・ルールとは関係なく、技術的な観点から不正であると見なされるアクション、例えば、メモリー不足であるにもかかわらず、注文に商品を追加しようとすることを防ぐための例外です。
本書では、ビジネス例外と技術的例外を分離する設計にすることを推奨しています。このドメインで発生するすべてのビジネス例外は AccountException クラスを継承するようにしているため、ビジネス例外と技術的例外とが混在せず、ビジネス例外について必要なハンドリングを適切に実装しやすくなります。
public Balance accountBalance(final Customer customer, final AccountNumber accountNumber) {
notNull(customer);
notNull(accountNumber);
try {
return repository.fetchAccountFor(customer, accountNumber).balance();
} catch (AccountNotFound e) {
// handling...
} catch (AccountException e) {
// handling...
}
}
1つの要素だけを期待するときの取り出し方 - P.334 #
Java の Stream API は様々なメソッドを提供しており、その中の findFirst メソッドは含まれている要素から最初にマッチした要素を返し、そうすることでストリーム処理をその時点で終えるようにするメソッドです。銀行口座と口座番号は1対1の関係にあるので、findFirst メソッドを選択しても特に不思議なことではないかもしれませんが、ここには、注意しなければならないことが潜んでいます。
public class AccountRepository {
private final AccountDatabase accountDatabase;
public Account fetchAccountFor(final Customer customer, final AccountNumber accountNumber) {
notNull(customer);
notNull(accountNumber);
try {
return accountDatabase
.selectAccountsFor(customer)
.stream()
.filter(account -> account.number().equals(accountNumber))
.findFirst()
} catch (SQLException e) {
// ...
}
}
}
findFirst メソッドが暗に意味することは、対象となる要素が存在するのかどうかが重要なのであり、どの要素が返るかは特に重要ではない、ということです。しかしながら、銀行口座を取得する場合、どの銀行口座であっても構わないわけではありません。顧客の口座番号に紐づいている銀行口座は正当な銀行口座でなくてはならず、もし、そうなっていないのであれば、大きな問題となってしまいます。今回の fetchAccountFor メソッドの中で Stream API の findFirst メソッドが機能している唯一の理由は銀行口座と口座番号との関係が1対1だからです。しかしながら、もし、意図してなのかバグなのかに関わらず、この関係が変わってしまったら、取得される銀行口座が何になるのかを制御できなくなってしまいます。
この銀行口座の取得に関するより良い解決策は、findFirst メソッドを使うのではなく、Stream API の reduce メソッドを使い、1つの口座番号に対して該当する銀行口座が1つしかないことを明示的に検証させるようにし、もし、検索結果が複数あった場合は処理を失敗させるようにすることです。
例外を使わない処理失敗時の対応 - P.342 #
例外を使ってドメイン・ロジックの処理が失敗したことを表現するアプローチはよく使われているのですが、それとは別の同じくらいよく使われるアプローチに例外を使わないものがあります。
public final class Account {
public Result transfer(final Amount amount, final Account account) {
notNull(amount);
notNull(toAccount);
if (balance().isLessThan(amount)) {
return INSUFFICIENT_FUNDS.failure();
}
return executeTransfer(amount, toAccount);
}
}
public final class Result {
public enum Failure {
INSUFFICIENT_FUNDS,
SERVICE_NOT_AVAILABLE;
public Result failure() {
return new Result(this);
}
}
public static Result success() {
return new Result(null);
}
private final Failure failure;
private Result(final Failure failure) {
this.failure = failure;
}
public boolean isFailure() {
return failure != null;
}
public boolean isSuccess() {
return !isFailure();
}
}