Skip to content
SecHelixv3 alpha
GitHub
DocsContributeSupportWorkbenchGitHub
Back to overview
Review guide · Business logic

The bug is between two individually correct operations.

Business logic defects do not live in one function, which is why per-file scanning misses them. Refund racing delivery, a retried webhook charging twice, a timeout coerced into failure: each needs a state machine, an invariant, and a deterministic reproduction.

Part of the AppSec agent guide.

A business logic vulnerability is a security defect that every individual function handles correctly. The refund handler validates its input. The delivery handler validates its input. The defect is that both can run against the same order, and no invariant spans them. This is why per-file scanning does not find these: there is no bad line to flag.

The two shapes that account for most of them are a guard that bounds one request instead of the running total, and a precondition that is read in application memory and committed in a separate statement. Both are shown below, taken from the paired evaluation fixtures in the open repository, and both are reviewed under the same evidence contract as any other class.

SEC-MONEY-INVARIANT-001SEC-RACE-IDEMPOTENCY-001Deterministic reproduction

Per-request guards on cumulative quantities

The comment in the vulnerable version is not wrong. A refund may indeed never exceed what the customer paid. The bound simply does not enforce that sentence.

Bounded against a static reference
def refund(self, order_id, amount, actor):
    order = self._orders.get(order_id)
    if order is None:
        raise LookupError("unknown order")
    if amount <= 0:
        raise ValueError("amount must be positive")
    # A refund may never exceed what the customer paid.
    if amount > order["total"]:
        raise ValueError("refund exceeds order total")
    self._ledger.credit(order["customer_id"], amount)
    self._orders.append_refund(order_id, amount, actor)
    return {"order_id": order_id, "refunded": amount}

Each call is individually valid. Call it four times for a quarter of the total and the customer is refunded the order twice over.

Bounded against the remaining balance
def refund(self, order_id, amount, actor):
    with self._orders.transaction() as tx:
        order = tx.lock_order(order_id)
        if order is None:
            raise LookupError("unknown order")
        already = sum(r["amount"] for r in tx.refunds_for(order_id))
        remaining = order["total"] - already
        # The invariant is cumulative, not per-request.
        if amount > remaining:
            raise ValueError("refund exceeds remaining refundable balance")

Two changes, both required. The balance is derived from committed movements, and the read and the write share one locked transaction.

The same defect appears wherever a quantity accumulates: store credit, payout instructions, seat allocations, inventory decrements, and usage quotas. The tell is a comparison against a stored total rather than against a total minus what has already been consumed.

Check-then-act on a single-use resource

The second shape is a read that establishes a precondition and a write that never re-asserts it. Between those two statements, another request can do the same thing.

Precondition checked in memory
voucher = self._db.query_one(
    "SELECT code, value, redeemed_by FROM vouchers WHERE code = ?", [code]
)
if voucher is None:
    raise LookupError("unknown voucher")
if voucher["redeemed_by"] is not None:
    raise ValueError("already redeemed")

self._wallet.credit(user_id, voucher["value"])
self._db.execute(
    "UPDATE vouchers SET redeemed_by = ? WHERE code = ?", [user_id, code]
)

Two concurrent redemptions both read redeemed_by IS NULL, both credit the wallet, and the second update overwrites the first.

Precondition carried by the write
# Claim atomically: the WHERE clause carries the unused precondition.
claimed = self._db.execute(
    "UPDATE vouchers SET redeemed_by = ? WHERE code = ? AND redeemed_by IS NULL",
    [user_id, code],
)
if claimed.rowcount != 1:
    raise ValueError("already redeemed or unknown voucher")

The WHERE clause is the lock. Exactly one statement affects a row, and rowcount is the authority on which caller won.

The root causes underneath both shapes

  • The guard is per-request rather than cumulativeIt bounds one operation instead of the sum of committed operations. Correct for one call, wrong for a sequence.
  • The idempotency record is written after the external effectA window exists in which the charge happened and the record does not. The retry then charges again.
  • The idempotency key is derived per attemptRetries present as new logical requests. The key has to identify the request, not the transmission.
  • Timeout is treated as failureAn external call that timed out has an unknown outcome. Coercing unknown into failure duplicates a side effect that already succeeded.
  • Uniqueness is enforced only in application codeA lookup with no constraint behind it in the store is a suggestion. Two processes can both pass it.
  • Balances are mutable stored totalsWhen the total is updated in place, no derivation exists that can be checked against the underlying entries, so divergence is undetectable.

What to record for every transition

These defects live between two actions, so the review artifact is a transition table rather than a file list.

Preconditions
The states from which this transition is legal.
Actor
Which effective subject may trigger it, including services and provider callbacks.
Source of truth
Which system owns the answer when two records disagree.
Side effects
Provider calls, ledger writes, inventory decrements, notifications.
Idempotency identity
The key that makes a repeat of this transition a no-op.
Retry behaviour
What a client, a queue, and a provider each do on failure.
Partial-success behaviour
What is persisted when the second write fails.
Terminal behaviour
Whether the state can be left, and by whom.

Then look for the combinations, because that is where the invariant is missing. The two Gold Check Packs below turn that search into a written plan, and installing the skill is what puts them in the session.

Failure combinations
refund + late provider success
delivery + cancellation
cost edit + finalized payout
two admins + one assignment
timeout + retry + delayed callback
partial fulfillment + "mark full"
seller A + seller B's object
Session prompt
Use SecHelix to audit business logic, payment/accounting truth, and concurrency.
Model state transitions and invariants for create/update/cancel/refund/approve/claim/redeem/
withdraw/transfer/purchase/webhook operations.
Test replay, idempotency, duplicate execution, partial success, late callbacks, negative/overflow
values, price tampering, stale state, TOCTOU, and double-spend windows in a safe environment.

The two packs that cover this ground

Money pack
SEC-MONEY-INVARIANT-001
Money boundary
MONEY_CONSERVATION · action TRANSFER · sinks REFUND_CALL, LEDGER_WRITE, PAYOUT_INSTRUCTION, PROVIDER_EVENT_HANDLER
Money impact statement
Maximum excess value per captured amount and per unit of time. Not a severity word.
Race pack
SEC-RACE-IDEMPOTENCY-001
Race boundary
SINGLE_USE_TRANSITION · action CONSUME · sinks STATE_TRANSITION_WRITE, EXTERNAL_SIDE_EFFECT, COUNTER_DECREMENT, EVENT_CONSUMER
Race refutation tests
Inspect the schema and migrations for a unique constraint; run the reproduction against a known-correct path to confirm the harness can tell a real duplicate from a flaky fixture.
Default validation mode
LOCAL for both. Destructive actions and production mutation are forbidden by the schema.
Calibration
NOT_MEASURED, sample size 0, for both.
  • The canonical fix is usually one exact-once merge rather than several dedupe rules, or one atomic operation rather than delete-then-insert.
  • Preserve historical, accounting, and audit evidence during repair. A fix that rewrites ledger history is not a fix.
  • Remediated variants must still complete the first legitimate execution, so a blanket denial cannot pass as a repair.

The last rule is not theoretical. In the published run, the regression suite asserts both that hostile input is refused and that legitimate input still resolves, precisely so a blanket denial could not pass as the fix.

Install

Model the transition, then try to run it twice.

npx skills@latest add omarmohelal/SecHelix --skill sechelix