The previous article closed the training. The model exists and knows how to classify a snippet of code.
The problem now is plumbing. A model inside a project is no use in a team's daily work. It needs to become something that runs inside the pipeline, on its own, without anybody opening Visual Studio.
.NET has a format for this, and you may never have needed it: the dotnet tool
The idea is simple. You package a console program the same way you would package a library, and whoever wants it installs it with one command. From then on there is a new command in that person's terminal.
dotnet tool install --global NeuralGuard.Cli
After that, neuralguard is a command available in any folder, as if it were git or dotnet.
To turn a console project into one of those tools, it is three lines in the project file:
<PackAsTool>true</PackAsTool>
<ToolCommandName>neuralguard</ToolCommandName>
<PackageId>NeuralGuard.Cli</PackageId>
PackAsTool says the package is a tool and not a library. ToolCommandName is the name of the command that will appear in the terminal. PackageId is the name people install by.
That is all. It is quite a bit less work than it sounds.
Except that was not all: the package that did not fit
Those three lines produce one package, with everything the tool needs inside it. And it has to be that way: NuGet does not resolve a tool's dependencies at install time, so whatever is not in the package does not exist.
For most tools that is irrelevant, because they weigh a few megabytes. Not here. TorchSharp brings libtorch, which is the actual library behind the maths, and it comes in three versions, one per operating system:
| Platform | libtorch size |
|---|---|
| Linux | 461 MB |
| Windows | 265 MB |
| macOS | 214 MB |
The package came out at 318 MB, and 1.1 GB once expanded. The NuGet.org limit is 250 MB, so it simply does not go up.
And even if it did, it would be bad. Every run in the pipeline would download the three platforms to run on one, and the GitHub runner is Linux.
The fourth line fixes it:
<RuntimeIdentifiers>linux-x64;win-x64;osx-arm64</RuntimeIdentifiers>
With it the SDK produces four packages instead of one: one per platform, and a small umbrella that points at them.
| Package | Size |
|---|---|
NeuralGuard.Cli | 28.8 MB |
NeuralGuard.Cli.linux-x64 | 190 MB |
NeuralGuard.Cli.win-x64 | 146 MB |
NeuralGuard.Cli.osx-arm64 | 126 MB |
All of them under the limit. And the install command does not change: you still ask for NeuralGuard.Cli, and the umbrella makes dotnet tool pick the package for your machine.
There is a gain I was not expecting. Installing dropped from 22 to 3 seconds, because it used to download the three platforms and now downloads one. In a pipeline that runs on every pull request, that counts.
What the tool needs to receive
Now the design question: what exactly does this tool analyze?
The most obvious road would be scanning the whole repository. It is the wrong road, for two reasons.
The first is time. Analyzing thousands of files on every pull request takes a while, and nobody waits five minutes for a check.
The second is more important: the result would be useless. If the repository already has thirty suspicious snippets nobody is going to fix today, every pull request would show the same thirty. People would learn to ignore the comment in the first week.
What matters is what is coming in now. That is: the lines that pull request adds.
Diff: the format that describes a change
The tool receives a diff. If you have never stopped to read one carefully, it is worth it, because the structure is quite direct.
--- a/src/UserRepository.cs
+++ b/src/UserRepository.cs
@@ -10,6 +10,8 @@ public sealed class UserRepository
public User? Find(string name)
{
+ var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";
+ using var command = new SqlCommand(sql, connection);
return null;
}
Line by line:
The line with +++ says which file is being changed. That is the one the tool stores so it can say later where the problem is.
The line with @@ marks the beginning of a changed chunk, and carries which line of the new file that chunk starts at. In that +10,8, the 10 is the line number. That is where the number shown in the pull request comment comes from.
Then come the code lines. The ones starting with + were added. The ones starting with - were removed. The ones starting with a space did not change, they are only there for context.
The tool looks only at the lines with +. Removed code cannot be exploited, and code that did not change is not that pull request's responsibility.
Joining lines into snippets
A single line is almost never analyzable. This one, on its own, says nothing:
using var command = new SqlCommand(sql, connection);
There is no way to know whether sql was built with concatenation or with a parameter. The information that decides is on the line above.
So the tool groups added lines that sit together into a single block, and sends the whole block to the classifier. When a non added line shows up in the middle, the block closes there and a new one starts after it.
Each of those blocks becomes a snippet, with the file and the line number where it starts.
The 500 megabyte model
Here is the annoying problem the title promised.
The detector needs two things to work: the classification head, which was trained and is 2.3 megabytes, and the CodeBERT body, which is almost 500 megabytes.
The head goes inside the tool package, no drama. The body cannot: a NuGet package with half a gigabyte would be heavy to download and the GitHub repository would not even accept the file.
The way out is for the tool to download the body the first time it runs and keep it in a local folder. On later runs, it is already there.
var path = Path.Combine(_cacheDirectory, asset.FileName);
if (File.Exists(path) && await MatchesHashAsync(path, asset.Sha256, cancellationToken))
return Result<string>.Success(path);
In the pipeline this is even better, because the folder can be kept between runs. Only the first run of the repository pays the download.
Downloading a model is a security problem
Now a point a security tool cannot handle carelessly.
Downloading 500 megabytes of executable code from a URL and loading it inside your process is exactly the kind of thing this tool exists to discourage. If somebody swaps that file, you load whatever that person wants.
The defense is pinning the fingerprint of the expected file.
A fingerprint, or hash, is a number calculated from the contents of a file. Change one byte and the number changes completely. And you cannot manufacture a different file that produces the same number.
So the expected value is written in the code, and the downloaded file is checked against it:
public static readonly ModelAsset CodeBertBody = new(
"codebert-base.safetensors",
"https://huggingface.co/claudios/codebert-base/resolve/main/model.safetensors",
"ec0b6c596d73fb5778a36d505663dca22f6bedf44c9292addf07e12f73d9517f");
If what arrived does not match that value, the file is deleted and execution stops. There is no path where the tool loads a model it does not recognize.
One more detail about that file
There is a story behind that URL worth telling, because it shows what "checking" means in practice.
The official CodeBERT is published by Microsoft, but in the old PyTorch format, which the library used here cannot read. There is a copy published by somebody else, in a format that works.
Using a third party's copy in a security tool calls for verification. So before choosing that URL I downloaded both files, the official one and the copy, took blocks of numbers from several points of the model (the beginning, a middle layer, the last layer) and searched for those blocks inside Microsoft's official file.
They all matched, byte for byte. It is the same network, just repackaged.
With that check done once, the hash pinned in the code guarantees the file stays that one, forever.
The tool's answer
What is left is defining how the tool communicates with whoever called it.
Terminal programs answer with a number, the exit code. Zero means it went fine, and any other number means some kind of problem. That is how the pipeline knows whether a step passed or failed.
NeuralGuard uses three:
public const int Clean = 0;
public const int VulnerabilityConfirmed = 1;
public const int Failed = 2;
0 means nothing was confirmed. The pull request can go ahead.
1 means at least one vulnerability was confirmed. That is the number that takes the check down.
2 means the analysis could not run. The diff file was missing, the download failed, something of that sort. Separating it from 1 matters: an infrastructure error is not the same thing as a problem in somebody's code, and treating both the same would destroy trust in the tool.
Besides the number, the tool writes a Markdown report, which is the text that will become the pull request comment.
How it looks in practice
Putting it together, local use is this:
git diff origin/main...HEAD -- '*.cs' > pull-request.diff
neuralguard --diff=pull-request.diff
That -- '*.cs' at the end of the git command limits the diff to C# files, which are the only ones the model was trained to read.
And the parameters that matter:
neuralguard \
--diff=pull-request.diff \
--backend=codebert \
--threshold=0.6 \
--block=true
The --threshold is what separates "weak suspicion" from "suspicion worth attention", and it will be the main subject of the next two articles.
What is still missing
The tool is standing, but it still has the problem the previous article measured: the classifier gets 77.3% right, and using that directly to block merges would be a disaster.
The next article faces that number head on: why a small classifier makes the mistakes it makes, which of the two kinds of error actually hurts a team, and why the solution is not more training.