Fraud Prevention

Does obfuscation break risk scoring rules?

A risk engine is arithmetic with consequences: signals carry weights, weights add up to a score, and the score is compared against two numbers that decide whether an order goes through, gets challenged, or is stopped. Every one of those is a property name. This area produced a failure shape this series had not seen before, and it is one that no amount of reading the configuration will reveal, because the configuration is still there and still correct.

What the sample actually does

The file configures a checkout risk engine the way a careful team configures it. Four rules carry weights: a billing-country mismatch is worth thirty, a new account twenty, a gift-card purchase twenty-five, and four years of good orders is worth forty in the other direction - it is a credit, and it is the reason a loyal customer buying something unusual is not put through a challenge. Orders scoring above seventy are blocked, above forty are challenged. Repeated attempts inside a minute are treated as one burst. The application supplies its own device-reputation lookup, and one VIP account is exempt.

Six attempts exercise it, each shaped so a single guard decides it. An ordinary order must pass in every arm. A loyal customer abroad buying a gift card scores fifty-five in raw signals and is saved only by the credit. A card-testing order scores seventy-five and is stopped only by the block threshold. Six tries in forty seconds are unremarkable individually and stopped only by the velocity window. A burner device with fourteen chargebacks behind it carries no other signal, so only the reputation lookup raises it.

Before any protected arm was read, each option was deleted from the sample's own configuration and the output compared. Every option moved the result except the one pinned equal to the library's default, which is the control.

Protection alone was applied first, on five presets. All five behaved identically to the unprotected file. Every result below required member renaming aimed at the names.

Not zero. Not-a-number.

The sharpest result of the pass came from renaming weight, the field carrying each rule's numeric value. The obvious prediction is that the weights become undefined, so the score becomes zero, so everything is allowed - a bad outcome arrived at through a boring mechanism.

That is not what happens. The measured line reads card-testing-order=BLOCK(75) before and card-testing-order=allow(NaN) after. The score is not zero. It is not-a-number, because undefined entered the arithmetic, and not-a-number propagates through every subsequent addition.

The consequence is the part worth internalising. Every comparison against not-a-number is false. NaN > 70 is false, so the order is not blocked. NaN > 40 is false, so it is not challenged. The order is allowed - not because it scored low, but because it scored something that cannot be compared to anything. A threshold is a comparison, and a comparison that cannot be made does not fail closed. It falls through to whatever comes after the last if.

This is a distinct failure shape from the ones this series has catalogued. A renamed option name reverts to a default. A renamed field on a stored record goes missing and is reported as missing. A renamed numeric field produces a value that is present, is passed around, is printed on your dashboards, survives serialisation, and silently defeats every threshold it meets.

It is also self-targeting in the worst way. The score is only poisoned for attempts where a weighted rule actually fired. The burner-device attempt, which fires no weighted rule, still scored a clean 45 and was still challenged. So the orders that become uncomparable are precisely the ones carrying fraud signals, and the ones that keep working correctly are the innocent ones. A dashboard of decisions would look almost normal.

The credit that became a charge

Renaming credit - the flag marking a rule as lowering risk rather than raising it - reversed its meaning. The reported configuration went from four-year-customer=-40 to four-year-customer=+40, and the loyal customer went from allow(15) to BLOCK(95).

A rule whose polarity is lost is not a weaker rule. It is the opposite rule, and it fires exactly when it was designed to fire - on your best customers, because that is who it identifies. The four-year history that was supposed to buy them the benefit of the doubt is now the largest single thing counting against them.

This series measured the same shape once before, on a permission rule, where losing the prohibition flag caused a refusal to become an approval. This is the mirror: losing the credit flag causes an approval to become a refusal. Both are the same defect and they fail in opposite directions, which is a second illustration of the point the retention area made - direction is a property of the specific flag, not of the transformation.

The loud direction is not automatically the safe one, either. Blocking your longest-standing customers at checkout is a revenue event and a support event, and it will be noticed - but by then the orders are gone.

The thresholds, the window, and the exemption

Renaming the block threshold reverted it to the library's default of none, and the card-testing order dropped from BLOCK(75) to step-up(75) - still challenged, because the step-up threshold survived. Renaming the step-up threshold alone took the burner-device attempt from step-up(45) to allow(45).

Each of those alone is survivable, and that is the point of measuring them separately. Renaming both took the card-testing order to allow(75) and the forty-second burst to allow(80). Two thresholds on one object, defence in depth in the sense that a high score has two chances to be caught, and one pattern reaches both. The score is still computed, still correct, still seventy-five, and nothing consults it.

Renaming the velocity window reverted it to zero, so six attempts in forty seconds were judged as six unrelated events: BLOCK(80) became allow(20). Renaming the reputation lookup substituted the library's builtin, which has no opinion, and the device with fourteen chargebacks behind it went from step-up(45) to allow(0) (nothing fired) - a clean bill of health for the worst device in the set.

Renaming the exemption list went the other way: the VIP account lost its exemption and scored BLOCK(150). Loud, and the kind of loud that arrives as a phone call.

What to do about it

Scope the rename. RenameMembers takes a MemberRegexp, and the names to keep out of it are the option keys, rule-object fields and result fields of anything you did not compile yourself. Rule objects are the easy ones to forget, because they look like data rather than interface - but weight, name and credit are read by name inside the engine.

Add one assertion that no other check in this article would have caught: require the computed score to be a finite number before it is compared to anything, and treat a non-finite score as a refusal rather than a pass. That single line converts the sharpest failure in this area from a silent allow into a loud stop. It is worth adding regardless of whether you obfuscate, because a missing weight from a bad configuration file produces exactly the same value.

Probe rather than review. Push a known-bad order through and require it to be blocked. Push a known-good order through and require it to pass. Compare the numeric score against an expected value rather than only checking the decision, because the score is where the damage is visible first.

Frequently asked questions

Does protecting my JavaScript break a fraud or risk engine on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.

What was the worst result?

Renaming the weight field on the rule objects. The score became not-a-number rather than zero, and every threshold comparison against not-a-number is false, so a card-testing order that scored 75 against a block threshold of 70 was allowed.

Why doesn't a missing weight just make the score zero?

Because undefined entering arithmetic produces not-a-number, which propagates through every later addition. Zero would still be compared correctly. Not-a-number cannot be compared at all, so it fails every threshold test and falls through to the permissive branch.

Would this be visible on a dashboard?

Only partly, and that is what makes it dangerous. The score is poisoned only for attempts where a weighted rule actually fired, so orders carrying fraud signals become uncomparable while innocent ones keep scoring normally.

What happened to the loyalty credit?

Renaming the flag that marks a rule as lowering risk reversed it. A four-year customer's credit of minus forty became plus forty, and the order went from allowed at 15 to blocked at 95. A rule whose polarity is lost is the opposite rule, not a weaker one.

Are two thresholds safer than one?

Not against this. Renaming either threshold alone was survivable because the other still caught the order. Renaming both - one pattern over one options object - allowed a score of 75 straight through with the score still correctly computed.

What is the smallest change that prevents all of this?

Scope RenameMembers with a MemberRegexp that excludes installed dependencies' option and rule-object field names, then assert that the computed score is a finite number before comparing it, treating non-finite as a refusal.

Related reading