Blog

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

All posts
Building the GitHub Action: the detector running on its own at every pull request
  • NeuralGuard
  • GitHub Actions
  • CI

Building the GitHub Action: the detector running on its own at every pull request

The previous article closed the part that thinks. Now the part that runs on its own is missing.

GitHub Actions: automation inside the repository itself

If you have never set one up, the basic explanation is worth it.

GitHub Actions is a system that watches your repository. When something you chose happens (a push, a pull request being opened, a time of day), it turns on a virtual machine, runs the steps you described, and turns it off.

You maintain no server. For a public repository it costs nothing, and for a private repository there is a free monthly allowance.

Two names that will show up:

A workflow is the file that says "when this happens, run these steps". It lives in .github/workflows/ in your repository.

An action is a step packaged for reuse. Instead of everyone writing the same ten lines, somebody publishes an action and the others use it with a single line. The actions/checkout that shows up in almost every workflow is one of them.

What this article builds is an action, so that whoever wants to use NeuralGuard writes very little.

Composite action: an action made of steps

There are three ways to build an action, and it is worth knowing why the chosen one is the simplest.

You can write it in JavaScript, which runs directly on the machine. You can package it in a Docker container, which works with any language but requires building and publishing an image. And you can make a composite action, which is basically a list of terminal steps grouped under a name.

Since the heavy work is already inside the dotnet tool, the action only needs to install and call. The composite serves perfectly and adds nothing to maintain.

The skeleton of the file

An action is described in an action.yml file at the root of its repository. It has two parts: what it receives and what it does.

name: NeuralGuard
description: Detects vulnerable code in a pull request diff and confirms every finding with Claude.

inputs:
  anthropic-api-key:
    description: Key used to ask Claude for a second opinion.
    required: true

runs:
  using: composite
  steps:
    - ...

The inputs are the parameters whoever uses the action can supply. The runs describes the steps.

The parameters exposed

Deciding what becomes a parameter is a design decision. Too few and the tool does not serve different contexts. Too many and nobody understands how to configure it.

NeuralGuard exposes seven:

ParameterWhat it is for
anthropic-api-keyThe access key for Claude. Required.
claude-modelWhich model reviews the findings.
backendWhich classifier raises the suspicions.
thresholdConfidence from which Claude is called.
blockWhether a confirmation takes the check down.
tool-versionWhich version of the tool gets installed.
github-tokenUsed to comment on the pull request.

Except for the key, all of them are optional and come empty by default. When they arrive empty, the tool applies its own default. That is deliberate: the default values live in one place only, inside the code, and the action does not repeat them. If a default changes tomorrow, there is no second file to remember.

The two that vary the most from team to team are threshold and block. A team starting out may want block switched off for the first few weeks, just to see what the tool says before letting it hold merges.

Step 1: preparing .NET

The first step installs .NET on the virtual machine, because the tool needs it.

- name: Set up .NET
  uses: actions/setup-dotnet@v4
  with:
    dotnet-version: "10.0.x"

That is an example of reuse: actions/setup-dotnet is an action maintained by GitHub's own team, and it handles the download and configuration.

Step 2: keeping the model between runs

This is the step that avoids a serious time problem.

As the packaging article explained, the tool downloads the CodeBERT body the first time it runs, and that is almost 500 megabytes.

The detail is that the GitHub Actions virtual machine is discarded at the end of each run. With no care taken, every pull request would download those 500 megabytes again. That is slow and wastes everyone's bandwidth, including that of whoever publishes the model.

The solution is GitHub Actions' own cache, which stores a folder and gives it back on the next run.

- name: Cache the model files
  uses: actions/cache@v4
  with:
    path: ~/.neuralguard/models
    key: neuralguard-models-v1

The path is the stored folder, which is the same one the tool uses. The key is the name of the stored bundle: if the key matches, it restores what was already there.

With that, the download happens once per repository. Later runs start with the model already on disk.

Step 3: installing the tool

- name: Install NeuralGuard
  shell: bash
  run: dotnet tool install --global NeuralGuard.Cli --version "${{ inputs.tool-version }}"

It is the same command a person would run in their terminal.

Notice the --version coming from a parameter. Pinning the version is a practice worth adopting in a pipeline: if the tool publishes a new version tomorrow, your pipeline does not change behaviour on its own in the middle of the week.

Step 4: finding out what changed

Now the part that requires understanding a detail of GitHub Actions.

The tool needs the pull request diff. And a diff is a comparison: you need the new code and the code to compare it against.

- name: Collect the pull request diff
  shell: bash
  run: |
    git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}"
    git diff "origin/${{ github.event.pull_request.base.ref }}...HEAD" -- '*.cs' > "${RUNNER_TEMP}/pull-request.diff"

That github.event.pull_request.base.ref is the name of the target branch, usually main. GitHub makes event information available in those variables.

The git fetch brings that branch, because it may not be on the machine. The git diff generates the comparison, limited to .cs files, and saves it in a temporary file.

There is a catch here worth highlighting, because it breaks the step with a confusing message: by default, actions/checkout downloads only the last commit of the repository, to save time. Without the history, git cannot compute the diff.

That is why whoever uses the action needs to ask for the full history:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

That fetch-depth: 0 means "bring everything". It is the line that generates the most confusion for anyone assembling the workflow for the first time, and it is documented in the action's README precisely for that reason.

Step 5: running the analysis

- name: Scan the diff
  id: scan
  shell: bash
  env:
    ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
  run: |
    set +e
    neuralguard \
      --diff="${RUNNER_TEMP}/pull-request.diff" \
      --backend="${{ inputs.backend }}" \
      --claude-model="${{ inputs.claude-model }}" \
      --threshold="${{ inputs.threshold }}" \
      --block="${{ inputs.block }}" \
      --output="${RUNNER_TEMP}/neuralguard.md"
    echo "exit-code=$?" >> "$GITHUB_OUTPUT"

Two things deserve explanation.

The env block is how the key reaches the tool. It becomes an environment variable only in that step, and that is where the Claude client finds it. The key never appears on the command line, which matters because command lines tend to be recorded in logs.

The set +e switches off the default behaviour of interrupting the step as soon as a command fails. That is deliberate: when the detector confirms a vulnerability, it answers 1, and the step would stop right there. But the pull request comment still has to be published, which is exactly the moment the person most needs to see the result.

So the exit code is stored in a variable, and the decision to fail is left for the end. The subject of the next article.

How it looks for whoever uses it

All the work above exists so that the workflow of whoever installs it is short:

name: NeuralGuard

on:
  pull_request:
    branches: [main]

permissions:
  contents: read
  pull-requests: write

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: rafaelarantes/NeuralGuard.Action@v1
        with:
          anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}

The permissions block is worth explaining, because it is about security.

By default the workflow receives a token with broad permissions. Declaring permissions reduces that to the minimum needed: reading the code and writing on pull requests. If somebody managed to influence what runs in that workflow, the damage is limited to what those two permissions reach.

And secrets.ANTHROPIC_API_KEY is where the key is stored. Secrets are configured in the repository settings, are kept encrypted, and GitHub hides their value if they accidentally show up in the output of some step.

One case where this does not work

Worth knowing before somebody finds out the hard way.

In a public repository, a pull request opened from a fork does not receive the secrets. It is a GitHub protection, and it makes complete sense: without it, anybody could open a pull request whose code prints your key into the log.

The practical effect is that the Action runs, does not find the key, and the check fails without the author of the pull request understanding why.

There is a trigger that gets around it, pull_request_target, and it is exactly what you do not want here: it runs with the secrets available against code that came from outside. In a project that exists to analyze untrusted code, that is the wrong trade.

So the honest reach of this Action is: pull requests from people who already have write access to the repository. That covers an internal team, which is the scenario of this series. For outside contribution the design would have to be another one, with the paid call happening outside the pull request runner.

What is missing

Now the analysis runs on its own at every pull request, and the result is in a file inside the virtual machine, which will be destroyed in a few seconds.

The next article solves that: publishing the result as a pull request comment, and turning a confirmation into a check that holds the merge.

Sources