Guide 063 explanation

What code scanning misses in business logic

Review the business rules a generic scanner may not know, using a refund example with atomic reservations, retries and unresolved outcomes.

Code scanning can see that a function exists and sometimes trace how data moves, but it may not know the rule the business meant to enforce. A refund endpoint can sanitize input, use parameterized queries and require login while still allowing more money to be refunded than was paid. The missing fact is the invariant: total successful refunds for an order must never exceed the captured payment. A tool with tailored rules and full workflow context might find the gap; a generic “clean” scan does not prove the invariant holds. To investigate, write the intended rule, map every state transition and owner, then check the controls at each transition and the evidence from the actual deployment.

OWASP's business-logic testing introduction recommends understanding the business process and its restrictions before testing it. Its Business Logic Security Cheat Sheet explains the central failure: code may faithfully execute a rule that does not match what the business requires. That is why the AppSec lead needs a conversation with the product and payments owners, not just a longer alert list.

Illustrative case: partial refunds

Fictional example; no requests were sent and no customer money moved: Cedar Credits sells subscriptions. A customer-service agent can issue partial refunds. At revision CC-52, the refund route checks that the agent is signed in, that a refund amount is positive, and that it does not exceed the order's original payment. The implementation calls the payment provider and writes a refund record. A static scan reports no injection or unsafe deserialization findings in this module.

Those observations do not answer whether the route enforces the intended rule across repeated requests. Suppose an order captured 100 currency units. One permitted 70-unit refund leaves at most 30 available. A second 70-unit request is individually below 100, so the local input check passes. If the service does not account for successful prior refunds, it may request a cumulative 140. Whether the provider prevents, accepts or later reverses that request is unknown from the route alone. The risk is a candidate, conditional on the full workflow and provider behavior; it is not a confirmed loss or executed exploit.

The policy may also be more nuanced: shipping fees might be non-refundable, taxes might be recalculated, a provider may reserve funds asynchronously, and an agent may be permitted to correct an earlier failed refund. These details define the actual invariant. The security reviewer should not silently choose “payment amount” as the universal cap when the business owner has not approved it.

Use this rule-to-path record to start the review:

Element Cedar Credits working answer Evidence still needed
Intended rule Sum of successful refunds for one captured payment must not exceed its approved refundable cap before refunds. The remaining balance is that cap less amounts already consumed or reserved. Payments/product owner confirms fees, taxes and correction policy.
Actor Customer-service agent with refund permission. Current role policy and account scope for the route and worker.
Entry points Agent UI calls refund API; support automation may call the same service. Route map and automation inventory at CC-52.
State change Payment provider accepts or rejects request; local ledger records a status. Provider contract, webhook ordering and retry behavior.
Concurrent path Two authorized refund requests may read the same pre-refund balance. Transaction boundary, lock or conditional update evidence.
Business effect Excess refund could be requested if cumulative balance is not enforced. Downstream provider response and reconciliation; no observed payout claimed.

The table is a reusable artifact because it exposes the missing facts. It also shows why a rule that checks “amount ≤ original payment” is insufficient if cumulative refunds are permitted. The risky behavior spans a state machine and perhaps two systems, whereas a generic source rule may focus on a single input and operation.

Turn “do not over-refund” into an enforceable rule

The product conversation is where the missing specification appears. Continue the fictional case:

Reviewer: “Does a pending refund consume the available balance?”

Payments owner: “Yes. A timeout may mean the provider accepted it. We cannot release the amount until rejection is established.”

Product owner: “Support can split a refund, but cannot exceed the refundable capture. Fees are excluded before that balance is calculated.”

For this example, the approved refundable capture is 100 units. The stronger admission rule becomes:

successful refunds + reserved unresolved refunds + new request ≤ 100

“Successful refunds must not exceed 100” is a desired final property. It is too weak as an admission check if pending requests can later succeed. The reservation term connects the business rule to the asynchronous system.

Work through the state instead of stopping at the formula:

Event Successful Reserved/unresolved Available Decision
Capture available 0 0 100 May request up to 100
Request A reserves 70 0 70 30 Send A under its stable operation identity
Request B asks for 70 0 70 30 Reject B before provider submission
A times out with unknown result 0 70 30 Retain reservation; reconcile A
Provider later confirms A succeeded 70 0 30 Convert reservation once; do not add another 70
Provider instead conclusively rejects A 0 0 100 Release reservation once

The last two rows are alternatives. A timeout is neither of them. A duplicate success event after A is already successful leaves 70/0/30 unchanged. A retry of A uses the same business operation identity; a genuinely new request must obtain its own reservation.

Find the source operation that preserves the arithmetic

Two handlers can both read available=100, both accept 70, and produce reservations totaling 140. The balance decision and reservation must therefore be serialized or conditional as one atomic application operation. A schematic transaction is:

load authoritative payment state under the chosen concurrency control
authorize caller for this payment
if operation A already exists:
    require same authorized caller, payment, amount and currency
    return its recorded state without creating a new reservation
require requested amount > 0 and amount ≤ available balance
record A as reserved and reduce available balance atomically
commit
submit A to provider using its stable operation identity

This is design pseudocode, not a complete payment implementation. A crash after commit but before submission leaves a recoverable reserved operation. Recovery must decide whether to submit or reconcile that same operation, not create a second refund. A provider idempotency facility can help with repeated submission, but it does not define the application's refundable amount or authorize the agent. Check the actual provider contract before relying on its behavior.

The review is now specific enough to use source analysis well: find every reservation writer, success/failure transition and bypass entry point. A generic scanner might find unsafe query construction in those functions; a custom rule might flag a missing reservation helper. Neither knows whether excluded fees belong in the cap unless the product supplies that rule.

This article's job is to expose the missing specification, not prescribe a universal refund architecture. OWASP's business-logic guidance supports explicit state machines and concurrency review. The completed example gives the reviewer a concrete invariant to take to the payments owner and a set of transitions that can falsify it in an authorized sandbox later. No payment or runtime request has been made here.

Review the recovery path, not just admission

Continue Cedar's example after A reserves 70 units and the provider response is lost. A support operator sees an old pending record and asks whether it can be deleted to unblock the customer. Deletion would erase the local explanation for why only 30 units remain available. Releasing those 70 units without resolving the provider outcome could permit a second request even if A succeeded. The missing business rule now concerns recovery authority: who can decide that a reservation is no longer needed, and which evidence supports that decision?

Write the permitted recovery outcomes before reviewing an administrative button. A confirmed success converts the existing reservation to a successful refund. A conclusive rejection releases it. An unresolved response retains a distinct reconciliation state and has a named owner. If the business chooses to provide another form of compensation while reconciliation continues, record that as a separate authorized financial decision; do not disguise it as proof that the original refund failed. The example does not establish what any particular payment provider will accept.

The same review should find every writer of the refundable balance. A careful API handler is insufficient if a support script, webhook consumer or batch repair process can change the ledger under a different rule. Ask each owner to explain how a repeated message or interrupted repair avoids applying the same transition twice. A label such as “internal admin only” describes who can call the path, not whether its arithmetic is correct. Administrative authority may be deliberately broader, but the exceptional operation and its audit record still need an explicit policy.

Keep amounts attached to their payment and currency when comparing states. Cedar's 100-unit example uses one payment and one currency throughout. Adding a 70-unit request from a different payment to this ledger would violate the object's boundary; adding amounts expressed in different currencies would not produce a meaningful balance. A useful review record therefore names the payment identity, units, original approved cap and each operation's identity alongside the numeric values.

These questions help the reviewer distinguish a missing source check from an unspecified recovery process. The first can often become a bounded engineering repair. The second needs a decision from the payments and product owners before code can faithfully implement it. Neither question is answered by the absence of injection findings.

Decide between a finding, investigation and rejected claim

A finding needs a supported actor-to-effect path at an identified revision and scope. Use independent review for consequential payment conclusions, and distinguish the defective admission rule from any unproven payout effect. If the route reads previous successful refunds, reserves the remaining balance atomically, and the provider enforces the same cap, the hypothetical over-refund path may be rejected with those exact checks. If the service simply compares each request with the original order amount and a reviewer confirms no later cap or reconciliation blocks cumulative excess, the path becomes a supported vulnerability candidate for final approval. If provider behavior or business policy is unknown, retain an investigation with an owner and next artifact. A clean static report cannot decide among those outcomes.

When the question is urgent, the team can restrict refund permissions or temporarily require a second approver while investigating. Such containment may be prudent, but it does not retroactively verify the suspected flaw. Record the intervention separately from the technical verdict so later readers know what changed.

Make the next review repeatable

Keep a compact register for high-value business actions. For each one, record the invariant, allowed actor, object and tenant boundary, state transitions, side effects, concurrency control, and one positive and one misuse scenario. Revisit it when a new entry point, queue worker or provider integration appears. A scanner can help locate relevant code, and custom checks may enforce some explicit invariants, but a rule should be judged against the full policy and the actual paths it covers. OWASP's SAST overview warns that access-control and other context-heavy issues are difficult to automate; that is a reason to document the missing context, not to discard automation.

For the next sprint, choose one money-moving or data-export workflow and write its admission invariant and walk the pending, success, rejection and retry states with your own authorized evidence. Use the SAST evaluation guide if a vendor claims to find such logic, or the finding triage guide to decide what to do with a specific alert.