Security Playbook

Gate the NuGet packages your agent adds: a lock file, the audit, and a short script for what advisories miss

An agent that adds a package changes your supply chain in one line of a .csproj. Fifteen minutes to put a gate on it, any agent.

A concrete wall with one narrow glowing doorway, a single plain box stopped on the threshold while identical boxes wait in the shadow behind it.

An agent is asked to add retry logic and comes back with a green build and a diff that includes one new line in a .csproj: a PackageReference to something with a plausible name. The build passes because the package exists. Nobody asks who published it, when, or what else came with it, because a 40-file diff has better places for the eye to go.

A package reference is the cheapest way in the whole diff to change what runs in production, and agents add them casually. Three checks catch most of what goes wrong: a lock file that makes every new package visible in review, NuGet’s built-in audit set to fail instead of warn, and a short script that reads the lock file diff and checks each new package for the things advisories don’t cover (a name that doesn’t exist, a package a fortnight old, a deprecated one).

This Playbook sets all three up for a .NET 10 repository. The technique needs an agent that can read files and run a shell, nothing more, so it works the same with Claude Code, Codex, Cursor, Copilot or a person typing dotnet add package. Everything below ran against nuget.org with SDK 10.0.114, and the output blocks are what it printed.

What the audit does and doesn’t do

NuGet Audit runs during dotnet restore and compares every package against the advisory feeds. For a project that targets net10.0 or higher, the audit covers transitive packages as well as direct ones by default (NuGetAuditMode defaults to all, per the NuGet docs). Direct-only is the default for older target frameworks.

What it reports is advisories: known vulnerabilities, with a severity. By default they’re warnings (NU1901 for low, NU1902 moderate, NU1903 high, NU1904 critical), which scroll past in a restore log. An agent that sees a warning and a passing build usually moves on.

What it can’t report is anything nobody has written an advisory for yet. A package published last week has no advisory. Neither does a deprecated library that works fine, or a package whose name is one character off the one you meant. Those need a different check, and the lock file is what makes that check possible.

Step 1: turn on the lock file

Put the settings in Directory.Build.props at the repository root so every project gets them:

<Project>
  <PropertyGroup>
    <!-- Write packages.lock.json next to each project -->
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>

    <!-- Audit direct and transitive packages, report every severity -->
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditMode>all</NuGetAuditMode>
    <NuGetAuditLevel>low</NuGetAuditLevel>

    <!-- A known advisory fails the restore instead of scrolling past -->
    <WarningsAsErrors>$(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsAsErrors>
  </PropertyGroup>
</Project>

Run dotnet restore, then commit the packages.lock.json files it writes. The NuGet docs say to check the lock file in for applications and executables, and not for libraries you ship. For a library, skip this playbook’s lock file steps and keep the audit.

The lock file lists the full closure with exact versions and content hashes. Here’s the entire file for a console app with one direct package:

{
  "version": 1,
  "dependencies": {
    "net10.0": {
      "Humanizer.Core": {
        "type": "Direct",
        "requested": "[2.14.1, )",
        "resolved": "2.14.1",
        "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
      }
    }
  }
}

Now a new package can’t hide. When a branch adds one, the lock file changes with it, and so do all the transitive packages it pulls in. That diff is the thing to check, by eye or by script.

Step 2: see the gate fail on a real advisory

On a branch, ask for a version of Newtonsoft.Json with a known high-severity advisory (12.0.3 is affected):

cd src/App
dotnet add package Newtonsoft.Json --version 12.0.3
error: NU1903: Warning As Error: Package 'Newtonsoft.Json' 12.0.3 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
info : PackageReference for package 'Newtonsoft.Json' version '12.0.3' added to file '/tmp/scratch/demo/src/App/App.csproj'.
log  : Failed to restore /tmp/scratch/demo/src/App/App.csproj (in 1.34 sec).

Two things to notice. The restore failed, which is the point. But dotnet add package had already edited the .csproj and the lock file, so the bad reference is sitting in the working tree. An agent that stops at “the command errored” leaves it there. The fix has to be part of the instruction: on an audit failure, move to a patched version or remove the reference, and say which.

A package name that doesn’t exist fails earlier and louder. Models do invent plausible package names, so the failure is worth seeing:

info :   NotFound https://api.nuget.org/v3/registration5-gz-semver2/humanizer.corre/index.json 678ms
error: There are no versions available for the package 'Humanizer.Corre'.

That one stops itself, as long as nobody has registered the name. If someone has, the restore succeeds and the build is green. That’s why the next step doesn’t treat a successful restore as proof of anything.

Step 3: check what advisories can’t see

The script below compares packages.lock.json on the current branch with the same file on a base ref, takes every package that is new, and looks each one up in the nuget.org registration API (the documented resource that lists every version of a package with its publish date, deprecation and vulnerability data). It flags a package that doesn’t exist on nuget.org, a version or a package younger than a threshold, a deprecated version, and a version with advisories. It needs only Python 3 and git, with no dependencies to install.

Save it as scripts/new-packages.py:

#!/usr/bin/env python3
"""List packages a branch adds to packages.lock.json files and check each on nuget.org.

Usage: new-packages.py [BASE_REF] [--min-version-days N] [--min-package-days N] [--trusted-prefix P ...]
Exit code 1 when any added package needs a human look.
"""
import argparse, gzip, json, subprocess, sys, urllib.error, urllib.request
from datetime import datetime, timezone

REG = "https://api.nuget.org/v3/registration5-gz-semver2/{id}/index.json"


def git(*args):
    r = subprocess.run(["git", *args], capture_output=True, text=True)
    return r.returncode, r.stdout


def packages(text):
    """{name: (resolved version, Direct|Transitive)} across all target frameworks."""
    found = {}
    for deps in json.loads(text).get("dependencies", {}).values():
        for name, info in deps.items():
            if info.get("type") == "Project":
                continue
            found[name] = (info.get("resolved", "?"), info.get("type", "?"))
    return found


def fetch(url):
    try:
        with urllib.request.urlopen(url, timeout=30) as resp:
            body = resp.read()
    except urllib.error.HTTPError as err:
        if err.code == 404:
            return None
        raise
    if body[:2] == b"\x1f\x8b":
        body = gzip.decompress(body)
    return json.loads(body)


def entries(package_id):
    index = fetch(REG.format(id=package_id.lower()))
    if index is None:
        return None
    out = []
    for page in index["items"]:
        if "items" not in page:
            page = fetch(page["@id"])
        out += [i["catalogEntry"] for i in page["items"]]
    return out


def days_ago(stamp):
    when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
    return (datetime.now(timezone.utc) - when).days


def check(name, version, args):
    versions = entries(name)
    if versions is None:
        return ["NOT ON NUGET.ORG (hallucinated or private name)"], "-", "-"
    flags = []
    listed = [v for v in versions if v.get("listed", True)]
    first = min(v["published"] for v in listed) if listed else None
    mine = next((v for v in versions if v["version"].lower() == version.lower()), None)
    pkg_days = days_ago(first) if first else None
    ver_days = days_ago(mine["published"]) if mine else None
    trusted = name.startswith(tuple(args.trusted_prefix))
    if not trusted and pkg_days is not None and pkg_days < args.min_package_days:
        flags.append(f"package first published {pkg_days} days ago")
    if not trusted and ver_days is not None and ver_days < args.min_version_days:
        flags.append(f"version published {ver_days} days ago")
    if mine and "deprecation" in mine:
        flags.append("deprecated: " + ", ".join(mine["deprecation"].get("reasons", [])))
    if mine and mine.get("vulnerabilities"):
        flags.append("has advisories")
    return flags, pkg_days, ver_days


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("base", nargs="?", default="origin/main")
    ap.add_argument("--min-version-days", type=int, default=14)
    ap.add_argument("--min-package-days", type=int, default=90)
    ap.add_argument("--trusted-prefix", action="append", default=[],
                    help="skip the age rules for ids with this prefix (deprecation and advisories still apply)")
    args = ap.parse_args()

    code, names = git("diff", "--name-only", args.base, "--", "*packages.lock.json")
    if code:
        sys.exit(f"git diff against {args.base} failed")
    bad = 0
    for path in filter(None, names.split("\n")):
        _, old = git("show", f"{args.base}:{path}")
        before = packages(old) if old else {}
        try:
            after = packages(open(path, encoding="utf-8").read())
        except FileNotFoundError:
            continue
        added = sorted(set(after) - set(before))
        print(f"{path}: {len(added)} new package(s)")
        for name in added:
            version, kind = after[name]
            flags, pkg_days, ver_days = check(name, version, args)
            mark = "CHECK" if flags else "ok   "
            bad += bool(flags)
            print(f"  {mark} {name} {version} ({kind}) package {pkg_days}d, version {ver_days}d")
            for flag in flags:
                print(f"        - {flag}")
    sys.exit(1 if bad else 0)


main()

Read it before you run it: it makes GET requests to api.nuget.org and runs git diff and git show, and touches nothing else. Run it from the repository root, with the base branch as the argument:

python3 scripts/new-packages.py origin/main

On a branch where an agent added a deprecated storage library, an old Newtonsoft.Json and an MCP SDK, it printed (long list trimmed, ages in days):

src/App/packages.lock.json: 16 new package(s)
  ok    Microsoft.Extensions.AI.Abstractions 10.11.0 (Transitive) package 753d, version 18d
  ok    Microsoft.Extensions.Options 10.0.13 (Transitive) package 3820d, version 18d
  ... (nine more Microsoft.Extensions.* rows, trimmed)
  CHECK ModelContextProtocol 2.3.0 (Direct) package 583d, version 11d
        - version published 11 days ago
  CHECK ModelContextProtocol.Core 2.3.0 (Transitive) package 515d, version 11d
        - version published 11 days ago
  ok    Newtonsoft.Json 13.0.3 (Direct) package 5775d, version 1333d
  CHECK WindowsAzure.Storage 9.3.3 (Direct) package 5442d, version 2896d
        - deprecated: Legacy

That run used --min-version-days 30 --trusted-prefix Microsoft. --trusted-prefix System.. Without the trusted prefixes, ten Microsoft.Extensions packages were flagged purely because they’d shipped a servicing release 18 days earlier, which is the wrong kind of noise: a high-volume publisher whose packages you already depend on. The prefix list skips the age rules for those ids only. Deprecation and advisories are still checked for them.

Notice what the output shows that the audit didn’t. The ModelContextProtocol reference arrived with a second package nobody asked for (ModelContextProtocol.Core) and a pile of Microsoft.Extensions packages. The script lists transitive additions on purpose, since a malicious dependency is more likely to arrive underneath a respectable one than as a direct reference.

The age rules are a prompt to look, not a verdict. A version eleven days old from a long-lived package is normal; whether you want to be on it is your call. The flagged lines tell a reviewer where to spend the thirty seconds.

Step 4: stop the lock file drifting

A lock file only helps if CI refuses to build without it. Locked mode makes restore fail when the project’s references and the lock file disagree:

dotnet restore src/App --locked-mode

If someone edits a .csproj by hand and forgets to regenerate the lock file, the restore errors out. Here’s the message (trimmed):

App.csproj : error NU1004: The package references have changed for net10.0. Lock file's package references: Humanizer.Core:[2.14.1, ), project's package references: Humanizer.Core >= 2.14.1, ModelContextProtocol >= 2.3.0, Newtonsoft.Json >= 13.0.3, WindowsAzure.Storage >= 9.3.3. The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. ...

This closes the gap where a reference is added in the .csproj and the lock file never shows it, so the script has nothing to diff. It also means every package change has to arrive as a lock file diff, which is exactly what the script reads.

Step 5: run it in CI

A GitHub Actions job that does the restore and the check on every pull request. This one is the documented shape of the settings above, not something run here:

name: package-gate
on: pull_request

jobs:
  packages:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - name: Restore in locked mode (fails on audit errors too)
        run: dotnet restore --locked-mode
      - name: Check new packages
        run: >
          python3 scripts/new-packages.py origin/${{ github.base_ref }}
          --trusted-prefix Microsoft. --trusted-prefix System.

fetch-depth: 0 is there so origin/<base> exists for git show. The restore step is where NU1903 and friends now fail the job, because the props file made them errors. Check the action versions against your own pins before using this.

NuGet’s docs also suggest keeping audit failures out of everyday builds so a newly published advisory doesn’t block an unrelated fix at 5pm. If that matters on your team, move the WarningsAsErrors line behind a condition and pass a property only from the pull request job, the way the audit docs show with AuditPipeline.

Step 6: tell the agent the rules

The gate catches mistakes after the fact. A few lines in whatever instruction file your agent reads (CLAUDE.md, AGENTS.md, Cursor rules) cut down how often it trips:

Adding a NuGet package:
1. Prefer the framework or a package already in packages.lock.json.
2. If you need a new one, run: dotnet add package <id>, then
   python3 scripts/new-packages.py origin/main
3. Do not commit if the script prints CHECK or restore reports NU19xx.
   Pick another version or package, or remove the reference.
4. In the PR description, list every new package (direct and transitive)
   with its version and one line on why it is needed.

The list in point 4 is the part that matters. In practice it forces a reason for each package, and it gives the reviewer something to hold the script’s output against.

Make it yours

Package source mapping goes further than anything above: it decides which feed each package id may come from, so an internal id can never be satisfied by nuget.org. The syntax from the NuGet docs:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="contoso.com" value="https://contoso.com/packages/" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="contoso.com">
      <package pattern="Contoso.*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

Once a mapping exists, every package, transitive ones included, must match a pattern. Worth it if you have internal packages. Not worth it if everything comes from nuget.org.

Other variations. A repo with several projects is already handled, because the script walks every changed packages.lock.json. For Node, package-lock.json has the same shape of problem and the same idea works, though the registry API is different (see the npm release-age post at /posts/npm-release-age-floor-agent-installs/).

What goes wrong

  • The script reads nuget.org only. Packages from a private feed show up as “NOT ON NUGET.ORG”, which is correct for a hallucinated name and noise for an internal one. If that’s your situation, extend check() to skip ids with your own prefix.
  • Age is a weak signal. It catches a name registered last week, and it misses a patient attacker who publishes a clean package and updates it a year later. Locked mode pins exact versions, but that’s a different guarantee from reviewing what a version contains.
  • A package with no advisories is not a package that’s safe. This is triage for human attention. It doesn’t read the package.
  • The script needs network access to nuget.org. An offline CI runner needs a mirror, and the URL at the top of the script needs changing.
  • Deprecated is only what the author set. Plenty of abandoned packages were never marked.

Don’t bother if the repository has no application to ship, or if every dependency is already vendored and reviewed. For anything that deploys, the gate costs one props file and a script.

The first 15 minutes

Add the props file, run dotnet restore, commit the lock files and the script. Then, on a branch, run dotnet add package with a package you’ve never used and run python3 scripts/new-packages.py main. Read what comes back before you read the diff.