RegExp.testの部分一致で契約締結を判定したら、"unsigned"が"signed"に誤マッチした
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
正規表現の非アンカー部分一致による完了判定は、"unsigned"が"signed"を部分文字列として含むため誤マッチし、未署名の文書を締結済みとして自動保管しうる。
結論
Anyflow(電子契約)のwebhookペイロードから締結完了を判定する関数は、/complete|completed|signed|締結|done/iという非アンカーの正規表現による部分一致で書かれていた。この書き方では"unsigned"という文字列が"signed"を部分文字列として含むために誤マッチし、未署名(中間状態)のwebhookを「締結済み」として自動保管しうる状態だった。直し方は、否定語を先に弾いてから肯定語で確定するfail-closed方式への変更。
症状
契約の締結完了は、Anyflowから届くwebhookペイロードのeventまたはdocument.statusを見て判定していた。
export function parseAnyflowEvent(payload: unknown): AnyflowEvent {
const event = str(pick(payload, "event")) ?? str(pick(payload, "type")) ?? "";
const docStatus =
str(pick(payload, "document", "status")) ?? str(pick(payload, "status"));
const completed =
/complete|completed|signed|締結|done/i.test(event) ||
/complete|completed|signed|締結|done/i.test(docStatus ?? "");
正規表現に^や$によるアンカーが無いため、eventやdocStatusの文字列のどこかにこれらの語が含まれていればcompletedはtrueになる。Anyflow側が送ってくる中間状態のステータス文字列には、"unsigned"(未署名)や"incomplete"(未完了)のように、完了を表す語を部分文字列として含むものがある。"unsigned"は"signed"を、"incomplete"は"complete"をそのまま内包しているため、この判定はどちらもtrueと誤判定する。
HMAC検証は通っている正規のwebhookであっても、そのペイロード自体が中間状態を表すステータス文字列を運んでくる以上、署名検証の有無はこの誤マッチを防ぐ手段にならない。
原因
「その語を含むかどうか」で完了を判定する設計は、肯定語だけをリストアップして.includes()や正規表現の部分一致に頼ると、否定語が肯定語を部分文字列として内包しているケースを想定できない。unsigned/signed・incomplete/completeはどちらも英語の接頭辞(un-, in-)による否定形であり、部分一致の判定では接頭辞の有無を区別できない。
肯定語のリストをいくら増やしても、この構造そのものは解消しない。新しい否定語(例えば"undone"が"done"を含む)が現れるたびに、同じ形の誤検出が別の語で再発しうる。
直す
否定語のリストを先にチェックして弾き、否定語が一つも無い場合にのみ肯定語で完了を確定する順序に変更した。
const eventNorm = event.toLowerCase();
const statusNorm = (docStatus ?? "").toLowerCase();
const NEGATIVE = [
"unsigned", "not_signed", "not-signed", "incomplete", "declined", "rejected",
"canceled", "cancelled", "expired", "voided", "pending", "sent", "viewed", "draft", "failed",
];
const POSITIVE = ["completed", "complete", "signed", "締結", "done"];
const hasNegative = (s: string) => NEGATIVE.some((n) => s.includes(n));
const hasPositive = (s: string) => POSITIVE.some((p) => s.includes(p));
const completed =
!hasNegative(eventNorm) &&
!hasNegative(statusNorm) &&
(hasPositive(eventNorm) || hasPositive(statusNorm));
否定語チェックを先に置くことで、eventとdocStatusのどちらか一方にでも否定語が含まれていれば、肯定語の有無に関わらずcompletedはfalseのまま確定する。あわせて追加されたテストは、中間状態の各ステータスが締結完了と誤検出されないことと、本物の完了状態が引き続きtrueになることの両方を確認している。
it("中間状態を締結完了と誤検出しない(部分一致の誤爆防止)", () => {
expect(parseAnyflowEvent({ document: { status: "unsigned" } }).completed).toBe(false);
expect(parseAnyflowEvent({ status: "incomplete" }).completed).toBe(false);
expect(parseAnyflowEvent({ event: "document.declined", status: "declined" }).completed).toBe(false);
expect(parseAnyflowEvent({ status: "pending" }).completed).toBe(false);
expect(parseAnyflowEvent({ event: "document.sent" }).completed).toBe(false);
expect(parseAnyflowEvent({ status: "voided" }).completed).toBe(false);
// 本物の完了状態は引き続き true。
expect(parseAnyflowEvent({ status: "completed" }).completed).toBe(true);
expect(parseAnyflowEvent({ event: "document.signed" }).completed).toBe(true);
expect(parseAnyflowEvent({ document: { status: "締結完了" } }).completed).toBe(true);
});
再発防止
根拠にあるのはこのコミット自体が「否定語を先に見る」という設計変更とテスト追加まで行った、という事実で、これ以外に運用上の再発防止策が追加された記録は無い。
「その語を含むかどうか」で状態を判定するコードは、否定形が肯定語を内包する自然言語の性質そのものと相性が悪い。今回のfail-closed方式は、否定語のリストを先に確認するという順序を固定することで、新しい否定語が増えたときの対処を「否定語リストに1行足す」だけに閉じ込めている。肯定語のリストだけを増やして対処していた場合、同じ構造の誤検出は別の語で再発し続けていたはずである。
よくある質問
Q1なぜ正規表現の部分一致で誤判定が起きたのですか?
/complete|completed|signed|締結|done/iのような非アンカー正規表現は、文字列のどこかにその語が含まれていれば真になります。"unsigned"は"signed"を、"incomplete"は"complete"を部分文字列として含むため、否定を表す語であっても肯定側の正規表現にそのままマッチしてしまいます。
Q2HMAC検証をしていれば防げたのではないですか?
防げません。HMAC検証はペイロードがAnyflowから送られた本物であることを保証するだけです。締結途中の中間状態(unsigned・incomplete等)を伝える正当なwebhookのステータス文字列そのものが誤マッチの原因なので、署名検証を通っていても症状は変わりません。
Q3直し方は何を変えたのですか?
unsigned・incomplete・declined・pending等の否定語リストを先にチェックして弾き、否定語が一つも無い場合にのみcompleted・signed・締結等の肯定語で完了と確定するfail-closed方式に変更しました。
Q4テストはどう変わりましたか?
中間状態のstatus(unsigned・incomplete・declined・pending・sent・voided)を締結完了と誤検出しないことを検証するテストケースを追加し、本物の完了状態(completed・signed・締結完了)が引き続きtrueと判定されることもあわせて確認しています。
確認した環境
- Next.js 16.2.7 / TypeScript ^5 / kimiteras-portal
- 2026-07-13 に修正
この記事の根拠
- TypeScriptファイル 108〜115行目コミット 8b5c074
- TypeScriptファイル 108〜129行目コミット 6d414ec
- TypeScriptファイル 89〜102行目コミット 6d414ec
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。