> For the complete documentation index, see [llms.txt](https://docs.nullify.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.nullify.ai/tools/defender-action.md).

# Defender GitHub Action

The Nullify Defender GitHub Action (`nullify-platform/nullify-defender-action`) blocks known-malicious and vulnerable packages before they are installed. It reads the lockfiles in your repository, asks the Nullify vulnerability database about every locked coordinate, and fails the check when a package is malicious, when its resolved artifact does not come from the public registry, or when an advisory meets your severity policy. Results surface as GitHub annotations, SARIF (Code Scanning), a pull-request comment, and a step summary.

The action is a composite action that runs Python already present on GitHub runners. It downloads nothing and executes nothing from your dependency tree.

## Quick start

```yaml
name: Nullify Defender
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  security-events: write
  pull-requests: write

jobs:
  defender:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: nullify-platform/nullify-defender-action@v1
        with:
          api-key: ${{ secrets.NULLIFY_API_KEY }}
          api-url: https://api.<tenant>.nullify.ai
```

## Required permissions

| Permission               | Why it is needed                                       |
| ------------------------ | ------------------------------------------------------ |
| `contents: read`         | Check out the repository so the lockfiles can be read. |
| `security-events: write` | Upload SARIF results to Code Scanning.                 |
| `pull-requests: write`   | Post the summary comment on pull requests.             |

The action refuses to run on `pull_request_target`. That event pairs a pull request's lockfiles and allowlist with your repository's secrets, so a pull request could point the gate at its own exceptions. Use `on: pull_request`.

## What blocks the build

| Condition                                                                                   | Blocks                                     | Overridable                                              |
| ------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------- |
| A coordinate the database marks confirmed malicious                                         | Always                                     | Only by a reviewed allowlist entry                       |
| A coordinate the database marks suspicious                                                  | When `on-suspicious` is `block`            | `on-suspicious`                                          |
| A resolved artifact whose registry does not match the public package                        | Unless its host is explicitly accepted     | `allow-identity-mismatch-hosts` downgrades it to unknown |
| An advisory at a severity named in `policy`                                                 | When `fail-on-findings` is `true`          | `policy`, `fail-on-findings`                             |
| A coordinate the database has never seen                                                    | When `unseen` or `unknown` is `fail`       | `unseen` defaults to `fail`                              |
| A lockfile entry missing both its resolved URL and digest, where the format records them    | When `unverifiable` or `unknown` is `fail` | `unverifiable` defaults to `fail`                        |
| A dependency file declaring an extra index, find-links, no-index, or another package source | When `unverifiable` is `fail`              | `unverifiable`                                           |
| A known coordinate whose analysis is incomplete or inconclusive                             | When `unknown` is `fail`                   | `unknown`                                                |
| A clean verdict on a version younger than `min-package-age-minutes`                         | When `unknown` is `fail`                   | `min-package-age-minutes`, `unknown`                     |
| A clean verdict whose publish time the registry could not confirm                           | When `unknown` is `fail`                   | `min-package-age-minutes`, `unknown`                     |
| A coordinate in an ecosystem the database has no advisory matcher for                       | When `unknown` is `fail`                   | `unknown`                                                |
| A lockfile digest the database never compared against the registry                          | When `unknown` is `fail`                   | `unknown`                                                |
| A requirement that names no single version                                                  | When `unknown` is `fail`                   | `unknown`                                                |

`mode: warn` turns every block above into a report: the check passes, the `would-block` output is `true`, and the summary says what would have failed.

An unknown coordinate is never reported as clean. It is annotated as unverified, and counted in `unknown-count`, whichever mode is set. `unknown: warn` does not override `unseen: fail` or `unverifiable: fail`.

If the vulnerability database cannot be reached, `mode: block` fails the check. With `mode: warn`, the action warns and reports the packages as unknown; a passing step in that mode does not mean the packages were verified.

The index-lag rule fails closed for the same reason. When the registry cannot say when a version was published, the coordinate is graded unknown rather than cleared: "we could not find out" is not "it is old enough", and treating the first as the second turns a registry outage into a green build for a package published a minute ago. An ecosystem the action has no publish-time endpoint for is not affected — nothing was asked, so nothing is claimed.

## Half-answers are unknown, not clean

Three checks can come back partial, and a partial check reported as a whole one is the failure this gate exists to prevent. Each is graded unknown: annotated, counted, and blocking under `unknown: fail`.

**No advisory match attempted.** The database builds single-package manifests for Go, npm, PyPI, Cargo, Maven, NuGet, RubyGems, Packagist, Hex and Pub. If it cannot build a supported manifest for a coordinate, the response marks advisory coverage unavailable. That empty advisory list does not establish that the package has no vulnerabilities. Malware verdicts and artifact identity still apply. Database ecosystem support does not imply that this action can read every ecosystem's lockfile; see [Supported lockfiles](#supported-lockfiles).

**A digest that was never compared.** A lockfile that pins an artifact digest is asking for the bytes to be checked. When the database could not compare it — the registry was unreachable, publishes no comparable algorithm, or the request's digest-lookup allowance was spent — the artifact is unverified, not verified. `integrity-budget-exhausted` reports the last case for the whole run.

**A requirement that pins nothing.** `-r other.txt`, `-e .`, a VCS URL, `>=` or `~=` all name a dependency the gate cannot resolve to one version. They are reported rather than skipped: a `requirements.txt` of ranges that produced no coordinates at all would otherwise be a green check that inspected nothing. The same goes for a lockfile the gate cannot parse — it is reported as unsupported, never as an empty one.

## Audit trail

The action writes a local JSON decision record containing evaluated package verdicts, including clean coordinates, artifact-identity fields, and applied exceptions. The `decision-record` output identifies the file. A separate step uploads it as the `nullify-defender-decisions` GitHub Actions artifact with a requested retention of 90 days, subject to repository retention settings.

These records are not posted to your Nullify tenant. The action has no `record-decisions` input. Artifact upload is best-effort; check the upload step when you need the record for an audit.

The Nullify API token needs `config:read` for the package lookup. The action does not require `finding:write` to store this GitHub artifact. See [API Authentication](/api-reference/api-reference/authentication.md) for service-account roles.

## Supported lockfiles

`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` (Classic and Berry), `pnpm-lock.yaml`, text `bun.lock`, `requirements.txt` (pinned `==` lines), `Pipfile.lock`, `poetry.lock`, `uv.lock`, `go.sum` and `Cargo.lock`. The collector also recognises requirements/constraints filename variants and `.txt` files directly under a `requirements/` directory. TOML lockfiles need Python 3.11's `tomllib` or an installed `tomli` backport; without either they are reported as unsupported.

Binary `bun.lockb`, `Gemfile.lock`, `composer.lock`, `packages.lock.json`, `gradle.lockfile`, `mix.lock` and `pubspec.lock` are recognised but not read. Each one present is graded unknown as a whole: reported in the step summary and as an annotation, counted in `unknown-count`, and blocking when `unknown` is `fail`. A repository on those package managers is never shown a clean result it did not earn, and under `unknown: fail` it cannot pass until the format is supported.

## Inputs

| Name                            | Required | Default                        | Description                                                                                                                                                                                                                                                                                                                                                                                       |
| ------------------------------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api-key`                       | Yes      |                                | Nullify API key.                                                                                                                                                                                                                                                                                                                                                                                  |
| `api-url`                       | Yes      |                                | Your Nullify tenant API base URL, `https://api.<tenant>.nullify.ai`. The action asks its `/scpm/dependencies/query/batch` route, authenticated with `api-key`.                                                                                                                                                                                                                                    |
| `scan-mode`                     | No       | `auto`                         | `full` checks every locked coordinate. `diff` checks only coordinates new or changed against the pull request base. `auto` uses `diff` on pull requests and `full` otherwise.                                                                                                                                                                                                                     |
| `policy`                        | No       | `critical,high`                | Advisory severities that block, comma separated from `critical,high,medium,low`. Malware and artifact-identity mismatches block regardless.                                                                                                                                                                                                                                                       |
| `unknown`                       | No       | `warn`                         | `warn` reports incomplete checks; `fail` blocks on them. Also covers unsupported lockfiles and unpinned requirements. The separate `unseen` and `unverifiable` policies can still block when this is `warn`.                                                                                                                                                                                      |
| `unseen`                        | No       | `fail`                         | Block versions the database has never indexed. `warn` reports them, but `unknown: fail` still blocks unseen coordinates.                                                                                                                                                                                                                                                                          |
| `unverifiable`                  | No       | `fail`                         | Block missing artifact evidence in lockfiles and declarations of extra package sources. `warn` permits these under this policy; missing-evidence coordinates can still block under `unknown: fail`.                                                                                                                                                                                               |
| `allowlist-file`                | No       | `.nullify/defender-allow.json` | Reviewed exceptions. See below.                                                                                                                                                                                                                                                                                                                                                                   |
| `fail-on-findings`              | No       | `true`                         | Fail the action on advisories matching `policy`. Malware still fails when `false`.                                                                                                                                                                                                                                                                                                                |
| `mode`                          | No       | `block`                        | `block` fails the check on a blocking verdict. `warn` reports everything, records what would have blocked, and always passes. Use it to measure before enforcing.                                                                                                                                                                                                                                 |
| `on-suspicious`                 | No       | `warn`                         | `warn` annotates a coordinate the database marks suspicious. `block` fails on it.                                                                                                                                                                                                                                                                                                                 |
| `allow-identity-mismatch-hosts` | No       |                                | Comma-separated registry hosts your organisation deliberately installs from, such as an internal npm or PyPI mirror. A mismatch whose resolved host is listed is downgraded from a block to **unknown** and then governed by `unknown`; it is never reported as clean, because the database still could not compare the artifact against the public package. Every other mismatch keeps blocking. |
| `min-package-age-minutes`       | No       | `60`                           | A clean verdict on a version published fewer than this many minutes ago is reported as unknown, because the database may not have analysed it yet. `0` disables the rule.                                                                                                                                                                                                                         |
| `working-directory`             | No       | `.`                            | Directory whose lockfiles are checked.                                                                                                                                                                                                                                                                                                                                                            |

`resolve` and `cli-version` are accepted and ignored so workflows written for earlier releases keep parsing.

## Allowlist

An exception names the exact coordinate, a reason and an expiry. All three are required and all three are enforced: an unexplained exception is indistinguishable from a mistake, and one that never expires is a permanent hole in the gate that outlives whoever understood why it was opened. An entry missing any of them, or past its expiry, is ignored with a warning.

```json
{
  "allow": [
    {
      "package": "npm/event-stream@3.3.6",
      "reason": "vendored fork audited 2026-09, SEC-1234",
      "expires": "2027-01-01T00:00:00Z"
    }
  ]
}
```

On a pull request the allowlist is read from the **merge base** of your branch and its target — the base branch tip when the checkout is too shallow to prove a merge base — never from the pull request head, and never with a fallback to it. That holds whatever `scan-mode` is set to. A change therefore cannot allowlist the package it introduces: land the exception first, then the dependency. Allowlisted malicious packages are still annotated and counted in `allowed-malware-count`.

Your tenant-wide package allowlist applies as well, with its own reason and expiry. Manage it through the [SCPM settings API](/api-reference/api-reference/scpm.md#package-policy) for exceptions that span repositories. Read the existing policy before updating `packageAllowlist`: the supplied array replaces the whole list. The public CLI does not expose a `nullify scpm settings` command. The decision record identifies which allowlist supplied an applied exception.

## Outputs

| Name                                                           | Description                                                                                                                                                                                  |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `findings-count`                                               | Malware, suspicious coordinates, identity mismatches and advisories at every severity.                                                                                                       |
| `critical-count` / `high-count` / `medium-count` / `low-count` | Advisories by severity.                                                                                                                                                                      |
| `malware-count`                                                | Confirmed-malicious coordinates, allowlisted ones excluded.                                                                                                                                  |
| `suspicious-count`                                             | Coordinates the database marks suspicious.                                                                                                                                                   |
| `allowed-malware-count`                                        | Malicious coordinates an allowlist entry exempted.                                                                                                                                           |
| `unknown-count`                                                | Unverified coordinates and unverified dependency-file entries, including unsupported lockfiles.                                                                                              |
| `unseen-count`                                                 | Never-indexed coordinates; a subset of `unknown-count`.                                                                                                                                      |
| `unverifiable-count`                                           | Missing artifact evidence and extra-source declarations; a subset of `unknown-count`.                                                                                                        |
| `extra-index-count`                                            | Dependency files declaring extra package sources; a subset of `unverifiable-count`.                                                                                                          |
| `too-new-count`                                                | Clean coordinates younger than `min-package-age-minutes`, counted as unknown.                                                                                                                |
| `would-block`                                                  | `true` when a blocking verdict was found, whatever `mode` did with it.                                                                                                                       |
| `decision-record`                                              | Path to the local JSON decision record. The action attempts to upload it as the `nullify-defender-decisions` GitHub Actions artifact; see [Audit trail](#audit-trail).                       |
| `identity-mismatch-count`                                      | Artifacts not served by the public package's registry.                                                                                                                                       |
| `packages-checked`                                             | Coordinates sent to the database.                                                                                                                                                            |
| `no-coordinates`                                               | `true` when no readable lockfile was found; no package was checked.                                                                                                                          |
| `integrity-budget-exhausted`                                   | `true` when the database spent its per-batch registry digest-lookup allowance, so some rows report `integrityVerified` false because their digest was never compared, not because it failed. |
| `sarif-file`                                                   | Path to the generated SARIF file.                                                                                                                                                            |

## Reporting a false positive

Every blocking annotation carries an **Evidence** link: the advisory page when the database named an advisory, your tenant's supply-chain view otherwise, and the package's own registry page as a last resort. The pull-request comment renders that link and a pre-addressed report for each blocked package, and every entry in the decision record carries the same `report_url`, already naming the coordinate, the verdict and the run.

Add a reviewed allowlist entry with a reason and an expiry to unblock the merge, then send the report. Verdicts come from the Nullify vulnerability database, so a correction there clears the finding for every repository at once.

## Examples

### Measure before enforcing

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    mode: "warn"
```

### Block on medium advisories too

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    policy: "critical,high,medium"
```

### Treat unverified packages as a failure

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    unknown: "fail"
```

### Install from an internal registry mirror

If the database reports an artifact-identity mismatch for an accepted mirror, naming that host downgrades the mismatch to unknown. `unknown: fail` still blocks that result; indexing the public package does not make the mirror artifact verified. Missing artifact evidence or extra-index declarations are separately governed by `unverifiable`.

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    allow-identity-mismatch-hosts: "npm.internal.example,pypi.internal.example"
```

### Scan a subdirectory

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    working-directory: "packages/backend"
```

### Advisory-only mode

Advisories are reported without failing the workflow. Malware and identity mismatches still fail it.

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    fail-on-findings: "false"
```

### Use outputs in later steps

```yaml
- uses: nullify-platform/nullify-defender-action@v1
  id: defender
  with:
    api-key: ${{ secrets.NULLIFY_API_KEY }}
    api-url: https://api.<tenant>.nullify.ai
    fail-on-findings: "false"

- name: Report
  run: |
    echo "Packages checked: ${{ steps.defender.outputs.packages-checked }}"
    echo "Unverified: ${{ steps.defender.outputs.unknown-count }}"
```

## Version checks

This guide describes the current lockfile-based action. Older documentation referred to `output-format` and `unpinned-count`; those are not part of this action's current contract. Use the declared inputs/outputs for the action revision you pin, including `unknown-count` for unverified coordinates. A DAST run is a different workflow; see [DAST GitHub Action](/tools/dast-action.md).
