RegExp.test False Positive: "unsigned" Matches "signed"
This article may contain affiliate links. Its content is not affected by advertising.
In short
An unanchored regex used to detect completion matches "unsigned" as a false positive because it contains "signed" as a substring, letting unsigned documents be auto-archived as completed.
Conclusion
A function that reads an Anyflow (e-signature) webhook payload to decide whether a contract was completed used the unanchored regex /complete|completed|signed|done/i to match on substrings. Because "unsigned" contains "signed" as a substring, this matched, and an intermediate, unsigned webhook could be auto-archived as “completed”. The fix was to switch to a fail-closed order: check negative words first, and only confirm completion with the positive words if none of the negative words matched.
Symptom
Contract completion was decided by reading event or document.status from the webhook payload sent by Anyflow.
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 ?? "");
The regex has no ^ or $ anchors, so completed becomes true if any of those words appear anywhere inside event or docStatus. Some of the intermediate status strings Anyflow actually sends — "unsigned", "incomplete" — contain a completion word as a substring. "unsigned" contains "signed"; "incomplete" contains "complete". Both are misjudged as true.
Even a legitimate webhook that passes HMAC verification still carries the same status string reporting an in-progress state, so whether the signature check passed or not has no bearing on this false match — the payload itself is the source of the bug.
Cause
Deciding completion by “does the string contain this word” cannot handle a case where the negative word contains the positive word as a substring, once the check is reduced to .includes() or a partial regex match over a list of positive words alone. unsigned/signed and incomplete/complete are both English negations formed with a prefix (un-, in-), and a substring match cannot tell the prefixed form from the bare form.
Growing the positive-word list further does not fix the underlying structure. Every time a new negative word shows up (for example "undone" containing "done"), the same shape of false positive can recur under a different word.
The fix
The order was changed to check a list of negative words first and reject on any match, only falling through to the positive-word check if none of the negative words are present.
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));
Putting the negative check first means that if either event or docStatus contains a negative word, completed stays false regardless of whether a positive word also happens to be present. The tests added alongside this change confirm both directions: that each intermediate status is not misdetected as completed, and that genuine completion states still return true.
it("does not misdetect intermediate states as completed (guards against partial-match false positives)", () => {
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);
// Genuine completion states still return true.
expect(parseAnyflowEvent({ status: "completed" }).completed).toBe(true);
expect(parseAnyflowEvent({ event: "document.signed" }).completed).toBe(true);
expect(parseAnyflowEvent({ document: { status: "締結完了" } }).completed).toBe(true);
});
Preventing a repeat
What the source shows is that this same commit made the design change to check negative words first and added the tests above; there is no record of any further process change beyond that. No other operational safeguard is documented.
Deciding state by “does the string contain this word” is fundamentally at odds with how negation works in natural language, where a negated form often contains its positive counterpart as a substring. The fail-closed order used here confines the cost of a new negative word appearing later to “add one line to the negative-word list.” Had the fix instead been to keep growing the positive-word list, the same shape of false positive would likely have recurred under a different word.
Frequently asked questions
Q1Why did an unanchored regex cause a false match here?
A regex like /complete|completed|signed|done/i with no ^ or $ anchors returns true if the word appears anywhere in the string. "unsigned" contains "signed" as a substring, and "incomplete" contains "complete", so both matched the positive-word regex even though they are negation words.
Q2Wouldn't HMAC verification have caught this?
No. HMAC only proves the payload came from Anyflow. The false match comes from the status string itself — a legitimate webhook reporting an in-progress state like "unsigned" or "incomplete" still carries that string and still triggers the same false match.
Q3What was the actual fix?
Check a list of negative words first (unsigned, incomplete, declined, pending, etc.) and reject on any match. Only if none match does it fall through to the positive words (completed, signed, done). A fail-closed order instead of a positive-only lookup.
Q4What did the new tests check?
Tests were added to confirm that intermediate statuses (unsigned, incomplete, declined, pending, sent, voided) are never misdetected as completed, alongside tests confirming that genuine completion states (completed, signed) still return true.
Environment verified
- Next.js 16.2.7 / TypeScript ^5 / kimiteras-portal
- Fixed on 2026-07-13
What this article is based on
- TypeScript file lines 108-115commit 8b5c074
- TypeScript file lines 108-129commit 6d414ec
- TypeScript file lines 89-102commit 6d414ec
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.