Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

All posts
Why 77.3% accuracy is not good enough to block a merge
  • NeuralGuard
  • Machine Learning
  • Security

Why 77.3% accuracy is not good enough to block a merge

The previous article left the detector ready to run in a pipeline. What is left is answering whether it should.

The answer is no, and this article is about why.

The two ways of being wrong

When a detector of anything is wrong, it is wrong in two ways, and they are quite different from each other.

The false negative is when a problem exists and it does not warn you. The vulnerable code slips through and goes to production.

The false positive is when there is no problem and it warns you anyway. The code is correct, and the tool accuses it.

Almost everyone's intuition is that the false negative is the serious error. After all, it is the one that lets the vulnerability in. The false positive looks like a mere annoyance.

In a pull request gate, that intuition is inverted.

Why the false positive is the error that kills the tool

Think about what happens in practice with each one.

A false negative is invisible. Nobody notices it happened. The tool did not warn, the pull request went ahead, and life continues. The team keeps trusting the tool, because from their point of view nothing went wrong.

A false positive is the opposite: it happens in front of everyone. Somebody opened a pull request with correct code, and the tool blocked it. That person will look at the code, see that it is right, and be stuck.

And then they have to do something. They will ask somebody to unblock it, or go looking for how to switch the check off, or open a discussion about the tool. It costs their time and somebody else's.

Now imagine that two or three times a week. What happens to every tool that does this is always the same: somebody decides it gets in the way more than it helps, and it gets switched off. Or it stays on without blocking, which amounts to the same thing, because then nobody reads it.

And there is an even worse effect before reaching that point. When the team learns the tool is wrong often, people stop reading what it says. So when it finally gets one right, and points at a real vulnerability, that warning arrives in an inbox nobody opens any more.

Too many false positives is not just an annoyance. It is what turns a correct finding into noise.

Where the classifier is wrong, measured

The overall score is 77.3%. Except "overall score" hides almost everything that matters, because it mixes six categories that get things wrong in very different ways.

CategoryPrecisionRecall
Safe74.1%76.4%
SQL Injection100.0%80.2%
Cross-Site Scripting64.0%100.0%
Command Injection100.0%100.0%
Path Traversal100.0%57.8%
Insecure Deserialization50.8%51.6%

Recalling what each column means: precision is, when it flags, how often it is right. Recall is, of the cases that existed, how many it found.

Command Injection is spotless in both columns. SQL Injection never gets it wrong when it flags, and finds four out of five. If the detector only knew those two, this conversation would be much shorter.

The problem lives in the other two rows.

Insecure Deserialization has 50.8% precision. When it shouts there, it is a coin toss. Half the time it is accusing correct code.

Path Traversal has 57.8% recall. It almost never gets it wrong when it flags, and it lets four out of ten through.

Those are two opposite defects, and the rest of this article is about them, because each one needs a different answer. One has a fix. The other does not.

The most uncomfortable false positive

Table numbers are abstract. This one is concrete.

This snippet is safe. It is the correct way to deserialize XML, with the type chosen by your code and not by the data that arrived:

var serializer = new XmlSerializer(typeof(Report));

var report = (Report)serializer.Deserialize(reader);

The classifier looks at it and answers: Insecure Deserialization, 97.7% confidence.

Ninety seven percent. It is not in doubt, it is convinced. And it is wrong.

A necessary piece of honesty: if you paste that same snippet inside an endpoint, with the route attribute and the return around it, the classifier gets it right and answers Safe. The error happens when it receives the snippet on its own.

That does not save the model, and it is worth understanding why. In a real pull request it receives both kinds of input, and the loose piece is common: somebody changed three lines in the middle of a method that already existed, and that is all the diff shows. So the situation where it fails is not artificial, it is an ordinary day of review.

Why it is wrong precisely there

Worth understanding the mechanism, because it explains the design of the solution.

Look at the safe and the vulnerable version side by side:

var serializer = new XmlSerializer(typeof(Report));

var serializer = new XmlSerializer(Type.GetType(typeName));

As text, they are nearly identical. Same call, same shape, same length. The difference is what goes inside the parentheses.

The model learns shape. And the shape here is practically the same. What separates one from the other is not the appearance, it is where the type comes from: in one, your code decides at compile time, in the other, a piece of text that arrived from outside decides. That is meaning, not appearance.

It has no way of knowing. It never executed anything, never read the documentation, has no notion that typeof pins the type and Type.GetType does not.

Why more training does not fix it

The natural reaction is to say: then add more examples.

It helps a little, and it does not fix it, for two reasons.

The first is that the problem is about the kind of information, not the quantity. More examples teach more shapes. They do not teach the model to know what a function does. You can show it a thousand examples with typeof and it will learn that this specific token usually appears in safe code. Then somebody shows up using a generic type parameter, which does the same thing with another shape, and the model has no idea.

The second is that the real world has infinite variation. Every project has its helper, its repository layer, its way of building a query. A small model trained on your examples will run into code that looks like nothing it has seen, all the time.

The error nobody sees: what it lets through

Go back to the table and look at the Path Traversal row: 57.8% recall. Four out of every ten cases that existed were not flagged.

Those four show up nowhere. There is no alert, no comment, no line in the report saying "I looked here and I am not sure". From the outside, a pull request where the detector found nothing and a pull request where it let four things through look exactly the same.

Now look at the difference between the two errors in this article, because it defines the design of the system.

The false positive is loud. It shows up, somebody reads it, somebody complains. It is an error that presents itself.

The false negative is silent. Nothing happens. The pull request goes green, the merge happens, and everybody carries on believing the tool looked. The only way to find out is for somebody to hit the flaw later, by another route.

And there is a worse asymmetry. Claude's second opinion, which is the subject of the next article, fixes the first error. For the second one, it is of no use at all. Claude is only called when a suspicion is raised. If the classifier does not raise it, there is no second opinion, because there is nothing to have an opinion about.

So: NeuralGuard can be strict about what it flags, and it cannot promise it flagged everything. It matters that this is written down, because a security tool that gives the wrong impression is worse than none.

The way out: separate who raises the suspicion from who gives the verdict

If the classifier cannot be trusted on its own, it should not have the final word.

But it is excellent at one specific thing. Look at the recall column again in the categories where it does well: Cross-Site Scripting 100%, Command Injection 100%. There it finds the cases. The problem is what it brings along.

That is the profile of a good first filter. From a first filter you do not want precision, you want it not to let things through. Precision is the second stage's problem.

So the design is this: the classifier does not decide, it raises the suspicion. Each suspicion is sent to a second reviewer, which looks at the snippet and decides whether it is genuinely exploitable or whether the model got confused.

That second reviewer needs exactly what the first one lacks: knowing what the functions do, understanding what happens to the data, and being able to say "no, here the type is pinned at compile time, this is not deserialization of an attacker chosen class".

That is a large language model. In this project, Claude.

And it is worth spelling out what this design does not do. It fixes the excess, not the shortfall. The four out of ten Path Traversal cases that are never flagged keep going through exactly the same, because they never reach the second stage. What the second opinion buys is the right to block a merge without being wrong. It is not a guarantee that everything was looked at.

Where the threshold comes in

One piece is missing, and it is economic.

Calling the second reviewer costs money per call. Sending every snippet of every pull request would be expensive for no reason, because the overwhelming majority of lines that go into a pull request are ordinary code that the classifier does not even find suspicious.

That is why the confidence threshold exists. Only what the classifier flagged as vulnerable and with confidence above a value goes forward.

public bool DeservesSecondOpinion(Classification classification) =>
    classification.IsVulnerable && classification.Confidence >= ConfidenceThreshold;

The default value is 0.6. It controls a direct balance: lowering it makes the system find more and spend more, raising it does the opposite.

Note that the threshold only touches one of the two errors. It filters weak suspicion, so it touches the false positive. About what the classifier never even suspected, there is nothing it can do. And lowering the threshold to zero would not help either, because the threshold only sees what was already flagged as vulnerable. Whatever came out as Safe never gets to that comparison.

What the team sees in the end

Worth closing with the practical effect of that division.

The classifier raises five suspicions in a pull request. Three are correct code it got confused by, two are real vulnerabilities. If it decided on its own, the author would receive five accusations, would be irritated by three of them, and would probably ignore all five.

With the second stage, the author receives two confirmations and three explained dismissals. They deal with the two confirmations, because they trust them.

Trust in the tool is the most fragile resource in the whole system, and it is what the second stage protects.

That is also why it matters how the tool presents itself. It is a second reading of what changed, not a stamp saying the code is secure. A detector that gives the impression of having looked at everything does more damage than one that does not exist, because it switches off the attention of whoever is reviewing.

The next article shows that second stage working: how to call the Claude API from C#, how to ask for an answer the program can read without guessing, and what to write in the prompt for it to be strict about false positives.

Sources