Changelog

What's new in Nox.

Every release, taken verbatim from the project's own CHANGELOG.md — including the ones that explain what was previously wrong. Nox is a security tool; a defect in it is a defect in your coverage, so the notes name what broke and what it meant rather than saying "various fixes".

Latest v1.55.2 Oct 28, 2026
Signed release artifacts →
brew upgrade felixgeelhaar/tap/nox
nox version   # 1.55.2

Since 1.6

The changes worth an upgrade.

The full history is below and it is long. These are the six changes that alter what Nox catches, or what it will now tell you it missed.

1.7.0

A 21-language taint engine

SAST went from a three-language proof of concept to deterministic dataflow across 21 languages — SQL injection, command injection, XSS, path traversal, unsafe deserialization, SSRF — with interprocedural taint inside a file. Measured precision rose from 0.30 to 1.00 on a labeled corpus, by fixing false-positive classes in the engine rather than by curating the corpus. No tree-sitter, no CGo, still one static binary.

1.11.0

Scans report what they could not check

Graceful degradation is only safe if it is visible. An OSV outage, a dead plugin, an unparseable lockfile or a corrupt baseline used to produce a clean exit 0 — indistinguishable from a real pass. Every incomplete check is now recorded in findings.json under meta.degradations, printed to stderr even under --quiet, and --fail-on-degraded makes CI treat "could not check" as failure.

1.10.0 – 1.12.0

Dependency scanning rebuilt

Go versions now come from go.mod — what Minimal Version Selection actually chose — not from go.sum, which hashes every version the resolver ever considered. Advisories are matched per package rather than per module, and real CVSS v3 base scores are computed from OSV vector strings, so a critical dependency CVE can finally trip a critical gate. yarn, pnpm and poetry lockfiles are parsed. One repository went from 376 findings to 19 with every in-build module retained.

1.11.0 – 1.13.0

The plugin trust boundary, hardened

Two adversarial audits drove it: the plugin↔host gRPC channel is authenticated per launch and bound to loopback, plugin binaries are re-verified against an install-time digest before they run, fingerprints are derived host-side so a plugin cannot suppress another plugin's findings, and the trust policy is enforced on install, update and .nox.yaml auto-install alike. Archive extraction is bounded against decompression bombs.

1.12.1 – 1.12.2

Secret detection that stays quiet

129 vendor rules whose pattern was only a character class and a length fired on ordinary code — comments, identifiers, JSON values — because a single mention of a vendor anywhere in a file armed them. They now require the keyword near the match and credential-grade entropy and shape. On nox's own source, SEC-* findings at high severity dropped from 45 to 12 with no loss of recall.

1.13.4

Alerts GitHub can actually sort

SARIF rule descriptors now carry security-severity, so Code Scanning can classify, filter and write alert rules against Nox findings. Info is deliberately unscored rather than sent as 0, which GitHub renders as "low" and would overstate it.

1.55.x 3 releases · Oct 2026

v1.55.2

  • Fixed
  • Changed

The remediate action (actions/remediate) and its Marketplace copy, nox-hq/nox-remediate-action, are one action again, published from here on every release. The CLI is unchanged from 1.55.1.

Fixed

  • actions/remediate and nox-hq/nox-remediate-action had drifted apart since July. The published action lacked findings-file, so nox advisories --prepare-findings could not drive it, and still installed nox through the v1.38.2 install action. This copy lacked upgrade-actions, and with --actions it reported no updates when only action pins needed upgrading: the package pass prints nox fix: no eligible upgrades found. above the action plans, and that line was the signal. Work is now detected by plan: lines. Both copies now hold the union. Checked with the released 1.55.1 binary on a fixture: package-only, package and action, and action-only plans each report updates correctly, findings-file is honoured, and the script runs under bash 3.2 and 5 (#924).

Changed

  • nox-hq/nox-remediate-action is now released in step with nox. After each nox release its sync workflow copies actions/remediate at that release’s commit, pins the install action to the same commit, runs a self-test against a fixture, then tags the same version (vX.Y.Z) and moves v1. Edit the action here; the published repository is a copy. Its versions jump from v1.0.3 to the nox version and stay in v1.

v1.55.1

  • Changed

nox-core v0.11.2: PyPI advisory ranges are ordered under PEP 440. Dogfooded against NOX Intelligence with the same pin.

Changed

  • nox-core v0.11.2. PEP 440 spells a prerelease without a separator (1.11a1), and the shared version comparator read that segment as 0, so a PyPI range starting at a prerelease covered every earlier minor release. NOX Intelligence applied GHSA-337x-4q8g-prc5 (django 1.11a1 to 1.11.18) and 11 other advisories to django 1.4.0 from its mirror; v0.24.6 fixes that with this version. In the CLI the comparator chooses the “fixed in” version when an advisory names one fix per release branch: PyPI bounds are now ordered under PEP 440 there too, and outside PyPI a bound that can only be read by zeroing a lettered segment is no longer trusted. Measured on 2,589 real (PyPI package, version, advisory) hits from live OSV, no reported fix version changes; this is the same rule the service now uses, not a behaviour change you should see.

v1.55.0

  • Changed
  • Fixed

A severity gate can no longer pass on a finding whose severity was never fetched, a worktree under an ignored directory is no longer scanned twice, and constrained required plugins are no longer reported as skipped. Dogfooded against NOX Intelligence main (93/93, including a real CLI scan answered from the mirror with the same 42 dependency findings as the live baseline).

Changed

  • nox-core v0.11.1. It changes only Record.Decide, the version-list matching NOX Intelligence uses to answer from its OSV mirror (PyPI lists now compare under PEP 440, so 1.4 equals 1.4.0; a near-equal entry elsewhere is undecidable and goes to live OSV). The CLI does not call it; no scan result changes.

Fixed

  • A severity gate (fail_on: high, nox ci) now fails when an advisory’s details could not be fetched from either source, where it used to pass silently. Dependency scans find advisories in one request and fetch each one’s severity, summary and affected ranges in a second; when that second request failed, the finding kept a default severity and could sit below the gate unnoticed. nox-core v0.11.0 retries the detail fetch twice on a transport error, a 5xx or a 429, fills a record one source could not hydrate from the same advisory on the other (NOX Intelligence or OSV.dev), and only when neither can supply it reports a blocking advisory_detail degradation naming the advisories. The finding itself is always reported; only its severity is in doubt. The scan summary, nox fix verification and the ISO 27001 report (nox compliance) treat the new kind like osv_lookup: the dependency check did not fully complete.
  • A linked git worktree under a directory the repository .gitignores (.claude/worktrees/<name>/) is no longer scanned when the parent’s index names it. git ls-files --recurse-submodules expands a gitlink registered in .gitmodules into the worktree’s files, which the tracked-file override then treated as tracked, so every finding of the real tree was reported twice under a path no waiver could stay ahead of. Linked worktrees (.git file pointing into .git/worktrees/) are now pruned under an ignored directory; submodules the parent tracks are still scanned. nox fix --actions also stops rewriting action.yml files inside nested worktrees. The behaviour for nested repositories is documented in docs/usage.md (#830).
  • The [degraded] N installed plugin(s) are not listed in plugins.required and did not run advisory no longer lists plugins you did require. An entry with a version constraint (nox/reachability@^0.9.0) was compared to the installed name (nox/reachability) as a raw string, so every constrained plugin was reported as skipped while it ran, and the count was inflated to match. The list and count now hold only installed plugins that are not required (#829).
1.54.x 1 release · Oct 2026

v1.54.0

  • Changed

Dependency scans get faster answers from NOX Intelligence without giving up verification. A verified scan now tells the service it can tell a lagging corpus from a withheld advisory, so the service may answer from its local OSV corpus instead of calling OSV live on every lookup; anything the corpus lacks because it changed after the last sync is restored from OSV.dev without an alarm, and anything older is still reported as suppression. Releases now publish a Homebrew cask and pin GoReleaser.

Changed

  • Releases now publish a Homebrew cask (Casks/nox.rb) instead of a formula, ending the GoReleaser brews deprecation warning, and pin GoReleaser to exactly v2.19.0 rather than the floating ~> v2 so a release can no longer change behaviour because GoReleaser did. The cask strips the macOS quarantine attribute after install because the binaries are not Apple-notarized. The manual Publish Homebrew Formula workflow is now Publish Homebrew Cask and renders the same cask GoReleaser emits. brew install felixgeelhaar/tap/nox is unchanged. The old Formula/nox.rb in the tap is left in place until the first cask release has been verified.
  • Dependency scans now declare X-Nox-Client-Capabilities: mirror-lag to NOX Intelligence automatically, with no configuration. It is sent only to the intelligence service (never OSV.dev) and only when verification against OSV is on (scan.intelligence.verify_against_osv, the default): the header means “I verify against a reference and restore what a lagging corpus lacks”, so with verification off nothing would restore an advisory published since the service’s last sync and such scans keep getting live answers. nox-core v0.10.0 can tell a mirror’s sync lag from withholding: a reference advisory changed after the mirror’s mirror_synced_at is restored without raising intel_suppression, while anything older, or any missing record with no usable sync time, is still reported as suppression. This lets the service answer from its local OSV corpus.
1.53.x 1 release · Oct 2026

v1.53.0

  • Added
  • Fixed

Every check nox runs now answers a question it used to get wrong or leave to you. A package.json without a lockfile is read instead of scanning as “0 dependencies, no issues” (the most common shape of an AI-built app); fix targets clear every advisory instead of stopping at a version that is itself vulnerable; an authorization lead becomes a two-account BOLA test plan; optional checks are turned on by what they check (nox enable api-abuse), never by plugin name; npm fixes can merge on evidence that the project references the vulnerable export; generated CI pins nox by commit SHA; and attack evidence from your own CI can be contributed to NOX Intelligence, attributed to your organisation, under separate terms (attack-2026-10). Version comparison now uses every segment and OSV’s explicit version lists (nox-core v0.9.1).

Added

  • nox attack plan (and the MCP attack_plan tool, which shares the same core plan) now turns object-level authorization leads and AUTHZ-001 findings into AUTHZ-BOLA hypotheses: the route, the parameter that names the object, the missing-ownership-check reasoning, and a two-account test procedure with the secure and vulnerable responses stated up front. Leads in findings.json now carry id_source. The scenario is plan-only: nox attack run reports it as not executed and never scores it PREVENTED or CONFIRMED.

  • npm: nox now answers whether project source references an export that NOX Intelligence’s analysis of the fix lists as changed (core/depimports JSSymbolUse). It reads every ordinary import idiom: ESM named, aliased, default, namespace and mixed clauses (multi-line, export-less, TypeScript import x = require()), CommonJS whole-module, destructured, renamed, member-bound and inline require('m').f(), bracket access, destructuring a whole-module binding, and dynamic import(). Comments, strings, import type, relative and node: specifiers, same-named properties of other objects, object keys, shadowing declarations and test, spec, mock and fixture paths do not count. A hit raises the finding to call_reachable with the file and line as the witness, so the default advisories.remediate gate can now merge an npm fix. The evidence is a reference, not a call path from an entry point, and the finding and the advisory reason say so (affected_symbol_evidence: source_reference). It only raises: no hit leaves the verdict exactly where it was, and it never produces not_impacting.

  • nox attack run|replay --contribute --candidate <fingerprint> sends each executed run’s allowlisted evidence (nox-core RunReport: no target, route, payload or response; a one-way trace digest) to NOX Intelligence as reporter-scoped evidence. It needs NOX_INTEL_TOKEN, separately accepted attack terms (nox intel consent attack) and refuses before any attack fires. Runs that were not executed, had an unsound control or never reached the target are skipped, not reported as prevented. nox intel allowlist attack prints the fields; nox intel forget --attested erases your organisation’s contributions.

  • scan.intelligence.attested: true sends scan observations as your organisation (NOX_INTEL_TOKEN). Anonymous contributions are stored but no longer count as independent sources, because a reporter id is self-asserted; attested ones count once per organisation. With no token nothing is sent and the scan says so.

  • nox enable <check>... | all turns on optional checks by what they check, not by plugin name, and writes .nox.yaml for you (comments and layout kept; the result is read back the way a scan reads it and undone if it does not round-trip). nox enable --list shows each check and whether it is on. The assessment now offers that one command with the exact checks instead of asking you to edit plugins.required. Nothing is enabled by default: docs/plugin-defaults.md measured every installed optional check and none meets the precision bar. Network-needing checks stay opt-in and say so; live checks are refused (use nox attack plan).

Fixed

  • A JavaScript call-reachable finding (affected_symbol_site) recorded the absolute path of the source file on the machine that ran the scan (/Users/.../src/use.js:4) in findings.json, SARIF and the assessment text. It is now the path inside the scanned tree (src/use.js:4), so output no longer differs between checkouts of the same commit.

  • The nox scan assessment named an upgrade target that could itself still be affected: it said Upgrade axios 0.21.1 -> 0.33.0 while nox fix plan (and the MCP fix_plan tool) said 0.34.0, because 0.33.0 carries two advisories the installed version does not. The assessment now asks the advisory database about its targets, as nox fix does, so the two agree. --offline, --no-osv and scan.osv.disabled still make no request and the plan is built from the findings alone.

  • Version comparison read only three numeric segments, so 6.6.0.7 and 6.6.0.3 compared equal and four-part versions (NuGet, Maven) could be judged not affected. nox-core v0.9.0 compares every segment and adds OSV’s explicit versions lists, which decide affectedness before ranges.

  • nox install-ci pinned nox-hq/nox to the release tag, which nox’s own IAC-013 flags HIGH on the workflow it had just written. It now writes the release commit SHA with the version as a trailing comment (@<sha> # v1.52.0), taken from the commit embedded at build time, so there is no network lookup. A build that records no commit refuses and asks for --nox-ref <sha>; an explicit tag still works but warns.

  • nox intel evidence --upload now sends the evidence_kind (and control_sound when the run executed) that NOX Intelligence derives a verdict from. The service no longer trusts the uploaded exploitability, so without a kind every run, however well earned, was recorded as INCONCLUSIVE. dynamic_exploit is sent only for a deterministic oracle whose violation reproduced; a model’s judgment is semantic; a deterministic run that did not reproduce is heuristic. Older releases upload as INCONCLUSIVE (the claim is kept).

  • A project with a package.json and no lockfile scanned as 0 dependencies / dependencies (none found) / No issues found in what was checked, although requirements.txt and go.mod without lockfiles were read. package.json dependencies, optionalDependencies and devDependencies are now inventoried and looked up. Exact versions are checked as written; a range (^, ~, >=, 1.x, ||) is checked at its lower bound, as requirements.txt already does, and its findings carry declared_spec, medium confidence and a message saying so. git/URL/file:/workspace: specs, dist-tags, * and upper-bound-only ranges are never guessed: they are listed under “Not checked”. peerDependencies are not read. A lockfile in the same directory or an ancestor stays authoritative (deduplicated by name), and node_modules manifests are ignored. The assessment now says “No lockfile is committed beside a package.json” and recommends committing one, and reads “declared, but none could be resolved” instead of “none found” when nothing could be resolved.

  • Fix planning chose a target that was itself still vulnerable. It took the highest fix named by the advisories affecting the installed version, but an advisory introduced later does not affect the installed version and does affect the target: axios 0.21.1 planned to 0.33.0, which two advisories (fixed in 0.34.0) still cover, and only re-verification noticed. With the advisory database reachable, nox fix, nox fix plan, the assessment and the MCP fix_plan tool now ask what each candidate is still vulnerable to and move to the lowest version that clears them all. When only a major bump clears them, the move stays held and names what would remain in policy and the version that clears it (needs --include-major); an unreachable database marks the move unverified instead of clear. Semver policy is unchanged.

1.52.x 1 release · Oct 2026

v1.52.0

  • Added

The NOX Intelligence feeds are verified and used by default. nox now ships the official feed public key (slopfeed-key-v1, raw-key fingerprint sha256:168327fe…51fa08), so the slopsquat feed and the MCP advisory feed (MCP-026) are consulted when intelligence is enabled and the scan is online. A project’s own pinned key still wins; a served key that differs from the built-in one is reported with both fingerprints and never accepted; offline scans make no request and say why the feeds were not checked. Tests are now hermetic: no test reaches a non-loopback host or reads the real ~/.nox.

Added

  • The official NOX Intelligence feed key (slopfeed-key-v1, fingerprint sha256:168327fe999925cd025ddeaf1d1cde605dfd0641395cf021da3259c8b451fa08) is built into nox, so the predictive slopsquat feed (SLOP-002) and the MCP advisory feed (MCP-026) are verified and used by default whenever the scan is online and scan.intelligence is not disabled. A configured signature_key_path always overrides it; findings record which key verified them in metadata.feed_key. A feed that fails verification is a blocking degradation, never trusted. If the service signs with a different key than the built-in one, nox reports both fingerprints and says to upgrade or pin the new key after review; it never accepts it silently. Opt out per feed with scan.slop.disabled / scan.mcp_feed.disabled. Offline and intelligence-disabled scans make no request and say the feeds were not checked.
1.51.x 1 release · Oct 2026

v1.51.0

  • Added
  • Fixed

Affected-symbol intelligence, MCP advisories and wider route coverage. Advisory relevance can rise to “a call path reaches it” from OSV’s own Go symbol lists and NOX Intelligence’s patch-diff symbols, which can never lower it. NOX Intelligence’s signed MCP advisory feed is checked against configured MCP servers (MCP-026), and MCP configs are read in each client’s own format (VS Code, Zed). NestJS controllers and DRF @action routes join the route table behind AUTHN-001, WEBHOOK-001 and nox model, which now names the reader that found each route. Requires nox-core v0.7.0.

Added

  • Affected symbols can raise an advisory’s relevance, never lower it. NOX Intelligence’s nox_symbols (and OSV’s own imports[].symbols) list the functions an advisory or its fix concerns. When the Go call graph finds a concrete call path from this module to a listed package-level function, the finding’s relevance rises to “a call path reaches it” and the sentence names the symbol and where it came from (the advisory, or nox’s analysis of the fix, which is a hint about where the fix landed and not proof). A listed symbol that nothing calls changes nothing: precision of the lists is about 0.3 to 0.5, so absence is not evidence, and a publisher’s statement that the build does not link the affected package is not overridden. Methods and non-Go ecosystems are not matched (nox has no call graph for them). Needs nox-core with nox_symbols decoding.
  • MCP advisory feed consumer (MCP-026). NOX Intelligence’s signed feed of public advisories for MCP packages is joined with the MCP servers a scan finds: “an advisory affects this MCP server package” when an exact pinned version lies inside an interval the advisory publishes, with the feed’s basis shown (a package classified by advisory text only is low confidence). A server launched through npx/uvx with no pinned version cannot be range-matched and is reported at info severity as exactly that. The feed is verified against a pinned public key (scan.mcp_feed.signature_key_path) with the slopsquat feed’s discipline and fails closed; it is cached and works offline from the verified cache. A feed the service has not published, a missing key, or an offline scan with no cache is a mcp_feed degradation saying the servers were not checked, never “no advisories”. See docs/mcp-advisory-feed.md. The package-runner parsing MCP-025 uses moved to core/mcpconfig so both read a launch the same way.
  • MCP configs are read by the client that owns them. .vscode/mcp.json keeps its servers under servers, not mcpServers, so VS Code’s project config showed up as one opaque component with no servers, no tool matrix, no pin and no shadow check. The key is now chosen from the path (VS Code servers, Zed .zed/settings.json context_servers, everyone else mcpServers), never from the content: a servers object in unrelated JSON is not an MCP config. Zed’s command: {path, args} form is read too.
  • NestJS controllers and DRF @action routes join the route table. NestJS @Controller/verb decorators (path forms, global prefix, URI versioning) are read, with @UseGuards, APP_GUARD, useGlobalGuards and @Public-style decorators counted as authentication, scoped per application directory; a controller outside any guarded scope is silent rather than a lead. DRF @action routes are routes of their own (url_path, detail), and an action’s permission_classes no longer stands in for the viewset’s. Measured on brocoders/nestjs-boilerplate, the nest samples, the realworld app, readthedocs and netbox: no new false leads.

Fixed

  • nox model names NestJS and DRF action routes, and its entry-point limit matches the readers. NestJS routes are “read by the NestJS reader”, DRF @action routes by the Django reader, and the limit text no longer says Next.js, Django or NestJS are not enumerated.
  • nox model names the reader that actually found a route. Every JavaScript route was “read by the authorization reader (Express)” and every Python route “(Flask or FastAPI)”, so Next.js and Django routes carried the wrong name. A route now carries its reader (reader in findings.json routes: Express or Fastify, Flask, FastAPI, Next.js, Django) and the model’s evidence says so.
1.50.x 1 release · Oct 2026

v1.50.0

  • Added
  • Changed
  • Fixed

The intelligence and trust release. Contribution to NOX Intelligence now requires accepting versioned terms on the machine (nox intel consent), and nox intel forget erases a reporter and rotates its secret; existing installations with scan.intelligence.contribute: true pause until consent is given. Scans keep working through an OSV outage on the service’s mirror and say so, report partial intelligence answers as incomplete rather than clean, and merge one vulnerability published under two ids into one finding (baselines keep matching). The default scan reports routes with no visible authentication (AUTHN-001 leads; Express, Fastify, Flask, FastAPI, Next.js, Django) and webhooks accepted without signature verification (WEBHOOK-001), and its headline counts what needs attention. nox fix no longer runs package install scripts and keeps the project’s version specifiers; MCP rug-pull pins are a committed project file instead of hidden machine state.

Added

  • nox intel forget rotates the reporter salt once the service confirms erasure, so consenting again is a new, unlinkable reporter instead of the erased one.

  • A scan keeps working through an OSV outage, and says how it was answered. The NOX Intelligence source now sends X-Nox-Accept-Degraded: mirror (never to OSV.dev), so during an OSV outage the service answers from its mirror instead of failing with 503. That answer is a blocking intel_incomplete degradation and a specific limit in the assessment (“Answered from NOX Intelligence’s mirror; live OSV unavailable”). findings.json advisory metadata gains advisory_source, advisory_via (live, mirror, candidate) and, for a mirror, advisory_mirror_synced_at; a live retrieval time is deliberately not written, so the file stays identical between runs. Records the service marks nox_same_as (one vulnerability under two ids) are one finding that keeps every id in aliases, at the highest severity of the group. Note: the vuln_id of such a finding can differ from the earlier per-record findings, so a baseline entry for the dropped id reads as fixed. Requires nox-core v0.6.0.

  • Next.js and Django routes join the route table. Next.js route handlers (app/**/route.ts, pages/api/**) and Django (urls.py with include() prefixes, function and class views, DRF routers and viewsets) are read into the table nox model, AUTHN-001 and WEBHOOK-001 use. Authentication is read from getServerSession/auth()/currentUser(), auth wrappers on the export, login_required, the Django mixins, permission_classes, a project-wide DRF DEFAULT_PERMISSION_CLASSES, LoginRequiredMiddleware, and from what a dispatcher view calls. Declared public (AllowAny, authentication_classes = [], @login_not_required, IsAuthenticatedOrReadOnly for reads), a custom auth middleware or middleware.ts it cannot evaluate, and a view it cannot find are never leads. AUTHZ-001’s object-level analysis does not run over these handlers, and “Checks completed” now says so. NestJS is still not read.

  • nox advisories as an MCP tool (advisories, read-only). It serves the decision nox advisories computes; the rendering and the “scan incomplete” rule moved into core/advisory, so the CLI and the tool cannot disagree.

  • Registry freshness. nox plugin search and info warn when the installed plugin is newer than the index’s highest version, and when the index’s generated_at is more than 14 days old. LATEST is now the highest version in the index, labelled as such (it was the last one listed).

  • nox scan --help documents SOURCE_DATE_EPOCH, and a test pins two scans with it set to byte-identical findings.json, SARIF, both SBOMs, ai.inventory.json and the HTML report.

  • Contribution is consented to, versioned, and erasable. nox scan now contributes (when scan.intelligence.contribute: true) only on a machine that has accepted the contribution terms: nox intel consent show prints them, nox intel consent accept records the accepted version in ~/.nox/intel-consent.json (per installation, beside the reporter salt; a committed .nox.yaml cannot consent for whoever scans it). Observations are sent as contribution schema 2 with consent_version and a deletion_verifier (SHA-256 of a key derived from the private salt and never sent). nox intel forget posts the key to /v1/reporters/erase, prints the counts the service reports, and withdraws consent locally; if the service cannot be reached or refuses, nothing changes locally. nox intel preview prints the terms and the exact fields. Behaviour change: an existing installation with contribute: true stops contributing until it runs nox intel consent accept; each scan says so, as a non-blocking degradation, and does the same when the terms version changes. With contribution off no request is made and nothing is said. The terms text (version 2026-10) is pending owner review.

  • The default scan now says when a route has no visible authentication. nox model already printed “no authentication middleware visible” for GET /api/admin/users and DELETE /api/users/:id; the scan assessment said nothing. Both now read one route table, so they cannot disagree: a route that reads or changes something an anonymous caller should not reach (an administrative path, a record fetched by id, or any DELETE, PUT or PATCH) and has no authentication in front of it or inside it is a lead under “Additional verification recommended”, in findings.json leads, and in the scan --previous / nox diff delta (“New leads”). AUTHN-001 makes each one a finding (scan.rules.enable: [AUTHN-001], opt-in like AUTHZ-001, CWE-306); without it a lead never reaches the findings array, SARIF, the exit code or a fingerprint. Express, Fastify, Flask and FastAPI, as AUTHZ-001 reads them. Authentication means middleware on the route, its router, a matching prefix or the mount; an auth decorator, dependency or before_request gate; or a handler that reads the caller’s identity or answers 401. A handler that does that is now recorded in the route table as authenticated (“an inline check on the caller’s identity”), so nox model stops calling it “none visible”. Public-by-design paths (login, health, docs, callbacks, webhooks), feeds, tests, examples, browser code and routes under an application-wide guard this reader cannot attribute (a FastAPI add_middleware, an Express router mounted where nox cannot resolve it beneath an app-wide app.use(auth)) are not leads. A route registered with a single access-control argument (app.get('/api/Users', security.isAuthorized()), .put(security.denyAll())) is a guard, not an endpoint.

  • WEBHOOK-001: webhook payload accepted without signature verification. A POST route that receives a Stripe, GitHub or Slack webhook (the provider in the path, its header, or its event types in the handler) and neither the handler, the functions it calls, nor the middleware in front of it verifies the signature is a high-severity, medium-confidence finding: Stripe constructEvent / construct_event, GitHub X-Hub-Signature-256 with an HMAC, Slack’s signing secret. A commented-out verification does not count. A handler nox cannot read is not reported. Reports by default (it is not opt-in); a gateway that verifies before the app is waived with nox:ignore.

  • The assessment’s checks line now says what was read: “authorization and authentication (Express, Fastify, Flask, FastAPI routes; leads only) and webhook signatures (Stripe, GitHub and Slack receivers in those routes)”.

Changed

  • The AI inventory binds an endpoint to the client that uses it. The first base_url in a file was recorded against every model call in it. The endpoint now comes from the construction of the client the call is made on, and is omitted when that cannot be established.

  • nox agent-graph names MCP servers. Each configured server was a box called mcp_client with a bare *. It now shows the server name, its transport (stdio or remote), and the capabilities its launch configuration shows (a shell interpreter, a filesystem or fetch server, a remote URL).

  • MCP tool annotations are complete. Every tool declares readOnlyHint and openWorldHint; baseline_add and baseline_add_many are additive and idempotent, plugin_install is destructive and open-world. docs/usage.md no longer says every tool is read-only.

  • The assessment headline counts what needs attention. “23 issues need attention” counted a privileged container and a missing LABEL maintainer alike. An issue needs attention when a finding in it is critical or high severity (the line nox ci-gate draws) or medium severity with high confidence; everything else is a lower-priority observation, shown as its own line (“Plus 16 lower-priority observations (24 findings)”) and listed under --verbose. Nothing leaves findings.json or SARIF. nox fix plan carries the same count and a needs_attention flag per item.

  • nox fix plan says what is missing. Fifteen of nineteen “needs information” items shared one sentence. Each now names the specific information (the CPU and memory limits, the probe endpoint, the maintainer contact, the image tag to pin, the signing secret…) and how to supply it, by rule, then by rule family. Dependencies say whether there is no fixed version, the ecosystem is one nox fix does not drive, or the fixed version could not be shown to be newer.

  • A default scan that looked dependencies up now prints one [network] line: how many dependencies (ecosystem, name, version) were sent to the intelligence service and the OSV reference, that no source or paths were, and how to opt out (scan.intelligence.disabled, --offline).

Fixed

  • A baseline written before duplicate advisory ids were merged still accepts the merged finding. When one vulnerability arrived as two records (a Go advisory and its GitHub advisory) they used to be two findings, so a baseline could hold an entry for either; now the pair is one finding, and an entry for the one that was folded away no longer matched, which would have turned CI red for an unchanged dependency. The merged finding now also answers to the fingerprint each absorbed record’s finding had (exactly the old message, not a guess from ids), so a different advisory or another version of the package still does not match. When it matches that way the finding carries baseline_match: alias_fingerprint and baseline_entry, and baseline update no longer prunes the entry that is doing the matching. VEX already matches by vuln id and aliases, and nox:ignore is by rule on a line, so neither changed.

  • Python SQL injection through sqlite3.connect(...).execute(...) was missed. The catalog names Python’s SQL sinks by receiver (cursor.execute, connection.execute); sqlite3.connect("x.db").cursor().execute(q) matched and sqlite3.connect("x.db").execute(q), which has no local to bind, did not, whatever the string was built with (%, +, an f-string). The inline connection is now a connection.

  • nox model no longer attaches a webhook or missing-authentication finding to a model or data store that happens to share the route’s file, and counts an authentication lead once, not twice.

  • Flask converters (<int:id>, <path:p>, <uuid:u>, <int(min=1):id>) were reported as a miss against the built binary; they were not (measured, and now pinned by a test for every converter form).

  • MCP-015 (rug-pull detection) no longer keeps its pin in hidden machine state. The pin lived in ~/.nox/cache/mcp-pins, was written by the scan itself, and was overwritten when it reported a change: after editing a server’s args, three scans gave 1, 0, 0 hits, and a fresh CI runner (no ~/.nox) could never report anything. The pin is now .nox/mcp-pins.json, a reviewable file you commit (like .nox/baseline.json), written only by the new nox mcp pin. A scan reads it, reports a changed server (high) or one that was never pinned (medium) on every run until you re-pin, and never edits it. With MCP servers but no pin file the scan reports an MCP degradation saying MCP-015 cannot run, instead of staying silent. The old ~/.nox/cache/mcp-pins cache is ignored (and named once in that degradation if it exists). Same project, same output.

  • The nox install-ci workflow no longer loses a disclosure when the Actions cache is evicted. The issue step ran only when a previous run was restored, so after the cache expired (seven days unused) the next run counted as a first run, reported nothing, and a new advisory was swallowed. The step now reports every current advisory that no existing issue, open or closed, already lists (each issue carries a hidden nox-advisory marker per advisory), so a missing cache costs a repeat of nothing and a miss of nothing. With no previous run nothing is still acted on (no pull request). The step’s shell is now executed by tests against the real nox advisories; it has not been run on GitHub.

  • ai.inventory.json and nox model attributed the wrong credential to a model. The credential was the first *_KEY/*_SECRET/*_TOKEN environment variable anywhere in the file, truncated: gpt-4o showed STRIPE_SECRET while the client was built from OPENAI_API_KEY. It now follows the client the call is made on to its constructor (apiKey: process.env.X, api_key=os.environ["X"], option.WithAPIKey(os.Getenv("X")), or one assignment away) and is omitted when that cannot be established (client built by a helper, two clients with different keys, an SDK reading its default variable implicitly).

  • nox fix no longer runs the install scripts of the packages it upgrades. It ran plain npm install pkg@version, which executes the new version’s preinstall, install and postinstall scripts: arbitrary code from a version nox had just chosen, in a tool that promises never to execute untrusted code. npm, pnpm, yarn (classic and Berry, the latter through YARN_ENABLE_SCRIPTS=false) and bun now run with scripts off. The other managers were audited the same way: pip installs wheels only (--only-binary :all:, since building a source distribution runs setup code), bundler runs bundle lock --update --conservative instead of bundle update (which compiled native extensions), composer runs with --no-scripts --no-plugins, and dotnet add package runs with --no-restore. Go and Cargo only resolve and are unchanged. nox fix now prints which steps were skipped and what to run after reviewing the packages.

  • nox fix keeps the version specifier the project wrote. An exact pin ("lodash": "4.17.15") was rewritten to a caret range (^4.18.0), widening what any later install may resolve. Exact stays exact, caret stays caret and tilde stays tilde, for npm and yarn (--save-exact, --save-prefix, --exact/--tilde/--caret); pnpm and bun already keep the style.

  • nox advisories no longer permits auto-merge on linkage alone. The default remediate gate was min_relevance: symbol_used, which for every ecosystem except Go is as far as the applicability ladder gets: the package is linked, nothing shows it is called. Severity alone therefore decided “Auto-merge permitted: true”. The default is now call_reachable; a repository that wants to merge on linkage sets advisories.remediate.min_relevance: symbol_used itself.

  • nox advisories says relevance in plain words. Text and markdown output printed relevance=symbol_used beside “whether anything calls it was not established”. They now print “linked, not shown to be called”, “a call path reaches it”, “not determined” or “shown not to affect this build”, and the merge-gate reason names the setting that governs it. JSON and .nox.yaml keep the identifiers.

  • The advisories pull request and nox fix plan agree on the target version. The pull request was planned over the advisories that cleared the gates only, so a gate that dropped one could lower the target (lodash 4.17.21 against fix plan’s 4.18.0). It is now planned over every active advisory, the way nox fix does, and the findings handed to nox fix include all the advisories that move fixes.

  • An incomplete NOX Intelligence answer is no longer read as “no vulnerabilities”. When the service marks a package’s answer incomplete or unsupported (nox_coverage, nox-intelligence v0.19.1), the scan records a blocking intel_incomplete degradation naming the packages, sets meta.advisory_lookup to incomplete, and nox diff does not compare that scan’s advisories against a full one. Records the reference restores for a package the service reported as partial are not counted as intel_suppression. A plain OSV.dev response carries no coverage statement and raises nothing. Requires nox-core v0.5.0.

1.49.x 1 release · Oct 2026

v1.49.0

  • Added
  • Changed
  • Fixed

The first-run experience and continuous understanding from the product vision (docs/vision.md, sections 15 and 17). nox scan . gives an assessment in plain language; nox fix plan/apply/verify prepare, apply and re-check fixes; nox install-ci and nox advisories handle newly disclosed advisories by a configured policy; nox compliance iso27001 and nox model produce evidence and a threat model from what the scan already knows. nox scan --previous and nox diff now say what changed: new and resolved findings, new authorization leads, capabilities an AI agent gained, and infrastructure settings that got weaker. Breaking: nox diff --json is an object, and nox diff exits 1 when something is new or worse.

Added

  • A change to infrastructure now names the security assumption it invalidated. findings.json records the deployment resources a scan read from Terraform, CloudFormation, Kubernetes, Compose and Dockerfiles under a new infrastructure key, with the security-relevant properties of each, hardened resources included (an S3 bucket’s public access block, ACL and default encryption; an RDS instance’s publicly_accessible and storage_encrypted; a security group open to the internet; a workload’s privileged, hostNetwork, runAsNonRoot, runAsUser, capabilities, seccomp, read-only root filesystem and image pinning; an Ingress’s TLS coverage and ssl-redirect; where each credential-named environment variable gets its value; a Dockerfile’s final USER and base image pin). A resource is identified by kind, namespace, name and file, never a line. nox scan --previous, nox diff and the MCP scan and diff tools gain an “Infrastructure assumptions weakened/strengthened” section (structured: change.infrastructure) listing, per resource, the properties that moved: aws_s3_bucket.uploads (deploy/tf/main.tf): public access block - was all true, now all false. The findings list is unchanged; the section is a hint like authorization leads and is not counted as a regression. An earlier findings.json without the key cannot be compared, and the comparison says so. The inventory is context, not findings: it reaches no gate, exit code, SARIF output or fingerprint, and the key is absent when no resource was read.

  • nox model builds deployment elements from that inventory, not from findings alone (schema 1.1.0). A hardened bucket, database, ingress, workload or security group is an element, so nox model --previous after the same resource is weakened reports its properties moving and its STRIDE cells changing instead of a “new data store”, “new ingress” or “new network rule” that existed all along. A resource added in a new file is still new. Elements carry properties; the delta carries properties_changed. A previous model that predates the inventory is not compared for deployment elements, with a caveat.

  • nox model, and an MCP tool threat_model: a threat model from scan evidence. Reads the scan outputs you already have (findings.json, an SBOM, ai.inventory.json) and writes model.md and model.json: the elements nox can evidence (Express routes with the authentication middleware visible on each, AI agents and their tool capabilities, MCP servers and their launch commands, models, data stores and external services from the dependency set, the ingress, workload, bucket, database and open-network-rule surface from infrastructure findings), the data flows and trust boundaries between them (“model output -> shell”, “untrusted input -> LLM prompt”, “internet -> exposed deployment resource”), a STRIDE table citing the evidence per element and category, questions for the team derived from the gaps, a Mermaid data-flow diagram, and an explicit list of what nox cannot see. A cell with no evidence says “nox has no evidence either way”; nothing is called secure, handled or mitigated, and a test bans those words from every rendering. It never scans and reads no clock: the same inputs give byte-identical output, with the SHA-256 of each input recorded. nox model --previous model.json reports new and removed elements, flows and trust boundaries and the STRIDE cells whose evidence changed, and says when a comparison cannot be made (an input missing from one side) instead of reading as “nothing changed”. One implementation, core/threatmodel; see docs/threat-model.md.

  • findings.json records the routes the authorization reader found, under a new routes key (method, path, file:line, and the authentication and authorization middleware visible on the route). They are context for a system model, not findings: they reach no gate, exit code, SARIF output or fingerprint, and the key is absent when no Express routes were read.

  • Possible IDOR leads (AUTHZ-001) now cover Fastify, Flask and FastAPI. The Express reader’s proposition (an authenticated route loads a record by a caller-supplied id and nothing visible relates the record to the caller) is now applied to the same shape in three more frameworks, with the same lead, the same counterexample classes and the same evidence standard. Fastify: fastify.get(path, { preHandler }, handler), fastify.route({ method, url, onRequest, handler }), plugins mounted with register(plugin, { prefix }), addHook('onRequest', auth), request.jwtVerify(), and TypeScript (get<{ Params }>, request.params as { id: string }). Flask: @app.route and @app.get, Blueprints with url_prefix (nested, and overridden at register_blueprint), <int:id> converters, @login_required / @jwt_required() and before_request gates. FastAPI: @app.get, APIRouter(prefix=) composed with include_router(prefix=) (a prefix held in one settings constant is resolved), path and query parameters as function arguments, and Depends(get_current_user) directly, through an Annotated alias or as router-level dependencies, read as authentication and never as ownership. Leads show the route as served (GET /api/v1/items/{id}). A lookup scoped to the caller (filter_by(id=i, user_id=current_user.id), select(...).where(... owner_id == user.id), SQL with an owner column), a post-load comparison or 403/404 against the principal, a policy call, a role read, or an authorization dependency or decorator keeps the route quiet. “Checks completed” now reads “authorization (Express, Fastify, Flask, FastAPI routes; leads only)”. Still not read: NestJS, Next.js, Django, Flask class-based views and Flask-RESTful, OpenAPI-first apps (connexion), @fastify/autoload directory prefixes and autohooks, Starlette, Sanic, Quart and aiohttp. Leads flow into findings.json, scan --previous, nox diff and MCP exactly as the Express ones do.

  • Nox now says what changed, not only what is wrong. nox scan --previous <findings.json> leads the assessment with a “Since the previous run:” section (new issues, what is no longer present, severity regressions, agents that gained or lost a tool or capability, new AI components, new authorization leads) before the usual assessment; with no --previous the output is byte-for-byte what it was. nox diff --base X --head Y is now a real delta: it scans the merge-base and the head from the object database (nothing is checked out or touched) and compares them, so a one-line lockfile change lists only the advisories it introduced, not the forty already there. The MCP scan tool takes previous (or compares with the last scan of the same path in the session) and the scan and diff tools return the same structured change. One implementation, core/change, with documented identity: a dependency advisory is manifest + package + advisory id or alias (an upgrade that leaves a package still affected is neither resolved nor new), other findings are the line-independent fingerprint (a line move is neither), a lead is route + file, an agent capability is agent + server + tool + capability. A comparison that cannot be made (an earlier run that did not record leads or the AI inventory, an incomplete or differently scoped run) is said so, never reported as “nothing changed”. findings.json records the authorization leads under their own leads and lead_checks keys, never among the findings.

  • nox install-ci pull requests now run your required checks. A pull request opened with GITHUB_TOKEN triggers no workflows, so a remediation PR’s checks never ran and its auto-merge never completed. The generated workflow now opens it as a GitHub App (secrets NOX_APP_ID, NOX_APP_PRIVATE_KEY, a short-lived token from actions/create-github-app-token scoped to contents, pull-requests and issues write), else as the NOX_TOKEN fine-grained token, else with GITHUB_TOKEN, which warns that CI will not run and does not enable auto-merge. Setup is in docs/advisory-policy.md.

  • A newly disclosed advisory gets exactly the response you configured. nox install-ci writes one workflow that scans pull requests and, on a daily schedule, scans the merged code and works out which dependency advisories are new since the previous scheduled run. nox advisories decides what to do about each, from the .nox.yaml advisories block: report (the default: job summary, SARIF, an issue), prepare (also a remediation pull request carrying only the new advisories’ upgrades, through actions/remediate) or remediate (also permits gh pr merge --auto, only when every advisory in that pull request passes the stricter gate and the package move is within max_bump; GitHub’s required checks decide whether it merges, and nothing bypasses them). Gates are min_severity and min_relevance (undetermined, symbol_used, call_reachable). Undetermined relevance is treated as possibly affecting the application and the reason says so; only a refuted not_impacting advisory is held at report. A waived or baselined advisory is never new; with no previous run, or a scan whose lookup failed, nothing is acted on and nox advisories says so. The decision is a pure function in core/advisory. actions/remediate gains a findings-file input, and remediate.sh no longer fails on bash 3.2 when no extra arguments are set. See docs/advisory-policy.md.

  • nox compliance iso27001, and an MCP tool compliance_iso27001. Reads the outputs of a scan already run (findings.json, an SBOM, ai.inventory.json) and writes iso27001.md for a reviewer and iso27001.json for tooling: for 16 ISO/IEC 27001:2022 Annex A controls, what the scan shows with counts, rule IDs, source file and SHA-256, the gaps in this run, what that evidence can never show, and a 25-item checklist of the organizational evidence a repository cannot hold (risk assessment, ISMS scope, access reviews, incident records, supplier agreements). It never states that a control is met: statuses describe the evidence (supporting_evidence, open_issues, limited_evidence, not_evidenced), and a test rejects any status or word that claims more. An empty result only counts when the outputs show something was there to check, a scan without a recorded advisory lookup is not read as “no known vulnerabilities”, and waived findings keep a control at limited. Deterministic, offline, no scan; the same outputs give the same bytes. See docs/compliance-iso27001.md.

  • nox fix plan | apply | verify: “can you fix these?” answered, and checked. plan takes every issue the assessment listed (same numbering) and says which are safe to apply, which need review and why (a major upgrade, a settings change that alters a deployed system), and which nox cannot fix and exactly what each needs (a published fix, a decision about a prompt boundary, a two-account test). apply makes the safe set (--include-major and --include-review widen it), then re-runs the checks that produced the targeted findings and reports each change as verified fixed, still present, new finding introduced, could not verify, or not applied. “Fixed” is claimed only when the check ran to completion and no longer reports the finding; an upgrade to a release the advisory still affects is reported as still present even though every fingerprint changed, and an offline run reports dependency changes as not verified rather than fixed. verify re-runs the checks after a change made by hand. --format json for CI. Built in core/remediate on core/assess and core/fix; the MCP fix_plan tool returns the same plan (plan, text) and the LSP attaches each finding’s entry to its diagnostic. MCP stays read-only: applying is a command a person runs. The bare nox fix flags, and the remediation workflow that uses them, are unchanged. The assessment’s next step now points at nox fix plan.

  • nox scan ends with an assessment instead of counts. Issues are grouped by the action that resolves them: one line per upgrade the nox fix planner would make (“Upgrade axios 1.7.4 -> 1.20.0: fixes 33 known vulnerabilities”), one per vulnerable package with no fix, one per rule for code findings with file:line, ranked by severity times confidence, top five shown (--verbose lists all). It then states which checks ran and which did not (and the one action that enables each), what the scan could not settle (reachability of dependency vulnerabilities, exploitability), the runtime tests nox attack plan would build, and the next commands. Built in core/assess from the shared core/fix planner and core/hypothesize, so other adapters can render the same account. The [tip] lines are replaced; the advisory “installed plugins not listed in plugins.required” [degraded] line moves behind --verbose when the assessment is shown. [results], [families] and blocking [degraded] lines are unchanged, and so are findings.json, SARIF and the SBOMs: this is presentation only.

  • Possible IDOR leads (AUTHZ-001) in the assessment. nox now reads Express and koa-router JavaScript/TypeScript for routes that require an authenticated caller, look up a record by a caller-supplied id (route parameter, query or body field), and show nothing that relates the record to the caller (OWASP API1:2023, CWE-639). By default each such route is a lead, not a finding: the assessment lists it under “Additional verification recommended” (“Authorization: GET /api/invoices/:id (src/routes/invoices.js:14) loads a record by a caller-supplied id with no visible ownership check. Test it with two user accounts…”), top five with the rest under --verbose, and “Checks completed” says “authorization (Express routes; leads only)” rather than claiming the check. Leads never reach findings.json, SARIF (results or rule catalogue), the exit code, gates or fingerprints: default-mode outputs are byte-identical to before. They are carried on ScanResult.Leads for every adapter. scan.rules.enable: [AUTHZ-001] makes each one a medium/low finding instead, with no duplicate lead. Routes, router and prefix use() middleware, mounts across files, and handlers in other files are resolved; thirteen counterexample classes clear a route (principal-scoped lookups, owner fields, request-scoped clients, middleware context passed to the data layer, comparisons with the principal, policy calls, 403s, role reads, authorization or loader middleware, router.param(), row-level security). Measured on 22 pinned open-source Express apps, every candidate read: deliberately vulnerable apps 18 TP / 8 FP, recall 10 of 14 documented BOLA instances; ordinary apps 12 object-level TP plus 5 missing admin checks and 0 FP, from only three apps. The FPs are all resources shared by design, which source cannot tell apart, hence leads by default.

Changed

  • nox diff reports a delta, and its --json shape changed. It used to print every finding in the files that differ between two refs; it now prints what the head introduced, what it resolved and what got worse (core/change). --json is an object (findings: the new findings in the old shape, changed_files, base, head, change: the full delta) instead of a bare array. Exit 1 now means the head made findings worse (a new finding or a raised severity); it no longer means “a changed file has a finding”. New flags --offline and --no-osv. Authorization leads are recorded in findings.json under leads and lead_checks; the findings array and SARIF are unchanged.
  • The assessment describes what was not checked in capabilities, not plugins. nox scan . on an ordinary app used to say “Optional plugins are installed but not enabled” and send the reader to pick from fifteen names. It now says what would have been checked (“API abuse patterns (rate limiting, error detail)”, “exposed endpoints and admin or debug routes”) and groups live testing (probing a running app, inspecting a cluster, adversarial prompts) into one line that points at nox attack plan. A plugin whose check core already performs (container configuration, data-flow analysis), one measured to hurt (reachability, pattern triage, pattern threat modelling) and one that only annotates are no longer offered as gaps. Nothing is auto-enabled: all 15 installed plugins were run against six real trees and none earns it (docs/plugin-defaults.md), and enabling by what happens to be installed would make findings depend on the machine. The degradation is unchanged: findings.json and --verbose still list every plugin that did not run, and --fail-on-degraded behaves as before. Plugin knowledge is in core/plugincap. Also: the generated_report degradation is worded as “Reports nox wrote itself were not scanned” instead of leaking its kind.

Fixed

  • IAC-504 reports one finding per resource, not one per switch. A aws_s3_bucket_public_access_block with all four flags false produced four identical findings (“in 4 places”). It now produces one, which keeps the rule ID, spans the flags, and names the ones that are off and the resource (aws_s3_bucket_public_access_block.invoices; a module call or a tfvars file is grouped by its block or file). Its identity follows the resource, so fixing one switch or moving the block does not turn it into a different finding. The rule was added in this release cycle, so no baseline, VEX statement or alias needs an entry. The findings it folds away are recorded as refuted in the stage accounting, with the reason.

  • MCP threat_model takes a previous model. The tool’s input schema was {}, so an agent could not ask what changed since an earlier model, though nox model --previous could. It now accepts an optional previous (the model.json of an earlier run), confined to the allowed workspace paths exactly as the scan tool’s previous is, and returns the same changes block. Both surfaces call threatmodel.BuildAgainst, so they cannot compare differently. A file that is missing or is not a nox threat model is an error, never an empty comparison.

  • findings.json routes now lists Flask and FastAPI routes, with their authentication. The route inventory held only JavaScript routes, so nox model showed Python entry points only through authorization leads, with “authentication middleware: not recorded”. Every Flask and FastAPI route is now listed, and @login_required, @jwt_required(), Depends(get_current_user) (as a parameter or a dependencies=[...] entry) and router-level dependencies on an APIRouter or include_router are recorded as the route’s authn, using the same classification the AUTHZ-001 lead logic applies; role and permission dependencies are recorded as authz, never as authentication. The threat model’s limits no longer say Python and Fastify routes are not enumerated.

  • nox compliance iso27001: a literal credential in a manifest now opens A.5.17. A literal database password in a Kubernetes env block (IAC-502) or a Compose list entry (IAC-503) left “Authentication information” at “checks ran, nothing open”, because the control read only secret scanning (SEC-*). A.5.17 now also reads every rule that finds a credential value written into configuration (IAC-022/047/130/190-192/221-223/225/235/236/254/271/343/351/ 502/503, MCP-004, AI-042), and a test fails when a new CWE-798 rule is neither mapped nor excluded with a reason. The rules added since the mapping was written are placed too: IAC-504 on A.8.12, MCP-025 on A.5.21, AUTHZ-001 and AI-AGENT-009 on A.8.28. The additions are silent when they find nothing, so a repository without those files is not downgraded. Authorization leads are named on A.8.28 as unconfirmed hints and still never counted as findings.

  • nox fix plan no longer calls a transitive upgrade “safe to apply” when a parent pins it. body-parser, path-to-regexp and qs under express 4.21.2 were listed under “Safe to apply, nothing here needs a decision”, and nox fix apply then could not move them (“a parent’s version range excludes the fix”). The plan now reads the project’s package-lock.json: each installed copy below the fixed version is checked against the declared range of every package that resolves to it (and against an existing overrides entry). If any range excludes the fix, or the lockfile is one nox does not read (pnpm, yarn, bun) or absent, the item is “needs review” (new kind held_upgrade) and names the holding parents and the exact entry to add: "overrides" for npm and bun, "resolutions" for yarn, "pnpm": { "overrides" } for pnpm. nox fix apply never selects a held item, even with --include-review.

  • --previous no longer lists every dependency advisory as new when one scan was offline. An offline scan reports no advisories because it did not look, and comparing it with an online scan (nox scan --previous <offline findings.json>, or the MCP scan tool) reported all of the online scan’s pre-existing advisories as new, and, the other way round, as resolved. A scan that did not look its dependencies up (--offline, --no-osv, osv.disabled, no dependency scope, or a failed OSV lookup) now has its advisories left out of the comparison on both sides, with a caveat in either direction (“the previous scan did not look up advisories (it was offline)…”). --no-osv previously left no trace in findings.json; it now records meta.advisory_lookup: "skipped". nox diff scans both trees with the same flags and was not affected in practice, but shares the guard.

  • nox fix verify blamed the user for the dependency directory the fix created. nox fix apply runs npm, which creates node_modules/; the next scan then read node_modules/combined-stream/yarn.lock (a lockfile that package publishes for its own development) as this project’s manifest and reported “Also new … VULN-002 at node_modules/combined-stream/yarn.lock:1”. That happens on any plain nox scan of a tree whose node_modules/ is not gitignored (measured: 3 packages in the inventory from nested lockfiles, with the project’s own lockfile already naming every installed package). A lockfile inside node_modules/ is no longer a project manifest, in the dependency scan, SBOM and licence pass alike; the directory is still scanned for secrets and code. Separately, fix verify now lists any new finding under node_modules, .venv or site-packages apart from the rest, labelled as inside a directory a tool generated and not attributed to the change (new_in_generated_dirs in JSON).

  • The project-scoped .mcp.json was inventoried as one opaque component. The AI inventory recognised an MCP configuration only when the file was named exactly mcp.json, so the .mcp.json that Claude Code reads from the repository root produced a single generic component, no servers and no tool matrix, and everything built on the inventory (the AI-BOM, nox attack plan, nox model) saw none of its servers. It is now read like mcp.json (and *.mcp.json), and each server records its launch command and arguments (never its env block).

  • Configurations that got worse used to scan the same as, or better than, the ones before them. Measured on a hardened application and the same application after a “just for debugging” commit: seven insecure changes produced no finding, and two hardened settings produced noise.

    • MCP-002 matched home-directory prefixes only, so a filesystem server rooted at / (or C:\, /etc, /var, /root, ${HOME}) scanned clean while /Users/alice/project was critical. The root must be a whole args element, so a/b and URLs do not match.
    • MCP-003 now also reads the env block: ALLOW_ALL=1, DANGEROUSLY_*, UNSAFE_*, SKIP_PERMISSIONS and the like, when switched on ("0" is the safe spelling). A server merely named shell is still not evidence.
    • MCP-025 (new): an MCP server launched with npx/pnpx/bunx/uvx/pipx and no exact package version (none, @latest, a range) fetches whatever the registry serves at every launch. MCP-015 cannot see this: the definition’s hash does not change when the package behind it does.
    • IAC-502 (new): a credential-named environment variable (PASSWORD, SECRET, TOKEN, API_KEY…) with a literal value in Kubernetes manifests and Helm values, and IAC-503 (new) for a Compose - NAME=literal entry. Moving a Secret out of valueFrom: secretKeyRef was invisible because every key-based rule (IAC-225, SEC-080) reads name: DB_PASSWORD and value: ... as two unrelated scalars. Paths (DB_PASSWORD_FILE), URLs, header names, identifiers, templates ({{ }}, ${}, $()), empty values and placeholders do not fire.
    • IAC-504 (new): Terraform aws_s3_bucket_public_access_block with any of block_public_acls, block_public_policy, ignore_public_acls, restrict_public_buckets set to false (IAC-052 is CloudFormation only).
    • IAC-302 and IAC-028 read only add: followed by a block list whose FIRST item was dangerous, so capabilities: {add: [SYS_ADMIN]} replacing drop: [ALL] was silent. Flow-style lists and any list position now match; drop: [SYS_ADMIN] and add: [NET_BIND_SERVICE] do not.
    • AI-AGENT-009 (new) and the sql_exec capability in ai.inventory.json’s tool_permission_matrix: a function-calling tool whose parameter is a SQL statement (sql, sql_query, …, or query on a tool described as SQL). The statement is the model’s output, so a prompt injection chooses it; a tool that takes an id and runs a fixed statement is not flagged. The evidence is the parameter schema, not the tool’s name.
    • IAC-135 (missing seccomp profile) fired on the container of a pod whose pod-level securityContext sets seccompProfile: RuntimeDefault, which the container inherits. A refiner now parses the document and drops the finding when the workload’s pod-level securityContext sets a profile; the rule’s firing domain and finding locations are unchanged, so baselines and waivers written against it keep matching.
    • IAC-296 (“verify TLS termination”) fired on every Ingress, including ones with a tls: block. It keeps its ID and now makes a claim that can be checked and that IAC-149 (no tls: at all) does not: an Ingress that HAS a tls block serves a spec.rules[].host no spec.tls[].hosts entry covers (a wildcard covers one label), or has a tls entry with no secretName and no cert-manager/controller annotation providing a certificate. It never fires with IAC-149, and a Helm template ({{ }}) is not evaluated.
    • IAC-144 reported valueFrom: secretKeyRef, the documented way to pass one credential and the form IAC-502 tells people to move to. It now reports envFrom: secretRef, which hands every key of the Secret to the process.
  • Which duplicate survived dedup depended on analyzer completion order. Deduplicate kept the first finding it met for a fingerprint and position, and the analyzers hand their results over in goroutine completion order, so two findings that tied on that key but differed in severity, confidence, message or metadata could swap between runs. DeduplicateFlows and SortDeterministic had the same gap (ties were left to input order), and the MCP baseline_add_many tool and the assessment’s verification list picked “the last finding with this fingerprint” from slice order. All now use one total order over findings (core/findings/order.go): the survivor is the minimum under it, and fingerprint lookups take the greatest, which is the finding they already took from a sorted result. Fingerprints, locations and per-rule counts are unchanged; kubernetes/examples scans byte-identical before and after.

  • The slopsquat feed could not be pinned with the key the docs say to make. docs/slopsquat-feed.md tells operators to pin the output of openssl pkey -pubout, a PKIX PUBLIC KEY. scan.slop.signature_key_path rejected it with “unexpected public key length: 44”, so require_signature: true could only be met with nox’s own raw key form. Both forms are now accepted. The feed format, digest, signing and the generator moved to github.com/nox-hq/nox-core/slopfeed (v0.4.0), shared with the service that will publish the feed; the CLI keeps only file, bundled and remote loading, and cmd/slopfeed is now a thin command line over it with the same flags and output.

  • DATA-012 reported every passport.js application as leaking a passport number. The pattern was case-insensitive throughout, so its value class [A-Z0-9]{6,12} matched any six-letter word: const passport = require('passport') was a HIGH finding with require as the number. Only the key is case-insensitive now; the value must be upper-case, end at a word boundary and contain a digit (US, UK), or be a nine-character German serial from the vowel-free PassV alphabet. The key must bind the value, directly or as the number field of a passport object, and the JSON key forms that were missed before now fire. DATA-010 (health record id) and DATA-011 (driver’s licence) shared the construction and the false positive (patient_id = request.args..., license_number = license.number) and get the same value check. Measured: DATA-012 6 -> 0 on hackathon-starter and 2 -> 0 on node_passport_login (every drop a require line); no change on the other 25 corpus repositories; generated real-format values still fire. hackathon-starter joins the rule-diff corpus as the witness.

  • VULN-002 reported established packages as typosquats. The ~240 hand-picked popular names per ecosystem were both the targets a typosquat imitates and the only exemption, so any established package one edit from a target was flagged: openai (openapi), jws (ws), safer-buffer (safe-buffer), gaxios, enquirer, psycopg, motor, boto and more. The roles are now split. Targets stay curated; the exemption is a pinned set of established packages (npm: the 17,330 names in npm-high-impact 1.13.0, MIT; PyPI: the top 10,000 of hugovk/top-pypi-packages 2026.10.1, CC-BY-4.0), embedded and reproduced by scripts/gen-typosquat-exemptions.py. The scan stays offline. A known-malicious name (VULN-003) is never exempt. Measured on real lockfiles, VULN-002 went 126 -> 0 on nestjs/nest v10.4.0, 19 -> 1 on npm/cli v10.9.0, 34 -> 8 on vercel/ai and 1 -> 0 on anthropic-sdk-python; historical typosquats (crossenv, mongose, colourama, python3-dateutil, djago, diango, setup-tools) still fire.

  • results.sarif differed between identical scans. The taint rule catalog kept the first sink a map iteration reached for each rule ID, and TAINT-005 is shared by code_injection and unsafe_deserialization sinks, so its descriptor (description, CWE, tags, helpUri) changed from run to run and was wrong for the other class every time. TAINT-003 (xss and ssti) had the same construction. Each shared ID’s descriptor now names every class and CWE it reports (TAINT-005: CWE-94, CWE-95, CWE-134, CWE-502), and the taint catalog’s listings are sorted. Rule IDs and fingerprints are unchanged, so no Code Scanning alert moves. nox explain classifies a finding by its own CWE before the rule’s. With SOURCE_DATE_EPOCH set, every report a scan writes is byte-identical across runs of one tree.

  • pnpm lockfiles produced phantom packages. The line-based reader took any indented key containing @ or / for a package, so every peerDependenciesMeta child became one: '@angular/core': turned into @angular at version core, in the inventory, both SBOMs, OSV and intel lookups, and the typosquat check. On vercel/ai (6c6c221) that was 353 phantom components and 8 bogus VULN-002 findings. pnpm-lock.yaml is now decoded as YAML and only packages: keys are read, in the 5.x, 6.0 and 9.0 layouts and in two-document lockfiles. The same change recovers real packages the old reader mangled: names containing _ (string_decoder, @types/babel__core), v5 hashed peer suffixes (vite@4.0.4_<hash>), and git/tarball dependencies, whose version is in a version: field rather than the key. Package counts now equal the lockfile’s own on vercel/ai, vuejs/core and trpc. VULN-001 is unchanged except on the v5.4 lockfile (vuejs/core v3.2.47), where vite@4.0.4’s 23 advisories had been reported three times, once per peer-suffixed version string; they are reported once.

  • A report nox wrote was scanned as source wherever it was kept. Only the current --output paths were excluded, so a renamed output directory, a downloaded CI artifact or a committed results.sarif fed nox’s own rule descriptions back in: cp -R out old-report took a 44-finding scan to 48 (AI-009 and AI-038 matching the SARIF rule catalog), and an --evidence-out file added 37 DATA-001 findings per copy. Discovery now recognises nox’s SARIF, findings/evidence JSON, CycloneDX, SPDX, attack-plan and HTML reports by content (a prefix sniff for the producer marker, confirmed by decoding the field it belongs in), under any name and in any directory, for the CLI, MCP and LSP alike. Each skipped file is named in an advisory generated_report degradation. Other tools’ SARIF and SBOMs are still scanned: a gitleaks or semgrep SARIF carries the credential it matched.

  • A detected LLM model was invisible. An app calling openai.chat.completions.create({model: "gpt-4o"}), in JavaScript or Python, printed 0 AI components and wrote no ai.inventory.json, even with --format all. nox detected the model, but every output counted only inventory components and models are kept separately. The console, the MCP scan and summary tools, the dashboard and the AI-assist context now share one count, components plus distinct models: 1 AI component (1 model: gpt-4o via openai). sbom.cdx.json (CLI and MCP) lists the same models as CycloneDX machine-learning-model components.

  • ai.inventory.json is written on every scan. It has a new checks field (schema 2.1.0) that names the AI checks that ran. An empty inventory with checks means nox found nothing; one without checks means the AI scope did not run.

  • No phantom models. With models now visible, two detector gaps would have shown up in the AI-BOM. A model call quoted in a comment was inventoried (nox’s own source listed a model named gpt-...), and in a file where “openai” appears only in a comment, an ORM’s model: "Invoice" counted as an ML model. Model extractors now skip comments. In ordinary source, a model: "X" assignment counts only when X is a recognised model family. SDK call sites and from_pretrained keep their own evidence.

1.48.x 3 releases · Oct 2026

v1.48.2

  • Fixed
  • Performance

Fixes from the concrete-witness research (#814): secret rules now report what their format establishes, and a JWT in any RFC-valid layout has an owner. nox fix --actions no longer deletes the waiver on a pin it bumps.

Fixed

  • nox fix --actions deleted waivers (#836). Rewriting a pin replaced its whole trailing comment with # <tag>, so a # nox:ignore ... after the version was lost on every bump; remediation PR #835 lost three. The version was also read from anywhere in the comment: @v4 # nox:ignore IAC-001 tracked “v1”, and # pinned for go1.26 held back a same-major upgrade as a major jump. Only the comment’s first word is the version now, and the rest is kept.
  • SEC-077 (age identity) reports the spec’s post-quantum AGE-SECRET-KEY-PQ- identity and verifies the Bech32 checksum (#816).
  • SEC-335 (Sourcegraph) no longer reports any 40-hex value near the word “sourcegraph”; a commit SHA or ETag was a high finding (#815).
  • SEC-519 and the eleven sibling ARN rules (SEC-414, 465, 466, 511-518) require the service’s full ARN, in every AWS partition. Their keyword was a Terraform resource stem no ARN contains (#817, #822).
  • SEC-251 (JWT) no longer reports an unsigned (alg: none) or truncated token as a credential (#820).
  • SEC-371, the JWT owner, runs wherever a JWT is. Its file keyword was jwt, which a token does not contain (#819).
  • A generic entropy finding no longer wins ownership of a provider token in dedup (#818).
  • Trailing boundary groups are context, not part of the reported span. In 135 rules the span included the consumed delimiter, so dedup missed overlaps and one token was reported several times (#821).
  • Supabase keys: an anon key is reported as public by design, a service_role key as critical (#825).
  • SEC-952 reports a signed JWT whose JSON is RFC-valid but not compact (spaced, pretty-printed), which no JWT rule matched (#826).
  • SLOP-001 resolves import z3 to the z3-solver distribution (#824).

Performance

  • A file’s secrets scan cost is linear in its findings (#827). It grew with findings x file size and findings x findings, so one crafted file in an untrusted repository could stall a CI scan.

On the release corpus (rule-diff against v1.48.1, 25 repositories) nothing drops. SEC-465, 513, 516 and 517 rise by 55 findings across podinfo, aws-cloudformation-templates and anthropic-sdk-python: ARNs in partitions the old literal prefix never matched.

v1.48.1

  • Fixed

Three secret rules that reported placeholders now report what their evidence establishes, and retired-rule fingerprints are protected by a test.

Fixed

  • SEC-082 (Bearer token) reported placeholders (#810). Every one of its 107 findings on the pinned corpus was one — alice-token, token123, null, and python read from BEDROCK_AUTH=bearer python. The value must now be a token: 16+ characters and random-looking by the test SEC-951 applies to a credential-named value. A quoted header key — "Authorization": "Bearer ...", the JSON, dict and fetch() form — is now matched; it never was.
  • Private-key header rules reported a header with no key (#810). A header named in a docstring or declared as a parser constant was a Critical finding. SEC-004, 078, 390-392, 426, 427 and the informational SEC-464 now need the key body: after a line break, an escaped \n as in a JSON service-account file, or PGP armor headers.
  • SEC-469 (environment-variable secret) asks the same value question as SEC-951, which already treats a keyboard placeholder such as dapi1234567890abcdef as an example (#810).
  • Retired-rule patterns are now held frozen (#811). They reproduce the fingerprints baselines, VEX statements and nox:ignore comments were written against; a test now fails if one changes or disappears.

On the release corpus (rule-diff against v1.48.0, 25 repositories) SEC-082 drops three findings, each read: a doc comment’s next-line // taken as the token, env-token in a test, alice-token in documentation. Nothing rises.

v1.48.0

  • Added
  • Changed
  • Fixed

Secret detection that reports what its evidence establishes. An audit of the rules whose pattern contained no credential corrected 46 of them by their claim — none removed — and constructing their true positives exposed three refiners that were hiding real credentials: long tokens in string literals, random tokens taken for camelCase identifiers, and response headers in recorded traffic treated as request headers.

Added

  • Declared corpus provenance: nox bench --provenance <manifest> (#800). A corpus manifest can state where parts of a tree came from (generated, versioned, vendored), each with the basis the project itself gives. Declarations are validated against the scanned tree before any scan, carried into the report as declared_provenance and shown in one markdown section, and change nothing else — no count, rule-review or calibration — which tests enforce. Independence is never inferred from findings: see docs/design/corpus-provenance.md and the research behind it in docs/research/evidence-independence/ and docs/research/deviance-not-incorrectness/ (both decision B, no new metric or rule-review signal).

Changed

  • Credential-body audit: 46 secret rules corrected by their claim (#807).
    • 33 rules reporting cloud resource identifiers — ARNs, S3/GCS/Azure URLs, resource paths, endpoints, broker and mail URLs — as High CWE-798 credentials are now informational, CWE-1051, with nothing to rotate. Those whose evidence did not establish even the identifier now require it (GKE resource names, Azure subscription and tenant GUIDs, actual gs://, amqp:// and smtp:// URLs). SEC-519 matched a misspelt service segment and could never fire; it is fixed together with its claim.
    • SSH and PGP public keys (SEC-463, SEC-464) are informational.
    • Six prefix-only rules (Bedrock, Google OAuth, Facebook, GitLab, Stripe, SendGrid) now require the credential body, and match on the format literal instead of invented keywords (google_oauth, fb_oauth) that meant SEC-423 and SEC-424 reported no real token at all.
    • SEC-469 (environment-variable secret), SEC-468 (kubectl create secret --from-literal) and SEC-410 (inline docker login JSON key) now require the value; SEC-416 reports the service-account email, as an identifier.
    • SEC-085 (URL with embedded password) also covers amqp(s) and smtp(s), so a broker URL that carries a password is reported as a credential.
    • Severity moves from High to info for the identifier and public-key rules, so a fail_on: high gate no longer fails on them. Rules whose pattern changed produce new fingerprints for their findings.

Fixed

  • A scan whose target is a symbolic link scanned nothing and reported clean (#801): 0 findings through a link, 16 by the real path. Discovery, the Go call graph, bench --precision, watch and fix now resolve a linked root; a dangling link is an error.
  • Credentials longer than 96 bytes in a string literal were never reported (#805). The data-blob heuristic assumed no credential is longer; Anthropic admin keys, Vault batch tokens, Cloudflare Origin CA keys, Slack configuration tokens and typical Google and Facebook access tokens are. A rule that anchors a vendor format is no longer overruled by length; data: URI payloads are still blobs.
  • Most random credentials were dropped as “camelCase identifiers” (#806): 66-82% of uniformly random 16-40 character tokens failed the secret-shape filter. Identifiers must now also be made of words (mean lowercase run of at least 2.5); 4-9% of random tokens remain affected.
  • Recorded HTTP response headers counted as credential locations (#806): in the standard vcrpy layout the request block never closed, so the confinement of entropy rules in cassettes did nothing.
  • FILTERED is a placeholder word, for scrubbed recordings (#807).
  • Symlink-dependent tests fail on CI instead of skipping silently, and the rule dump carries every rule field (#804).

On the release corpus (rule-diff against v1.47.0, 25 repositories) nothing drops. The rises are documentation examples in certbot’s plugin docstrings (AWS’s documented example key, example Cloudflare and Google credential files) and random test keys — text the length heuristic used to hide in docstrings and that nox already reports in Markdown — plus three GCP resource locations, now informational.

1.47.x 1 release · Oct 2026

v1.47.0

  • Added
  • Changed
  • Fixed

Taint analysis measured on a labelled suite for every language that has one, scans that can be narrowed to one concern, and a scan that is faster and smaller than Semgrep’s while covering more. Load-gated on the seven pinned benchmark repositories: a full scan takes 89 s offline and 97 s online, against 141 s for Semgrep p/default, which scans code only; peak memory is 790 MB on llama_index, against Semgrep’s 968 MB.

Added

  • Scoped scans: --only / --skip (#786, #787). nox scan --only secrets or --skip deps, scan.scopes / scan.skip_scopes in .nox.yaml, and only / skip on the MCP scan tool. The scopes are secrets, code, deps, iac, ai, data and supply-chain. A scope left out does not run at all: no analyzers, no lockfile reads or vulnerability lookups without deps, no plugins. Every output says what was not scanned: meta.scope in findings.json, nox/scope/not-scanned notes in SARIF, the summary line and report.html. --format cdx/spdx without deps is an error rather than an empty SBOM. A scoped scan’s findings are exactly the full scan’s findings for its scopes (checked on the seven benchmark repositories). --only secrets takes 27 s on them (gitleaks 73 s, TruffleHog 10 s); --only code takes 33 s (Semgrep 141 s).
  • Go taint on the OWASP Benchmark port (#778): 7.3 -> 35.6 (Semgrep 22.1), with receiver types, branch-aware definite assignment, element-sensitive slice literals and HARDEN-003 for Go cookies.
  • C#, JavaScript/TypeScript and Ruby measured (#779): NIST Juliet C# 16.7 -> 24.8, and 82.6 on its in-scope cases (Semgrep 0.3 and 1.4). JavaScript and Ruby recall is measured on CodeQL’s own test cases, read as a floor.
  • PHP on NIST SARD (#780): -0.0 -> 4.6, and 12.3 on HTTP-input cases (Semgrep 0.0 and 0.1), with getter summaries and URL/path argument gates.
  • Per-language scorecard and load-gated speed benchmarks: docs/benchmarks/ 2026-09-30-languages and 2026-10-01-speed.

Changed

  • Faster rule matching (#783). Vendor-token rules ((?i)[\w.-]{0,50}?(?:okta)...) are matched from their literal, a candidate scan yields to the full scan when it would cost more, and every keyword pre-filter is answered in one pass per file. Total CPU on the seven repositories 1,771 s -> 968 s, with identical findings.
  • Analyzers use every core (#776).
  • Online dependency lookups (#794): nox-core v0.3.2 sends batches four at a time, and a failed batch no longer loses the batches after it.
  • scan.analyzer_rules skip_analyzer now knows weakcrypto, fileperms, hardening and memsafe, and removes AGENT-* with ai; it silently did nothing for those before.

Fixed

  • Online scans labelled lockfiles with analysis limitations (#791). Every file with a finding was read and matched for reflection, dynamic loading and FFI; online, those are the lockfiles vulnerability findings point at, where ctypes and importlib-metadata are package names. 749 findings on llama_index carried limitations no analysis had, and the read cost 49 s of CPU. Limitations now come from source files only.
  • Peak memory (#789, #796). The IaC and slopsquat analyzers held every file’s contents until they finished, and declared package names kept whole lockfiles alive as substrings: llama_index 3.6 GB -> 790 MB.
  • The deterministic finding order was not a total order (#789). Two matches of the same text on one line could come out in either order between builds.
  • A panic in one file’s taint extraction no longer ends the scan (#775); the file is skipped and the scan reports a degradation.
  • Reserved words are judged per language (#777).
  • Waivers for rules a scoped scan did not run are no longer reported as unused (#786).
1.46.x 1 release · Sep 2026

v1.46.0

  • Added
  • Fixed

Java XPath and LDAP injection, and container taint that tracks literal keys. On the OWASP Benchmark for Java the average score goes from 39.6 to 48.2 (Semgrep p/default: 34.9), and for Python from 49.3 to 56.1 (Semgrep: 10.7); taint findings on 11 real repositories are unchanged, and rule-diff’s 25-repository corpus shows no rule-level change.

Added

  • XPath and LDAP injection in Java (TAINT-008, TAINT-009). A local declared as javax.xml.xpath.XPath or as a DirContext / InitialDirContext / LdapContext is now known by its type, and xp.evaluate(expression, ...), xp.compile(expression) and ctx.search(name, filter, ...) on it are sinks — keyed on the method name alone, evaluate and search would match expression engines and search indexes. XPath is judged on its expression argument. ESAPI’s encodeForXPath, encodeForLDAP and encodeForDN sanitize. OWASP Benchmark for Java: xpathi 0 -> 38, ldapi 0 -> 38, average 41.2 -> 48.2 (Semgrep p/default: 34.9). No change on Kafka, Keycloak or Jenkins.

Fixed

  • brace-expansion 5.0.9 -> 5.0.12 in the VS Code extension (#770). Two high advisories (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p) were published against the transitive dependency and failed nox’s own pre-push self-scan.

  • Container taint tracks literal keys (Python and Java). A value stored under one key tainted the whole container: m['keyB'] = param; bar = m['keyA'] read as tainted, and so did map.put("keyB", param); map.get("keyA") and configparser’s set/get. Worse, a later store of a constant under another key overwrote the whole container and dropped the tainted key. A container that is created empty and only ever stored into and read with literal keys is now one variable per key. Any other use — passing it whole, a non-literal key, update/putAll, a non-empty initializer — keeps whole-container taint, so a flow is only dropped where it provably cannot happen. OWASP Benchmark for Python 49.3 -> 56.1 (detection rises in sqli, cmdi, xpathi and path traversal as well as fewer false positives); Java 39.6 -> 41.2 (Semgrep p/default: 10.7 and 34.9). No change on 11 real repositories (220 taint findings) or the 90-file GitHub servlet sample.

1.45.x 1 release · Sep 2026

v1.45.0

  • Added
  • Fixed

Java catches up. Predictable randomness and setSecure(false) cookies are now reported in Java; prepareStatement was a SQL sanitizer and is now a sink, so the classic JDBC injection is found; and Java taint gains a branch model, correct method scoping and container stores. On the OWASP Benchmark for Java the average score goes from 14.1 to 39.6 (Semgrep p/default: 34.9); Python is unchanged at 49.3. Every change was also measured on Kafka, Keycloak, Jenkins and 90 GitHub servlet files, and rule-diff’s 25-repository corpus shows no rule-level change.

Added

  • Java SQL injection through prepareStatement, Spring JdbcTemplate and more request sources (TAINT-001). prepareStatement was in the Java catalog as a SQL sanitizer. A prepared statement is safe only when its SQL string is constant, so the classic JDBC injection — conn.prepareStatement("... '" + request.getParameter("q") + "'") — was cleared rather than reported. It is now a sink on its SQL argument, as are prepareCall, addBatch and Spring’s queryForObject / queryForList / queryForMap / queryForRowSet / batchUpdate; bind parameters after the SQL are the safe form and never count. getParameterNames, getParameterMap, getRequestURI, getRequestURL, getPathInfo and getHeaderNames are now sources. Each injection is reported once, where the statement is prepared, not again at the zero-argument executeQuery() that runs it. On 90 GitHub servlet files: 21 more findings, all SQL built from request data, none removed. OWASP Benchmark for Java sqli: 4 -> 14.

  • Java: predictable randomness (CRYPTO-002) and setSecure(false) cookies (HARDEN-003). Both rules existed for Go and Python only, and on the OWASP Benchmark for Java both categories scored 0 where Semgrep p/default scores 100. java.util.Random, Math.random, ThreadLocalRandom and commons-lang’s static RandomStringUtils.random* are reported when the names around the draw say it is a secret — with the same one-step forward hop, so long l = new Random().nextLong(); String rememberMeKey = Long.toString(l); is found. SecureRandom never fires, even behind a Random-typed field: a generator is judged by its constructor. Cookies: a servlet setSecure(false) and Spring’s ResponseCookie .secure(false), literal only. Measured:

    • OWASP Benchmark for Java: weakrand 0 -> 100, securecookie 0 -> 100; average 14.1 -> 32.3 (Semgrep 34.9).
    • Kafka, Keycloak and Jenkins (1,227 Java files, 73 weak generator call sites outside tests): 2 findings — Kafka’s fetch-session ID (debatable) and Jenkins’ deliberately predictable dummy password, which SpotBugs also flags and the project suppresses with a justification.
    • 125 GitHub files that generate tokens, OTPs or passwords with Random: 64 findings, 62 of them the thing the rule is for.

Fixed

  • Java taint read most methods wrong. Four defects, found together while reading why nox missed flows on the OWASP Benchmark for Java and checked on Kafka, Keycloak, Jenkins and 90 GitHub servlet files:

    • No branch model. A statement in an if, else, loop or switch was an unconditional overwrite, and a brace-less branch — if (c) bar = "x"; else bar = param; — was skipped whole because its line starts with if. Branch bodies are now weak updates, brace-less branches are read, and an if/else, switch case or ternary whose condition is a constant is resolved the way Python’s are (#757): only code that provably does not run is dropped, with switch fall-through and stacked labels honoured.
    • A method whose throws clause sat on the next line was not a method. Its statements folded into one file-wide scope, which joined flows across methods that never share a variable.
    • map.put(k, param), list.add(param) and sb.append(a).append(b) dropped the value’s taint. Java now has Python’s container stores, including chained builders. PreparedStatement.setString is deliberately not one: binding a value is the safe form.
    • System.getProperty("user.dir") was untrusted input. The properties the JVM defines (user.dir, user.home, java.io.tmpdir, os.name, line.separator, …) describe the machine; only a custom -D property, or a non-literal key, is still a source.
  • A sanitizer wrapping a helper’s argument was ignored. In every language, helper(escape(x)) was reported by the helper’s own sink when the helper was a function in the same file; a direct sink already honoured the wrapper. Found on Jenkins’ FormFieldValidator, whose error(msg) escapes before it writes. hudson.Util.escape and Spring’s HtmlUtils.htmlEscape are now Java XSS sanitizers.

  • A string-literal argument shifted every later argument. The code view blanks literals, and an argument that was blank was skipped, so in query("SELECT ... WHERE id=?", source()) the source was read as the SQL argument. That misaligned the per-argument record in every language, including the interprocedural pass’s mapping of arguments to parameters. Found through Spring’s queryForMap(SQL, request.getParameter(...)), a bind parameter reported as injection. The Java engine now also checks whether the first argument is actually tainted rather than merely a variable, as Python’s already did. No change on 11 real repositories across Python, JavaScript, Go and Java (254 taint findings, identical).

  • CRYPTO-002 no longer accuses a duration, an index or a teardown. Found by running the Java rule on Kafka and Keycloak, and applied to every language: names that measure time or proportion (sessionLifetimeMs, pctToUse) do not accuse; a draw assigned to index/idx/pos is a pick, so a later key = keys[index] cannot accuse it; and a teardown purpose (close, destroy, wipe, scrub) vetoes, because overwriting a secret’s memory with noise is scrubbing, not generating. The Python GitHub sample keeps all 55 of its findings.

  • Gradle src/testFixtures/ is test code. Every analyzer that skips tests now skips it; Kafka’s SmokeTestDriver lives there.

1.44.x 1 release · Sep 2026

v1.44.0

  • Added
  • Changed
  • Fixed

Six new Python detections, and taint analysis that follows two ordinary idioms it used to drop. Weak randomness (CRYPTO-002), XPath, LDAP and XXE injection (TAINT-008 to 010), secure=False cookies (HARDEN-003), reflected XSS through Flask route returns, taint through x += y, and branches whose condition is a constant no longer analysed as if they could run. On the OWASP Benchmark for Python the average score goes from 15.5 to 49.3 (Semgrep p/default: 10.7); rule-diff’s 25-repository corpus shows no rule-level change, and every new rule was also measured on real repositories before it shipped. One rule, TAINT-011 (untrusted data stored in the session), is opt-in.

Added

  • Reflected XSS through a Flask handler’s return value (TAINT-003). Flask serves a string a view returns as text/html, so return f"<p>{request.args['q']}</p>" is reflected XSS with no template in sight; the only Python XSS sinks were Markup and mark_safe. A plain value returned from a route in a file that imports Flask is now a sink (FastAPI’s identical decorators serialize a returned string as JSON, so it is excluded), and so are make_response and Django’s HttpResponse on the body only — a tainted header is not XSS. Returned jsonify, dicts, lists, and locals built by a response constructor are not. OWASP Benchmark for Python xss: 0 -> 23 (Semgrep p/default: -28). AGENTFLOW-002 does not treat a route’s return as an action a hijacked model takes: nox’s own self-scan showed it would otherwise fire, at high severity, on every Flask app that returns a model’s reply — including the examples’ safe.py. Rendering model output as HTML (OWASP LLM05) deserves its own measured rule.

  • XML external entity resolution (XXE) is a taint sink (TAINT-010). Python’s XML parsers stopped resolving external entities by default (the standard library in 3.7.1, lxml in 5.0), so the vulnerable condition is two things together: a parser someone switched entities back on — setFeature(feature_external_ges, True), XMLParser(resolve_entities=True) or no_network=False — and a document the caller does not control. Either alone is not reported: a default parser on request data is safe, and an entity-resolving parser on a bundled file is not an exploit. OWASP Benchmark for Python xxe: 0 -> 42 (TPR 62%, FPR 20%). On 58 GitHub files that enable entity resolution, 16 findings: 14 in deliberately vulnerable XXE demo apps and 2 in command-line tools that parse a file named on the command line; the 42 files that never parse untrusted input stay silent.

  • A cookie set with secure=False is reported (HARDEN-003, CWE-614). A Python set_cookie / set_signed_cookie call (Flask, Werkzeug, Django, Starlette) passed the literal secure=False sends the cookie over plain HTTP. Only the explicit statement is reported: an omitted flag, and a flag bound to configuration (secure=not app.debug), are deployment decisions a file cannot see. Framework signatures (def set_cookie(self, ..., secure=False)) are parameter defaults, not cookies, and are skipped — they were 36 of the first 41 GitHub hits. OWASP Benchmark for Python securecookie: 0 -> 100. On 41 GitHub files containing secure=False, 3 findings, all real calls.

  • Opt-in: untrusted data stored in the session (TAINT-011, CWE-501). A request value written into session[...], flask.session[...] or Django’s request.session[...], as key or value, lets later code trust what the attacker chose. It is off by default and low severity: 5 of 53 Flask files from GitHub search do it, a mix of preferences (session['lang']) and pre-authentication identity. Turn it on with scan.rules.enable: [TAINT-011]; taint rules now honour that switch, as regex rules already did. On the benchmark it finds 44% of the trustbound cases at a 42% false-positive rate — the dead-branch idioms task-80 addresses — which is itself the reason it is not on by default.

  • XPath and LDAP injection are taint sinks (TAINT-008, TAINT-009). There was no sink class for either, so a request value spliced into an XPath expression or an LDAP filter was invisible. Python sinks: lxml’s .xpath(query) and etree.XPath, elementpath.select; python-ldap’s search_s family, and ldap3’s conn.search where conn was made by something LDAP-named (ldap3.Connection(...), ldap.initialize(...), a project’s get_ldap_connection()) — search alone is re.search and every vector store, so it is not a sink by name. Only the query counts: XPath’s parameterised form (root.xpath("//u[@id=$id]", id=v)) and a user-supplied document with a constant path stay silent. escape_filter_chars, filter_format, escape_dn_chars and escape_rdn sanitize LDAP; int and float sanitize both. On the OWASP Benchmark for Python, xpathi goes from 0 to 22 and ldapi from 0 to 41 (Semgrep p/default: 0 on both). Twelve of the XPath false positives reject an apostrophe and return before building a single-quoted query; recognising that guard is task-80’s work, with the rest of the benchmark’s dead-branch idioms.

  • Predictable randomness is now caught in Python (CRYPTO-002). The rule was Go-only. A random draw (random.random, randint, choice in a join, getrandbits, a random.Random instance, …) is reported when the names around it say the value is a secret: otp = random.randint(...), app.secret_key = ''.join(random.choice(...) ...), or a neutral name stored one step later into a session. secrets and random.SystemRandom never fire, and jitter, backoff, sampling and element picks stay silent, as in Go. Measured three ways:

    • OWASP Benchmark for Python, weakrand (326 cases): 0 → 100 (every vulnerable case found, no safe case flagged). The benchmark’s overall score goes from 15.5 to 22.7; Semgrep p/default scores 10.7.
    • 403 Python files that use random in the rule-diff and head-to-head corpora, holding 85 draws for jitter, sleeps, sample data and request IDs: no findings.
    • 89 files from GitHub code search for security-named random use: 55 findings, of which 51 are plainly secrets (OTPs, Flask secret_key, password and licence-key generators, a salt, a Diffie-Hellman private key) and 4 are debatable. Misses are neutrally named secrets (hashCode for a reset code), the limit the Go rule documents too.

    Two differences from the Go rule, both documented in rand_python.go: the enclosing function’s name vetoes only on a purpose word (retry, sample, …) or a pytest test_ prefix, not on words like “demo” or “bench” anywhere in it; and a security word glued to a lowercase word (mysession, authtoken) is split before matching. “guess” is now a benign word in both languages: a brute-force loop’s guess_password is not a secret.

Changed

  • Python rules share one comment/string masker. lexctx.MaskNonCode blanks comments and string bodies with offsets kept, and leaves an f-string’s interpolations as code; CRYPTO-002’s Python scanner now uses it instead of its own copy, and sees names inside f-string expressions it used to miss.

Fixed

  • Python taint reported flows through branches that cannot run. An if, elif, else, match/case arm or conditional expression whose condition is a constant — if 7 * 42 - num > 200: with num = 106, x if 'a' in 'abc' else y, match "ABC"[1]: — was treated as “may run”. A small evaluator now resolves such conditions and drops the arm that cannot run. It refuses rather than guesses: any operand it cannot pin to a value (a call, an attribute, a parameter, a name the file assigns more than once, which covers loop variables) leaves the branch as it was, so only code that provably does not execute is removed. A condition’s own calls are still statements (if os.system(c): stays a sink). On the OWASP Benchmark for Python no category’s detection rate moves, and the false-positive rate falls in eight: xss 22% -> 10%, ldapi 15% -> 0%, deserialization 11% -> 3%, xpathi 33% -> 23%, codeinj 52% -> 33%, redirect 52% -> 38%, pathtraver 23% -> 14%, xxe 20% -> 15%. The average score goes from 42.7 to 49.3.

  • x += y in Python carried no taint. An augmented assignment was not modelled as an assignment at all, so sql += q; cursor.execute(sql) and body += f"<p>{q}</p>"; return body were both unreported. It now keeps the target’s taint and adds the operand’s, like x = x + y. On the OWASP Benchmark for Python this alone moves command injection 23 -> 40, deserialization 39 -> 50, XPath 22 -> 27, LDAP 41 -> 47, open redirect 1 -> 9 and path traversal 7 -> 11. The other recognizer languages share the gap and are left for their own measurement.

  • A sanitizer for open redirect was never honoured. The engine asks the catalog “does this call clear class X” for a fixed list of classes, and two hand-kept copies of that list stopped at prompt injection. Open redirect had no catalog sanitizer yet, so nothing changed in practice, but every class added after it would have been declared sanitizable and silently not been. The list now comes from one place, taint.AllVulnClasses.

1.43.x 2 releases · Sep 2026

v1.43.1

  • Changed
  • Fixed

Scans take half the CPU, with identical findings: 1,262 of the 1,392 regex rules are now tried only where their opening literal occurs. Verified three ways, including full scans of the seven benchmark repositories and rule-diff’s 25-repository corpus, both unchanged.

Changed

  • nox’s own weekly remediation runs nox 1.43.0. Both pin lines moved together; the sha256 is of nox_1.43.0_linux_amd64.tar.gz and matches the release’s cosign-verified checksums.txt.

Fixed

  • Scans take half the CPU, with identical findings. About half of a scan’s CPU was Go’s regex engine scanning whole files for rules whose keyword appeared somewhere in them (#736). A pattern that can only start with one of a few literal words ((?i)mlflow[_-]?tracking…, \b(?:eval|exec)\() is now tried only where one of those words occurs, which returns exactly the matches a full scan returns; any pattern this cannot be proven for keeps the full scan. 1,262 of the 1,392 regex rules qualify. Checked three ways: the fast and full paths agree on every built-in rule over 3,101 files (a test that runs in CI and was shown to fail on a broken fast path), the seven benchmark repositories give identical findings down to column and fingerprint, and rule-diff shows no change on its 25-repository corpus. CPU time on the benchmark falls from 1,721 s to 863 s (crewAI −54%, llama_index −53%); elapsed time from 818 s to 503 s. Line offsets are now computed only for files a rule matches.

v1.43.0

  • Changed
  • Fixed
  • Added

nox was compared, for the first time, with the tools people run instead of it (docs/benchmarks/2026-09-27-head-to-head/): gitleaks and TruffleHog for secrets, osv-scanner and Trivy for dependencies, Semgrep for code. This release is what that comparison found. On the seven benchmark repositories secret findings fall 62% and personal-data findings 94%, with nothing real lost; nox now finds all nine real credentials any of the tools found there (gitleaks five, TruffleHog three), including a Weaviate Cloud key none of them reported. Dependency advisories no longer stop at a deadline in large monorepos, which brings nox level with osv-scanner on pinned dependencies. On the OWASP Benchmark for Python, nox’s score goes from 10.9 to 15.5 (Semgrep p/default: 10.7). Every dropped finding was read, and the ones that changed the rule-diff corpus are explained in the ledger this release rolls.

Changed

  • nox’s own weekly remediation runs nox 1.42.0. Both pin lines moved together; the sha256 is of nox_1.42.0_linux_amd64.tar.gz and matches the release’s cosign-verified checksums.txt.

Fixed

  • The rule-diff check passed a run that never finished. Under set -e a failing command exited with its own status, usually 1, which the workflow reads as “every drop is explained”. An rm of a fresh clone’s .git failed on the third of 25 corpus repositories, the check passed, and an unexplained drop merged. Any unexpected failure now exits 2, which the workflow fails; background git maintenance is off and the rm is retried (#735).

  • A monorepo’s dependency advisories stopped at the lookup deadline. nox asked the advisory source once per lockfile entry, so a package pinned in 600 lockfiles was asked about 600 times. On llama_index the batches ran past the lookup’s two-minute budget and the rest were never sent: 211 of its 608 vulnerable lockfiles reported nothing. The scan did say so (osv_lookup, “dependency vulnerabilities are under-reported”), but the advisories were still missing. Each distinct package version is now asked about once and the answer reported in every lockfile that pins it: llama_index goes from 397 to 608 lockfiles with findings and from 248 to 273 distinct advisories, with nothing lost, and no degradation. Found by running osv-scanner over the same seven repositories.

  • Python taint analysis lost the flows most Python web code has. Measured on the OWASP Benchmark for Python (1,230 labelled cases), four gaps:

    • cur = con.cursor(); cur.execute(sql) was never a SQL sink: the sinks are cursor.execute / connection.execute, matched on the variable’s name. A local made by .cursor() or .connect() now stands for one.
    • if not param: param = "" cleaned a tainted value, as if the branch always ran. An assignment inside an if/loop/except body is now a weak update: it keeps prior taint, and a sanitizer in it counts only if both paths applied it.
    • A value stored with m['k'] = v, l.append(v) or conf.set(s, k, v) lost its taint. Python now taints the container, as Perl and Dart already did.
    • cursor.execute(sql, (user,)) was reported whenever the SQL sat in a variable, because “the first argument is a variable” was read as “the first argument is tainted”. The parameterised form is now recognised. The benchmark score (detection rate minus false-positive rate, averaged over its 14 categories) goes from 10.9 to 15.5; Semgrep’s p/default scores 10.7. SQL injection 0 → 40, command injection 0 → 23, deserialization 28 →
    1. On the seven benchmark repositories nothing is added and three command injection false positives are removed (os.environ passed as env= to an argv subprocess.run).
  • Three rules no longer scan most of a repository for a word they never find. DATA-009’s keyword tin matched inside setting, testing and routing, so its case-insensitive regex ran over 27,881 of the benchmark’s 55,102 text files (1 GB) to report nothing at all. A new rule flag, KeywordTokens, makes a keyword count only as a whole word, in the file pre-filter and at the start of the match. On for DATA-009 (27,881 → 6 files scanned), DATA-004 (12,163 → 1,384) and DATA-005 (40,973 → 10,087). The findings on the seven benchmark repositories are identical, fingerprints included; words that merely end in the keyword (martin:, hotel:, zip =) no longer match. Wall time on the benchmark: 2,313 s → 1,781 s, measured on a loaded machine (#736).

  • A fictional or placeholder phone number is not personal data. DATA-004’s 32 findings on the benchmark were numbers no person has: 28 × +1 415 555 0123 in openai-python’s API reference, in the 555-0100–0199 range the North American Numbering Plan reserves for fiction, and 4 × 1234567890. Those, and runs of consecutive or identical digits, are no longer reported; (212) 555-0237 and other real-looking numbers still are. DATA findings on the benchmark go from 55 to 23, nothing added, every drop read.

  • “PASTE YOUR PASSWORD HERE” and FakeExamplePassword are placeholders. Both reached reports once #732 made notebooks scannable. “your” as a separate word now marks a placeholder (it already did as your- and your_), and a value’s CamelCase words are read as words, so FakeExamplePassword carries the fake marker. A passphrase in CamelCase with no marker (CorrectHorseBatteryStaple) is still reported. Benchmark: llama_index 74 → 67 secret findings, all seven drops read, nothing added.

  • Model-issued ciphertext is not a credential. Recorded model API traffic carries opaque values the provider issues and takes back: Anthropic’s thinking-block signature and web-search encrypted_index, OpenAI’s reasoning encrypted_content, Gemini’s thoughtSignature. They are long base64, so the entropy rule (SEC-161) and short-prefix vendor rules (SEC-048, SEC-055) fired inside them. A match in the string value of one of those keys is now refuted; the same token under any other key is still reported. anthropic-sdk-python 96 → 88 and vercel/ai 211 → 152 secret findings, all 67 drops under those four keys, nothing added.

  • A role mailbox is not personal data, and neither is a database URL. DATA-001 is CWE-359, exposure of private personal information. On the head-to-head benchmark two addresses were 309 of its 328 findings: support@crewai.com in OpenAPI contact blocks and hello@neatlogs.com in docs, repeated in every versioned copy. A role mailbox (support@, hello@, noreply@, security@…) names a function rather than a person, and is no longer reported; supporter.jane@ still is. The user:password@host part of a connection URL, which the rule’s pattern also matched, is no longer read as an e-mail address. On the seven benchmark repositories DATA findings go from 369 to 55, nothing added, every drop read.

  • A value that describes itself is not a credential. Bearer test-api-key, password="test_password" and OPENAI_API_KEY=sua_chave_openai were reported as hardcoded secrets: on the head-to-head benchmark they were most of nox’s secret findings (test-api-key alone, 86 lines). The placeholder check now recognises a value made only of short words and numbers that carries a test marker (test, mock, fake, stub, expired), opens with a translated “your” (sua_chave, tu_clave, votre_cle), or uses only credential vocabulary (my-api-key, token-2). A value with a random body is never one of these, so a Stripe test-mode key sk_test_51H… and a passphrase like correct-horse-battery-staple are still reported. Across the seven benchmark repositories secret findings go from 960 to 547, nothing is added, and every drop was read; none of the real credentials nox found is among them.

  • A notebook cell is scanned as the code it holds. A .ipynb file stores each source line as a JSON string, so api_key="…" in a cell is api_key=\"…\" on disk, and rules that expect a quote right against the value never matched it. The same line in a .py file was reported. Escaped quotes in a notebook now read as quotes, with every line and column unchanged, so findings still point at the file on disk. On llama_index’s 759 notebooks this adds 42 findings and removes none: a Vercel AI Gateway key and a Weaviate Cloud API key (two lines each; neither nox, gitleaks nor TruffleHog reported the Weaviate key), and 38 hardcoded passwords in example notebooks, the same rule nox applies to .py files.

  • Base64 images are image data, not API keys. A notebook’s outputs and a recorded API response carry images as bare base64 strings ("image/png": "iVBORw0KGgo…", "data": "/9j/4AAQ…"), and any long base64 run contains any short vendor prefix: SEC-048 (NuGet, oy2…) and SEC-055 fired inside them. The data: URI check needed the data: prefix these lack. A match inside a string that starts with an image’s own base64 signature (PNG, JPEG, GIF, WebP) is now refuted. On the benchmark: llama_index 236 → 101 secret findings, vercel/ai 491 → 486, nothing added, every drop on an image line. The same token in an ordinary string is still reported.

Added

  • SEC-951, a generic credential rule, and scan.rules.enable for opt-in rules. Some keys have no vendor format to match: a GigaChat key is base64 of client_id:secret, a MonsterAPI key is a UUID. In the head-to-head only gitleaks found them, with a generic rule that reported them among 169 findings. SEC-951 reports a credential-named key (api_key, secret, token, credentials, …) bound to a quoted value of 16–200 characters that looks random: entropy of at least 3.5, not made of words, not a key that is public by design (PostHog phc_, Stripe pk_), not a documentation example, and not a run of sequential digits. It ranks below every other secret rule on a shared span, so it reports only what nothing else does and never moves an existing finding to a new rule ID. It runs by default at medium severity and low confidence, below the usual fail_on: high; scan.rules.disable: [SEC-951] turns it off. Opt-in rules (scan.rules.enable, tagged opt-in in nox rules) cost nothing when off; none ships opt-in today.
1.42.x 1 release · Sep 2026

v1.42.0

  • Changed
  • Fixed

Eleven rules stop reporting what merely resembles the condition they name: nine vendor-binding secret rules no longer read identifiers as API keys, and AI-036 and AI-050 need a model actually selected and retries actually turned off. No rule was removed; each was narrowed or bound, and every dropped finding was read. docs/benchmarks/2026-09-27 re-measures the seven pinned repositories against the 2026-09-15 run: engine findings outside VULN-001 fall from 4,229 to 2,689 (−36%), a figure that also includes the precision work released in 1.40.0 and 1.41.0. scripts/rule-deltas.json is rolled to v1.42.0.

Changed

  • nox’s own weekly remediation runs nox 1.41.0, with the currency pass back on. It had been pinned to the org default (1.28.0), which predates refusing a vulnerable upgrade target, so outdated-fixes was off after Dependabot was removed. The pin’s sha256 was checked against the downloaded archive.

Fixed

  • AI-036 and AI-050 report the condition they name, and nothing else. AI-036 (“deprecated GPT-3.5 model”) matched any gpt-3.5 string, so an SDK listing the models it accepts reported it — 224 findings in openai-python alone, from type annotations and model lists. It now needs the model to be selected: a model= / model_name= argument, a model: key, an *_MODEL variable. AI-050 (“retries disabled”) matched any retry = False, which is ordinary control flow; it now matches max_retries / maxRetries set to 0. On the seven-repo benchmark AI-036 goes 330 → 34 and AI-050 42 → 26, and every finding left is a real selection or a client configured with retries off. Both rules stay: narrowing them was chosen over removing them.

  • nox bench reported a benchmark of nothing as a success. A corpus of symlinked clones — the natural way to assemble one from repositories already on disk — produced “0 projects”, a report and exit 0, because only plain directories counted. Links to directories are projects now, and a corpus with no projects is an error rather than an empty report.

  • Nine vendor-binding secret rules read a class name as an API key, and missed JSON. splitter = SemanticDoubleMergingSplitterNodeParser( reported SEC-659 “Split API Key” six times across llama_index — splitter starts with split, and the class name’s first 32 characters qualified. A value that runs on into a call, attribute or index is now refuted as an identifier. In the other direction, none of the nine (Namecheap, Wave, FCM, Lob, Salesforce, Jenkins, Split, Heap, FullStory) could see {"split_key": "…"}, because the key’s closing quote sat before the colon; it may now. Existing findings keep their fingerprints.

1.41.x 1 release · Sep 2026

v1.41.0

  • Changed
  • Added
  • Fixed

nox keeps its own dependencies current, and Dependabot is gone. Doing that honestly took more than deleting a file: the version bumper nox already had would have repeated Dependabot’s last mistake — moving grpc onto a release that is newer and vulnerable — and it could not see the VS Code extension, the composite actions nox publishes, or its base images. Each of those is fixed here, and each was checked against nox’s own tree with live registries.

Changed

  • nox keeps its own dependencies current; Dependabot is gone. Both .github/dependabot.yml and the workflow that auto-merged its pull requests are removed. nox’s weekly remediation run (nox fix over the org’s reusable workflow, gated on the full test suite) already existed and had been running alongside it. Dependabot’s last contribution was the reason to stop: #699 moved grpc from 1.83.2, which is patched against GO-2026-6443, to 1.84.0, which is not, and auto-merged it on a green suite.

    The currency pass (nox fix --outdated) is off in nox’s own run for now, because it does not yet check the version it upgrades to against known advisories and would repeat that exact bump. The security pass still runs.

Added

  • nox fix --outdated keeps base images current. The FROM lines of each configured directory’s Dockerfiles are checked against their registry: a digest pin is refreshed when its tag is rebuilt, and a numeric tag moves to the newest tag of the same shape (1.27-alpine → 1.28-alpine), majors held. This was the last thing Dependabot did for nox that nox could not. On nox’s own Dockerfile it found golang:1.27-alpine rebuilt on 2026-09-21 under the same tag, two days after the pinned digest.

  • nox fix --outdated covers the directories .nox.yaml lists. fix.outdated.directories names the project directories to keep current (default: the root alone, so nothing changes without it), and fix.outdated.hold caps how far a named package may move, with a required reason printed whenever it applies. Together they are what Dependabot’s directories and ignore were doing for nox: the VS Code extension under editors/vscode had gone unchecked since Dependabot was removed, and @types/vscode must track engines.vscode, not the newest VS Code.

Fixed

  • nox fix rewrote package.json around an npm advisory. It ran npm install pkg@version for every npm finding, and npm writes whatever it is given into the manifest: a transitive package became a new direct dependency (one run on roady added eleven), and a devDependency moved into dependencies. The manifest now decides — a declared package keeps its section, anything else is moved with npm update, which touches only the lockfile, and the lockfile is read back so a copy a parent’s range still holds below the fix is an error, not an “applied”. A package an earlier upgrade in the same run had already moved is reported as satisfied rather than failed, which had made a fully successful run exit non-zero.

  • nox fix --actions skipped composite actions outside .github. nox’s own root action.yml and actions/remediate/action.yml — which pinned nox itself two releases behind — were never refreshed. Any action.yml / action.yaml in the repository is now read, except in vendored and fixture trees.

  • nox fix --outdated could upgrade a dependency into a known vulnerability. It asked only whether a newer version existed. grpc 1.84.0 is newer than 1.83.2 and is affected by GO-2026-6443, which 1.83.2 is patched against — the fix existed only on a development branch, so the newest stable release was also the vulnerable one. That is the bump Dependabot made in #699, and nox’s own currency pass would have made it too.

    Every upgrade target is now looked up against the same advisory source the scan uses before anything is shown or applied. A target with a known advisory is held, and the line says which (held: google.golang.org/grpc v1.83.2 -> v1.84.0: the target is affected by GO-2026-6443). If the lookup cannot complete, nothing is applied and the command exits non-zero: a currency bump is optional, and an unchecked target is not a clean one. Verified against the live OSV API on nox’s own tree, where the previous binary planned exactly that bump.

1.40.x 1 release · Sep 2026

v1.40.0

  • ⚠️ Behaviour changes
  • Fixed
  • Documentation

A cassette is a test recording of real HTTP traffic, and it was the single largest source of secret findings nox produced on real software. Suppressing that noise turned out to require fixing a false negative first — because nox could not report the one thing in a cassette worth reporting.

Two smaller classes went the same way, each measured before it was built: SEC-080 reporting a reference to a secret manager as a hardcoded password, and SLOP-001 reading a codemod’s test fixtures as its dependencies. Against v1.39.2 on the 25-repository rule-diff corpus, those three changes are the only rule-level movement: SEC-161 373 → 0, SLOP-001 109 → 0, SEC-080 12 → 0.

⚠️ Behaviour changes

  • SEC-161 and SEC-162 no longer report recorded HTTP traffic. Inside a recorded exchange — a vcrpy, betamax or Ruby VCR cassette — a credential the repository holds appears in a request header that authenticates the request, or in the request URI. Everything else is traffic: response headers and bodies, request bodies, and cookies in either direction.

    Measured against v1.39.2 on the rule-diff corpus: 373 → 0 on crewAI’s cassettes, and no change across the other 24 repositories — which is the evidence the gate is scoped to recordings rather than over-reaching.

    v1.38.2 had already removed 149 of these through its 2048-character bound on entropy candidates and its word-boundary context hints; against v1.35.0 the same gate is 522 → 0. The smaller number is this release’s, and the larger one is only quoted to say which it is not.

    All 540 were read individually and not one was a credential the project holds: 234 Cloudflare __cf_bm/_cfuvid cookies, 148 continuation lines of base64 response bodies, 58 response ids, 44 embedding vectors up to 8,193 characters, 32 Gemini thought signatures, and 8 occurrences of PostHog’s phc_ project key — which the vendor publishes, and which SEC-661 was redesigned specifically to stop reporting. The generic entropy rule reported it anyway, out of a recorded response body.

    The gate applies to the entropy rules only: any rule encoding a vendor’s credential format still fires anywhere in a recording, request body included, which is where an OAuth client_secret sits in a recorded token exchange. See docs/design/recorded-http-exchanges.md, including the gap it leaves — a credential with no recognised vendor format, hardcoded into a recorded request body, is now reported by nothing.

  • SEC-572 is removed and SEC-562 is re-keyed. Both matched live_[a-zA-Z0-9]{32} under different vendor names, so one token near either word produced a finding named for the other — and neither vendor issues a credential of that shape. SEC-562 now keys on Checkout.com’s documented sk_ and sk_sbox_ prefixes; pk_ is deliberately absent because the vendor publishes the public key on purpose. SEC-572 had nothing to be re-keyed onto: Payoneer authenticates with OAuth2 client_id/client_secret and publishes no token prefix, length or charset at all. A rule cannot encode a format the vendor does not have.

    Stripe’s sk_live_/sk_test_ namespace (SEC-030) cannot collide with the new pattern, because [a-z0-9]{26,} cannot cross the underscore.

    SEC-572 is removed, not retired. A baseline entry, VEX statement or nox:ignore naming it resolves through the withdrawn-rule path added in v1.38.1.

Fixed

  • A leaked authorization: header written as a YAML list was never reported. This is why the change above was safe to make, and it had to land first.

    authorization: Bearer sk-proj-…     # reported
    authorization:
    - Bearer sk-proj-…                  # NOT reported

    SEC-082 allowed only whitespace between the key and Bearer, and Go’s \s crosses a newline but not a YAML sequence dash. An HTTP header may repeat, so every recorded exchange — and most header maps anywhere — writes them as a list. vcrpy does not filter headers unless filter_headers is configured, which makes an unredacted key the most valuable thing there is to find in a cassette.

    Had the suppression shipped first, cassettes would have gone quiet while remaining unchecked, and from the outside those two states are identical. Both block (- x) and flow ([x]) sequence forms now match, for SEC-082 and SEC-083, which fixes every YAML header map and not only cassettes.

  • SEC-080 reported its own remediation as the defect. “Generic password assignment” recommends environment variables or a secrets manager, and then reported exactly those: '{{resolve:secretsmanager:…}}', "${{ secrets.DOCKERHUB_TOKEN }}", "{{ upassword }}", "$hashed_password". Measured on the 25-entry rule-diff corpus: 12 → 0, each read individually, every one a reference, and no other rule moved in any repository.

    Only a value that is entirely a reference is refuted, because any literal part could be the credential. {{ 'hunter2' }} is a template that evaluates to a hardcoded literal; ${PW:-hunter2} has a hardcoded fallback that runs whenever the variable is unset; a bare $name is a reference only in a shell file, since YAML, JSON and Python do not expand it. All of those are still reported. placeholderCandidate’s comment had claimed to handle "${SECRET}" since it was written — its regex, <[^>]*>, never matched one.

  • A decoded finding bypassed the recording gate. DecodeAndScan adds its results past the ScanArtifacts refiners — 107 of crewAI’s cassette findings survived the gate that way, base64 OpenTelemetry payloads decoded out of request bodies and relocated back onto them. The recording gate is now wired through that path, because position-in-the-document is the one question the relocation preserves the answer to. The other refiners split in two: the positional ones must never apply (every decoded finding is inside an encoded segment by construction), and the value ones — placeholder, bare prefix — are an inconsistency on a constructed input (a base64-wrapped xxxx… AWS key is reported as SEC-002 critical) that was measured inert across all 25 corpus entries: no decoded finding reaches output anywhere. The exact fix is written at the call site, to be built when one is seen.

  • AI-019 reported a pinned model load as unverified. Its pattern ended at the opening paren and never saw the arguments, and its own comment conceded the absence it claimed to detect was not visible where it looked. Of 99 model loads across the fourteen pinned repositories, zero carry a pin — so this changes no count, and that is the measurement rather than an excuse: the rule was right about every one of them for a reason it could not give. A project that does pin now stops being told it has not. Separately, a bare pipeline(...) at the start of a line matched nothing, because the guard excluding conn.pipeline() is a character class and needs a character.

  • SLOP-001: two classes v1.39.2 did not cover. That release gated the JavaScript extractor so prose in a comment stopped being read as an import, and left the Python extractor ungated — and a docstring showing a caller what to type is the most common place in Python to write an import you are not making. Same gate, same keyword offset. Separately, no npm registry can serve a name containing $, { or }, so @ai-sdk/${pkgName} and ${moduleName} are not package names; the lexctx gate catches the common shape where the generated import sits inside a template literal, and this covers the rest of the class exactly rather than heuristically.

  • SLOP-001 read a codemod’s test fixtures as its dependencies. A file under __testfixtures__ is jscodeshift’s input/output pair: the test reads it as text and runs a transform over it, and nothing resolves or installs its imports. vercel/ai’s fixtures import other-pkg, not-ai and some-other-package on purpose, to prove the codemod leaves imports it does not own alone. 109 → 0 on the codemod corpus entry (109 on v1.39.2 and on main alike), and an A/B against main moves no rule in the other 24; on a full vercel/ai checkout, 36 → 3, the three left outside any fixture directory. Only a directory named exactly __testfixtures__ qualifies, and each exempted import is recorded as a refutation rather than dropped.

    Two larger-looking SLOP-001 classes in the same corpus — 39 vitest imports in three vercel/ai packages and 84 in crewAI’s utilities — were measured and are not defects: they exist only because the corpus scans a subdirectory, below the root manifest that declares them. A full checkout reports 0 and 1.

  • scripts/rule-diff.sh could lose corpus repositories in silence. The manifest was the loop’s stdin, so any child process in the body that read stdin consumed repo lines; those repositories were never scanned, and the ledger entries explaining their drops surfaced as stale — which is how a stale-entry error appeared on one invocation and not the next with no input changing. Reproduced against the shipped script with two local repositories and a stub binary that reads stdin: “no rule-level change across 1 repo(s)” with two in the manifest. The manifest now arrives on fd 3, and a new check asserts every manifest entry was reached, naming stdin as the suspect.

  • IAC-225’s remediation still assumed Ansible. The rule was deliberately renamed away from Ansible — its subject is any YAML mapping key whose name ends in password, and the widening was prompted by real hits in Kubernetes Secrets and a Cassandra config — but the remediation told every reader to run ansible-vault encrypt_string, which helps with none of those.

  • Two claims that nothing checked. The README said 1506 rules across five suites, 938 secrets, 50 AI and 500 IaC, while the catalog held 1491, 882, 55 and 482. Nobody wrote a wrong number — every retirement made the sentence a little less true, and the AI count had drifted the other way, which is the direction nobody thinks to check. It is now gated by a test. And TestRuleDeltaLedgerNamesRealRules rejected a rule-removed ledger entry on the premise that an ID belonging to no rule can never match a drop; that premise is false precisely for a removal, because the harness diffs the baseline binary against the candidate and the baseline still has the rule. The check now runs in both directions.

Documentation

  • docs/design/condition-dedup.md records a vocabulary gap: the cross-analyzer allowlist can say a pair of rules is legitimately different and the invariant test can say a pair is wrong, but nothing can say a pair should be merged. IAC-225|SEC-080 is the entry that shows it — its reason names two views of one hardcoded password rather than the two fixes the list’s own bar demands. It is not a live duplicate: the two rules land on the same line zero times across the 25 rule-diff corpus entries, and co-fire only on a synthetic fixture. An earlier revision of that note claimed otherwise without measuring the overlap; it is corrected, and says what measuring it found instead (below).
1.39.x 3 releases · Sep 2026

v1.39.2

  • Fixed

Prose in a comment is not an import. SLOP-001 matched import syntax anywhere in a file, so a sentence in a comment could be reported as an undeclared package — a slopsquat claim against text nobody installed.

Fixed

  • Prose in a comment is no longer read as an import. SLOP-001 collected import specifiers with regexes that match anywhere in the file, so a code comment containing from "..." was reported as an undeclared package — possible slopsquat. One TypeScript repository produced three of them, including a comment reading indistinguishable from "you have no groups", each blocking a pull request until it was dismissed by hand. JS/TS matches are now classified with lexctx and kept only where the keyword (from, import, require) sits in code, so the same text in a comment or a string literal is ignored. Python was already safe: its patterns are line-anchored.

  • --outdated says which ecosystems it resolves. The flag description read “Go only” while the currency pass had been resolving npm, PyPI, crates.io, RubyGems, Packagist and NuGet for as long as docs/usage.md documented seven ecosystems. Only the text an operator reads was wrong, so the flag understated what it does. Pinned by a test over the usage text.

v1.39.1

  • Fixed

A tsconfig path alias is first-party source. SLOP-001 read a scoped paths alias as an undeclared npm package and reported the import as a slopsquat candidate; the analyzer now reads the project’s own config.

Fixed

  • A tsconfig paths alias is first-party source, not a hallucinated package. SLOP-001 read @shared/types as a scoped npm package nobody had declared, and reported the import as a slopsquat candidate. The specifier has exactly the shape npm requires, so it cannot be told from a real dependency without reading the project’s own config — which the analyzer now does. compilerOptions.paths is collected from every tsconfig*.json and jsconfig*.json in the tree, comments and trailing commas included, and an import covered by a declared pattern resolves to first-party source instead of becoming a candidate. A bare "*" catch-all is ignored, since honouring it would silence the rule for the whole project. This is the non-empty-scope half of the narrowing that @/components already received: on one TypeScript project it removed 28 of 31 SLOP-001 findings, every one of them an alias.

v1.39.0

  • Added

Estate-wide blast radius. Two new commands describe a service and its nox attack runs to the intelligence service, which assesses what a vulnerability reaches across an organisation’s estate.

Added

  • nox intel components and nox intel evidence: the inputs to estate-wide blast radius. The intelligence service’s blast_radius capability assesses what a candidate reaches across an organisation’s estate, from the estate the organisation describes.
    • components describes one service. Its dependencies come from the scan, and reachability means the service’s own code imports the package. Capabilities are derived from sink calls in the service’s own code, each citing the call. Exposure, identities and data classes are declared by the operator. Test code is excluded, and the scan runs offline.
    • evidence binds nox attack run traces to a candidate the operator names. A run is marked deterministic only if a machine-checkable oracle decided it, and reproduced only if the determinism gate passed.
    • Both print what they would send and send nothing without --upload, which needs an organisation token in NOX_INTEL_TOKEN. An upload replaces only the named service’s components. See docs/intelligence.md.
1.38.x 5 releases · Sep 2026

v1.38.5

  • Fixed

Taint analysis stops trusting two kinds of call that looked like defences and aren’t. Findings can only increase, apart from one Java/Kotlin false positive that goes away: canonicalize-then-check code written with a chained normalize().

Fixed

  • A canonicalized path is no longer treated as a contained one. filepath.Clean, os.path.realpath, os.path.abspath, path.resolve, path.normalize, PHP and C realpath, and Java/Kotlin Path.normalize all cleared path-traversal taint on their own. None of them contains a path: Clean("../../etc/passwd") is still ../../etc/passwd, and realpath resolves a traversal rather than refusing it. The catalog said as much in a note on each (“must be paired with a prefix/allow-base check”), and the engine ignored it. They now clear TAINT-004 only when an allow-base check (strings.HasPrefix, filepath.Rel, filepath.IsLocal, startswith, startsWith, str_starts_with, strncmp, …) reads the canonicalized value in a branch of the same function. A check on the raw input before canonicalizing does not count, and neither does canonicalizing inside the sink call. Basename-style calls (filepath.Base, os.path.basename, …) strip the directory and still clear on their own. The pairing also fixes a false positive: Java and Kotlin Paths.get(src).normalize() followed by a startsWith check was reported, because the chained normalize was never seen.

  • Parsing a URL is no longer an SSRF sanitizer. url.parse, new URL, urllib.parse.urlparse, ipaddress.ip_address and URI.parse return the attacker’s host intact, so http.get(url.parse(input)) went unreported. A host allowlist is the defence, and it cannot be told apart from a scheme check by the calls it makes, so an allowlisted request is now reported and left to a waiver. Encoding a URL component (encodeURIComponent, url.QueryEscape, curl_easy_escape) still clears.

v1.38.4

  • Fixed

Fixed

  • A plugin version that needs a newer nox is not installed on an older one. The registry’s minimum_nox_version was written by nox plugin entry and read by nothing, so such a plugin installed cleanly and then failed registration on every scan. Resolution now skips those versions, choosing an older compatible one if there is one, and otherwise names the nox release required. The first plugin to need it is nox/freshness 0.1.0, released alongside, which requires 1.38.3.

v1.38.3

  • Fixed
  • Changed
  • Release history, corrected
  • Across the plugin fleet

The audit taken all the way out: not only this repository but the release artifacts, the organisation’s other repositories, and the posts about to be published. Every item found is closed here or recorded below as closed elsewhere.

Fixed

  • MCP-009 and MCP-010 no longer report an injection fed to an agent as tool metadata. Both rules describe “MCP tool metadata”. phidata’s guardrail cookbook passes input="Ignore previous instructions ..." to an agent to test its guardrail, and v1.38.2 reported each as tool poisoning. A match is now dropped only when the phrase is inside the string literal that is the value of an agent-input key (input, prompt, query, question, user_input, user_message, user_prompt) on the same line. A poisoned tool reaches a model through a description, instructions or a docstring, never through input=, and recall is witnessed on Cisco’s malicious example servers.

  • Supply-chain plugins may reach the Go module proxy and PyPI’s API. The track allowed npm and PyPI but not proxy.golang.org, so nox-plugin-freshness was rejected under the track built for it. *.pypi.org also never matched the apex pypi.org, where PyPI’s JSON API lives. Both are allowed, exactly.

  • nox plugin test works. It printed “not yet implemented” while listed in nox plugin’s usage. It now registers a built plugin under its track’s policy and runs its scan tool through the same code a scan uses, so it fails on what a scan would silently degrade.

  • nox plugin init scaffolds the pipeline the registry verifies. Scaffolds generated the retired shared pipeline — Go 1.25, Ed25519 signing, the SDK at nox v0.1.0 — which is why nox-plugin-freshness was red from its first commit. They now carry GoReleaser, keyless cosign and a plugin.yaml, pinned to the release that generated them.

  • A flag after a positional is honoured by every command. Twenty-two commands dropped it silently, the bug #103 fixed only for nox scan: nox diff . --base main ignored --base, and nox registry add <url> --name x — the order its own usage gives — ignored --name. -- still passes everything after it to a child process untouched.

  • The GitHub Action refuses a version that names no release. version: 1 fetched releases/download/v1/nox_1_*, an accidental June build, and scanned with it silently.

  • actions/remediate opens its pull request with a default checkout. With credentials persisted (the default), the PR step failed with Duplicate header: Authorization. The same fix ships in nox-remediate-action v1.0.3.

  • Every verification command in the docs works as written. VERIFICATION.md matched the signing identity as nox-hq; certificates say Nox-HQ, and the match is case-sensitive, so nothing verified. It now uses each tag’s exact identity and was executed end to end. The plugin guide described the retired pipeline, a track table missing half the tracks, and a registry URL that does not resolve.

  • The README and marketplace guide describe the plugins that exist. The example .nox.yaml required nox/taint-analysis and the plugin table listed it and nox/red-team as current; both were archived in early September, superseded by core. The table is now generated from the registry. Publishing instructions pointed at a registry-scaffold/ directory and a marketplace URL that no longer exist; they point at nox-hq/registry and nox-hq.dev/plugins.

Changed

  • Releases run only for full semver tags. v* matched the floating v1 tag, and a manual push of it once published a release.
  • Badge-refresh PRs enable auto-merge. The workflow always said they would; none ever did.
  • The rule-diff corpus gains eleven witnesses, so AI and MCP rule changes are no longer invisible to the gate that exists to catch them.

Release history, corrected

  • Provenance for v0.10.2 – v1.38.1 was regenerated on each release’s own tag and now verifies with slsa-verifier --source-tag: 62 releases, every one in that range except the seven below.
  • Eight attestations were false and are removed: v1.14.0 – v1.17.0 and v0.10.1 carried backfills generated on main that named a different commit as their source. They cannot be regenerated, because those tags’ copies of the workflow cannot run; each release page says so, as do the 18 releases that predate provenance.
  • The accidental v1 release (June 5 binaries versioned “1”) and five orphaned draft releases were deleted. The v1 tag, which the Action uses, is unchanged.

Across the plugin fleet

  • Archived plugins are deprecated in the registry. taint-analysis, red-team, risk-score and threat-explain were archived with READMEs saying “Do not install this plugin”, but the registry still offered them as current. All seven archived plugins are now deprecated, each with what replaced it.
  • Every plugin’s release notes verify. Their footer told users to verify with --signature checksums.txt.sig, a file the plugins do not publish. The release config is fixed in 14 repositories, and the command in 72 published release notes was rewritten for the files each release actually carries. All 66 verifiable ones were then run and verify; six early releases published a signature without its certificate, and now say plainly that it cannot be verified.
  • The CodeQL pin that did not exist is gone fleet-wide. 14 plugins pinned the same upload-sarif@33119e58 nox did; all now use v4.38.1. llm-triage also passed --output-signature, which cosign v4 removed, so its next release would have failed at signing.

v1.38.2

  • Fixed
  • Changed

What v1.38.1 claimed and did not have. It was released on the condition that nothing known was open, and the audit behind that claim checked the issue tracker, open pull requests and the recorded backlog, but not nox’s own self-scan, the repository’s security alerts, or the release’s provenance against its tag. Each of those had something. This release is the second pass, done the way the first should have been.

Fixed

  • Release provenance names the release. Every SLSA attestation recorded refs/heads/main, because it was generated by a workflow_run from main. The digests were right — each named the exact tagged commit — but slsa-verifier --source-tag vX.Y.Z failed for every release through v1.38.1: the provenance could prove which commit built a binary, not which release it was. The generator now runs from the tag-triggered release workflow, and a new job verifies every archive with --source-tag, which is the check a consumer runs. The old check asserted that an attestation existed; this one existed, and verified, and still could not answer the question that mattered. Releases up to v1.38.1 keep commit-bound provenance only.

  • The CodeQL upload action was pinned to a commit that does not exist upstream. github/codeql-action/upload-sarif@33119e58 is absent from github/codeql-action’s commits and git database, and none of its 289 v3 tags points at it; it still resolved in Actions, which is the shape of a commit reachable only through the fork network, or of rewritten history. docs/usage.md told users to copy it three times. Now v4.38.1. The remediation example pinned an annotated tag object rather than a commit, and four version comments named the wrong release for a correct SHA. All 24 pins were then checked: each exists upstream and its comment’s tag resolves to it.

  • The VS Code extension could not be packaged. vsce refused because @types/vscode (^1.136) exceeded engines.vscode (^1.91); CI only type-checked, so it stayed green. The types now track the engine floor — the code type-checks against 1.91’s API, so no supported editor is dropped — and CI packages the extension on every run.

  • A character-set definition is not a secret. The entropy detectors reported nox’s own base62 alphabet. Alphabets are now recognised by density (32+ characters, 75%+ of adjacent pairs consecutive), not by a single run: a run alone would also excuse a human password like Summer12345678!, which the existing tests caught. No change across the 14 rule-diff repositories.

  • The builder image and otel/sdk are current. The Dockerfile’s golang:1.27-alpine digest was two behind (Dependabot’s own bump had gone stale while open). go.opentelemetry.io/otel/sdk moves to v1.45.0 for GHSA-8wmf-6v46-5gfg; no nox binary was affected, since the SDK is only selected for a dependency’s tests and is not linked.

Changed

  • Manually dispatched publishing workflows wait for approval. Homebrew publishing, the slopfeed publisher (manual runs only; its schedule is unaffected) and provenance backfill run behind a protected release environment. The six dispatchable workflows that publish nothing carry waivers stating why. nox’s self-scan is now 0 active findings and 0 degradations.

v1.38.1

  • Fixed

v1.38.1 is the release. v1.38.0 was tagged but never published, and is retracted in go.mod; everything described under it ships here. The tag recorded by the Go checksum database can’t be moved, so the fix needed a new version.

Fixed

  • A waiver naming a withdrawn rule is explained wherever it appears. The unused-waiver check runs on two paths: a sweep over files with no findings, and a pass over files that have some. Only the sweep consulted the tombstone registry, so a waiver in a file with any other finding — the ordinary case in a real codebase — got “check the rule ID” instead of the withdrawal. v1.37.0 shipped with the gap, which is why its tombstones worked in its release check and not in general: that check’s fixture had no other finding.

    Found by verifying the v1.38.0 release binary against a fixture that did. Both paths now call one function, so they cannot drift apart again, and the new test drives a scan down each path rather than testing the helper. Also verified end to end in one scan: a waiver, a baseline entry and a VEX statement, each explaining its withdrawn rule.

1.37.x 1 release · Sep 2026

v1.37.0

  • Added
  • Fixed
  • Changed

The theme is that a rule disappearing is itself a conclusion, and nox now preserves the evidence for it.

v1.36.0 withdrew AI-029 and AI-041 outright — retracted, not renamed, because their condition was not a security condition. It shipped without telling anyone who had written those IDs down. This release fixes that retroactively and then makes the underlying mistake harder to repeat.

Added

  • A withdrawn rule now leaves a tombstone, not a silence. Every place an operator writes a rule ID down explains the withdrawal instead of going quiet: a nox:ignore waiver, a baseline entry, and a VEX statement.

    Before this, a waiver naming AI-029 produced the generic “waives a rule but matched no finding” degradation, whose advice — check the rule ID, check the line, check whether the finding moved — was wrong in every particular, because the rule was gone. Baselines and VEX statements said nothing at all.

    Each tombstone names the release that withdrew the rule and why, so somebody upgrading past v1.36.0 gets the explanation for a rule that vanished in a release they have already installed. A rule merely RETIRED into a survivor is deliberately unaffected: its waivers still resolve through the survivor’s identity, and telling an operator it was withdrawn would be false.

  • nox rule-review — a maintainer-facing report of rule propositions worth a human read. It surfaces three signals independently and prescribes nothing: no rule is scored, ranked, suppressed, retired or changed by it.

    Two of the three are ingested rather than recomputed, because nox already measures them: single_construct from the metamorphic sweep’s triage, and the raw-findings-versus-authored-occurrences collapse from nox bench --json.

    The third is new. A rule whose remediation recommends the value its own trigger requires is a proposition that did not survive being written down twice — AI-029 flagged presence_penalty = 0 while advising “Set presence_penalty (-2 to 0)”, a range containing the flagged value, and it fired 446 times on the pinned corpus before anyone read the two strings next to each other.

    Measured against the catalogue as it stood before that withdrawal (1,498 rules), the check reports AI-029 and nothing else: one true positive, zero false positives. The wider definition tried first — “the rule’s pattern matches its own remediation” — reported 35 rules, essentially all of them correct remediations quoting the defect in order to say remove it, and missed AI-029 entirely; it is kept as a negative fixture so it is not proposed again.

    The honest limit is recorded too: AI-041, withdrawn in the same release, is surfaced by none of the three signals, because “this condition is not a security condition” is not something any of them detects. What has been demonstrated is precision, not coverage. See docs/design/rule-review-candidates.md.

  • A built-in rule can no longer prescribe as its remedy the condition it reports as insecure. TestNoBuiltinRuleContradictsItsOwnRemediation fails the build on one that does.

    Only this signal is promoted to a gate, on its own evidence: it is decidable from the rule alone, measures 1 true positive and 0 false positives across 1,498 rules, and encodes an invariant nobody argues with — following a remediation to the letter must make the finding go away. The other two signals stay informational, because single_construct reports a gap in the corpus and prevalence collapse reports that a corpus repeats what a rule correctly detects; neither is about a rule being wrong.

    The gate runs over the built-in catalogue only, and deliberately not inside CheckCoherence, so an operator’s own rule with loose remediation wording can never fail to load and take their scan with it.

  • A built-in rule can no longer advise a value that a DIFFERENT built-in rule reports as a defect. TestNoBuiltinRuleAdvisesWhatAnotherFlags fails the build on a pair that does.

    The same invariant as above, extended across the catalogue: following nox’s own advice must not produce another nox finding. AI-023 and AI-041 were such a pair for months — AI-023 advised “Use top_p of 0.7-0.95”, AI-041 fired on top_p: 0.95 — and it was resolved only incidentally, when AI-041 was withdrawn in v1.36.0 for an unrelated reason.

    The check is measured by construction rather than inferred: values are sampled from the endorsed range, written out as real assignments, and run through the other rule’s compiled pattern, so it cannot be wrong about what a pattern covers. That caught an error in the first hand-written account of the pair, which named the wrong rule as the adviser.

Fixed

  • nox plugin init no longer writes a dead waiver into the generated plugin. cli/templates/Dockerfile.tmpl carried nox:ignore IAC-002, which is copied verbatim to the new plugin’s Dockerfile, so anyone scanning a freshly initialised plugin was told their Dockerfile waives a rule that does not fire there. Measured both ways on the rendered file: 0 findings and 7 suppressed either way, with the degradation present only in the version carrying the waiver. A second dead waiver was removed from actions/remediate/action.yml.

Changed

  • nox rule-review shows prevalence-collapse rows that clear two bars by default — a copy factor of 2 and at least 3 duplicated lines; --all shows every measured row. The bars are complementary: a 2x built from four findings is a single duplicated line, and 673 findings over 667 sites is a factor of 1.009 on six, and neither number alone withholds both.

    3 was swept rather than picked: every copies floor from 3 to 36, and every findings floor from 5 to 53, selects the same 8 rows. The cutoff is presentation, not adjudication — the measurements stay canonical, every collapsing rule is still computed and counted, and the report states how many rows it withheld so a filtered list never reads as a short one.

1.36.x 1 release · Sep 2026

v1.36.0

  • ⚠️ Behaviour changes
  • Fixed
  • Added

One theme, found five times in different clothes: a keyword that matches as a substring is not evidence. Every instance below was measured on real repositories rather than argued from the code.

The largest is that 151 secret rules matched nothing but a character class and a length, with the vendor’s name as the only thing tying the value to a credential. Proximity was the previous defence — the vendor word within 4 lines and 512 characters — and crewAI’s recorded cassettes showed it is not enough: a Content-Security-Policy header listing CDN domains sat three lines above an HTTP ETag, and five separate rules reported that one ETag as their vendor’s credential. Against a corpus of adversarial HTTP traffic — ETags, request and trace ids, cache keys, session cookies — 116 of 151 fired on something that is not a credential and never was. The vendor name now has to BIND the value: <vendor>… = "<shape>".

SEC-583 is the one worth reading. Its pattern is [a-zA-Z0-9]{20} gated only by the keyword “zuora”. Exactly one file in crewAI contains that string: a 1.2M recorded cassette holding a base64-encoded PDF, in which the letters zUOrA occur by chance at byte 50087 inside the payload. Every 20-character run in that file — a base64 PDF has thousands — became a High-severity Zuora API key. 6,160 findings from one accident. The rule text never changed.

SEC-604 inverted the scanner against itself: it reported all five of certbot’s pinned GitHub Actions — uses: mattermost/action-mattermost-notify@ae31bb6f… — as Mattermost API keys, because 32 characters of a git SHA sat next to the action’s own name. Pinning an action to a SHA is the practice IAC-013 recommends.

⚠️ Behaviour changes

  • AI-029 and AI-041 are retired. Both reported LLM tuning values as security findings. Repairing their matchers first is what made the question answerable: AI-041 fell to zero once it stopped treating exactly 0.9 as “above 0.9”, and AI-029’s residue was two lines of one documentation sample — frequency_penalty=0.0 and presence_penalty=0.0, which is the OpenAI default, flagged by a rule whose own remediation advised setting it ”(-2 to 0)”. Neither carries a security proposition. If you pin either rule ID in a baseline or waiver, it will no longer resolve — they were retired outright rather than into a survivor, so no alias carries them.
  • Vendor secret rules require a binding, not a nearby name. A credential named for its vendor is still reported, and the vendor stem is matched as well as the literal keyword, so <vendor>_token and <vendor>_api_token are caught where they previously were not by anything. A vendor word merely near a generic token no longer reports.
  • Entropy candidates have an upper bound of 2048 characters. There was none, and recorded cassettes produced findings spanning 8,192 and 37,392 characters — base64 response bodies reported as possible secrets. The bound is on the candidate, not the file: a 40-character key inside a 2MB cassette is still found. Derived rather than chosen: the longest credential any rule models is 1,000 characters.
  • Secret-context hints match words, not substrings. input_tokens supplied “token” and monkey supplied “key”, and since input_tokens appears in every Anthropic API response, every recorded cassette line had its entropy threshold lowered. Splitting is on punctuation and camelCase, so apiKey, API_KEY and signing_key all still hint.

Fixed

  • AI-015 joined two unrelated lines, failing the corpus-wide metamorphic audit on main since 2026-09-07. \s matches a newline in Go, so a comment naming innerHTML above a docstring mentioning an LLM matched as 'innerHTML\n\"\"\"A minimal LLM'. Rendering happens on one line.
  • Stacked pull requests ran no tests at all. ci.yml and metamorphic.yml filtered on branches: [main], so a PR targeting any other branch ran neither — not Lint, Test, Build, Security, nor the metamorphic invariance gate. A green tick on a stacked PR stood for strictly less than the same tick on a PR into main, and nothing said so.

Added

  • The rule-diff corpus can pin a subdirectory, fetched with a sparse checkout. crewAI’s recorded cassettes are 19.9M of a 370M repository: 3.4s to fetch this way against 4–5 minutes to clone whole. Pinning them surfaced twelve narrowings the existing corpus was structurally unable to witness.
  • An entropy control in the rule-diff corpus. anthropic-sdk-python carries 62 standing SEC-161 findings; no previous entry witnessed that family at all.
  • Bench reporting separates raw findings from authored occurrences, and counts distinct security conditions for rules that declare what their finding is about. AI-029 measured 446 raw findings and 26 authored occurrences on crewAI — the raw figure ranked it among the worst rules in the set, the authored figure showed it was one documentation page.
1.35.x 1 release · Sep 2026

v1.35.0

  • ⚠️ Behaviour changes
  • Added
  • Fixed
  • Changed
  • Guarantees now under test
  • Unchanged

Seven changes and three defects, all one theme: a scan now says what it did not establish, per finding, instead of leaving silence to be read as an all-clear.

The largest is that every finding carries the questions nobody answered about it. A YAML finding and a Go finding in the same scan are not equally well understood — nox has no YAML lexer, so it cannot tell a match in a comment from one in a value — and until now nothing in the output said so. Competence is grouped rather than repeated: measured, 53 findings on the precision suite resolve to 4 distinct profiles and 62 on nox’s own tree to 3, because it varies by language and analyses rather than by finding.

call_graph and entry_point were provided by nothing at all. They are now answered for Go, with a witness that is a real chain of call expressions. What that analysis refuses to do is the design: a syntactic graph cannot see interface dispatch, function values, generics, reflection or generated code, so it never reports that no path exists. Every scope it builds carries a limitation, which makes reach.Refute decline to build a negative from it.

Three defects were found by writing the measurements rather than by reading the code, and one of them was hiding findings.

⚠️ Behaviour changes

  • A baseline entry now accepts one finding, not every finding sharing its fingerprint. Two genuinely distinct findings can share a digest: the v2 fingerprint is sha256(rule_id, path, message), and a rule whose message is a static description — most IaC rules — produces one digest for every occurrence in a file. Accepting one of four continue-on-error steps accepted all four, and accepted the fifth added a month later: born baselined, with nox scan reporting 0 findings (3 suppressed).

    If your baseline has fewer entries than there are findings sharing a fingerprint, the surplus will start appearing. They were suppressed without anyone having accepted them. Run nox baseline update . to accept the ones you want and prune the rest. A baselined finding that MOVES in its file still matches, which is what the v2 fingerprint was designed for and what a fix keyed on position would have cost.

  • Exploitability is now on every finding, not only on scans that recorded reasoning. It is POTENTIAL for every static finding, always — the state comes from the run outcome, and a scan executes nothing. Saying so is the point: a finding silent about never having been validated reads as a stronger claim than it is. EvidenceConfidence stays conditional, because that one IS derived from the ledger and an empty ledger aggregates to LOW.

  • policy.uncertainty defaults to fail where a requirement is declared. With no require_capabilities listed nothing changes — that is every existing repository. With one listed, an unmet requirement now exits non-zero rather than warning: declaring the requirement is itself the deliberate act, and answering it with a warning nobody gates on is how a project stops finding out that what it relies on stopped being answered. Set uncertainty: warn explicitly to keep the signal without the gate.

    §1.5.3 specified this default moving “only after a release where the warning names the flag”. That was 1.32.0; 1.33.0 and 1.34.0 have shipped since.

  • A waived finding no longer grounds an attack hypothesis. A nox:ignore, a baseline entry or a VEX statement did not stop nox attack plan building one, so nox attack run --authorize would fire real payloads at a live target for something explicitly accepted. Waived findings now appear in the plan’s skipped list saying so.

Added

  • findings.json carries meta.capabilities: what each analysis can establish on this installation, and how many subjects it actually concluded about. provided and answered are separate fields because they answer different questions and come apart the moment something fails at runtime.

  • results.sarif carries the same as invocations[].toolExecutionNotifications at level note. SARIF has no other slot for a statement about the run rather than the code, and without it Code Scanning renders a scan that could not look and one that looked and found nothing identically.

  • Every finding names a competence profile: the set of questions unanswered about it, resolved through meta.competence_profiles. In SARIF the list is resolved inline as properties.nox_unevaluated, because a profile ID is a nox concept a SARIF consumer cannot look up.

  • A dependency finding can say whether your code actually calls the affected package. The applicability ladder climbs from symbol_used — the build links it, which is as far as go list -deps reaches — to call_reachable, with the chain of calls from an entry point as the witness: main -> digest -> crypto/md5. Only a concrete entry point (main, init) climbs the rung: an exported function is reachable by somebody in principle, which is the rung above, and conflating them turns “the code exists” into “the code runs”.

    Not finding a path leaves the rung unclimbed rather than refuting it. A syntactic call graph cannot see interface dispatch, function values or reflection, so an empty search is unknown — and a project with no Go module is unsupported, which is a different answer and stays a different one.

  • core/callgraph answers call_graph and entry_point for Go. A finding can carry call_path and entry_kind, which distinguishes concrete (main or init), test and exported — an exported function is reachable by somebody, which is not evidence that anybody does.

  • nox scan --emit-hypotheses <file> writes the active-testing questions a scan raises: subject, entry point, attacker input, trigger condition, expected oracle, assumptions, and the open questions about that finding. Nothing is tested; running them stays behind nox attack run --plan ... --authorize.

  • nox analysis-capabilities states its standing limits unconditionally. They used to print only alongside a missing capability, which became a silent regression the moment the last one was implemented: a full matrix with no caveat reads as a full answer.

  • reach_limitations on a finding: the machine-readable half of reach_scope, which is prose. Parsing English back out is not a contract.

  • Every negative nox why renders says what it holds within. A refutation is a universal claim — “this does not hold” — and only as good as the search behind it, and a ledger refutation used to render as a bare sentence that reads as settled. Where a refiner named its blind spot, the blind spot is shown: a taint refutation now reads “established by the python taint engine, over this file, which cannot see interface dispatch, function values, reflection, or a sanitizer applied in another file”. Where none was recorded, the negative is still qualified generically, so no path through the renderer can produce an unqualified one. Supporting claims are deliberately not hedged the same way: one witness settles an existential claim however narrow the search was.

  • meta.stage_accounting: what each rule family produced and what became of it — candidates in, promoted, refuted on evidence, withheld by configuration. A finding count cannot tell a family that refines from one that does not; both produce findings. The four counts partition the candidates exactly, and refuted is kept apart from withheld because a deduplication is not a judgement about whether a finding was true. Present only when the scan records reasoning, and deliberately carrying no timing: this artifact is byte-identical across runs by contract and a duration is not.

Fixed

  • Undetermined reachability was recorded as never-evaluated (#603). goSymbolReferenced returns ok=false for every undetermined outcome and the analyzer gated its whole metadata block on that value, so “asked and could not tell” arrived looking exactly like “nobody asked” — and the Undetermined arm of the switch that maps it was unreachable code. The common path is an advisory with no ecosystem_specific.imports; only the Go vulndb populates that field, so every GHSA-sourced Go advisory landed there. No vulnerability was hidden — both states are non-conclusive and neither may suppress a finding — but the two call for different responses and the operator was told the wrong one. The suite had declared want_state in every fixture since it was written and nothing asserted it.

  • A baseline entry absorbed every future finding sharing its fingerprint (#609). See the behaviour change above.

  • attack plan built hypotheses from waived findings (#611). See above.

  • Adjudication is scoped to the subject being decided, in the scan and across every path in core/attack. The run path already was; replay, regress and the MCP path were not, and were safe only by accident of each building a fresh single-purpose ledger.

  • Four analyzer families dropped findings without recording why, which is the pattern core/reasoning exists to end. Stage accounting reported each as refuting nothing — true of the ledger, false of the analyzer. IaC’s comment, kind-reference and artifacts-always filters (added in 1.34.0’s successors) now record; the taint engine records the sanitized flows it clears, going from 0 refutations to 6 on the precision suite; SLOP records why an import resolved, and refutes 18 against 3 reported. DATA refines nothing and reporting zero for it is correct.

    Nothing reported changes: these are recordings of decisions the analyzers already made. What changes is that a recognizer clearing the WRONG thing no longer looks identical to one that had nothing to clear.

  • A plan with nothing to attempt serialises as [] rather than null.

  • An unnamed entry point no longer renders as “the entry point is reachable by an attacker” — a hole where a value should be, rather than the assumption it is.

Changed

  • The Phase 4.1 divergence measurement was re-run. “15 of 37 findings diverge, all over-claimed” was measured on 2026-08-30, quoted in four documents, and carried through three PRs that changed IaC output. It is 17 of 53, and the “all over-claimed” half stopped being true: IaC rules author LOW while their static evidence aggregates to MEDIUM, so 1 of 17 here and 14 of 19 on the refutation suite run the other way. Pinned by a deliberately brittle test whose failure says to re-measure rather than edit the constant.
  • Two documented claims were corrected in place: Finding.Exploitability said POTENTIAL meant “static evidence exists and no attack path was constructed” (the first half was never carried by the value), and ScanOptions.RecordReasoning said a scan with it off is byte-identical to one with it on (already untrue when written).

Guarantees now under test

Behaviour that was already intended and is now asserted, so a future change cannot quietly lose it:

  • A retracted claim stops contributing everywhere nox adjudicates, not only in intel: it does not move the confidence, the rationale never cites it, and a retracted refutation stops suppressing — so withdrawing an argument restores the finding rather than leaving it hidden behind something nobody stands behind any more.
  • Missing evidence is never a refutation. Deduplication, a disabled rule, an excluded path and a construct the analysis cannot follow are all recorded with no polarity. An entropy rule that matched a real credential matched a real credential; it is gone because five reports of one secret is noise, and recording that as evidence against it would be a fabrication.
  • The call graph never reports that no path exists, in any language.

Unchanged

Detection 231 TP / 0 FP / 0 FN and refutation 37 / 0 / 0 across every change in this release. Every one adds output or corrects a state; none removes a finding.

1.34.x 1 release · Sep 2026

v1.34.0

  • Added
  • Fixed
  • Changed

Ten changes, eight of them defects nox found in itself. The theme is one shape: a check that never ran, reported identically to a check that passed.

The largest is recall. IaC absence rules could not see a Kubernetes manifest embedded in a YAML block scalar — the block’s contents are a string value of the outer document, not a document — while pattern rules, being regexes over the file, carried on matching. Half the ruleset evaluated, and nothing in the output said the other half had not. Every workload deployed through a wrapper that embeds its manifest (RollOps RolloutConfig, Argo CD Application with inline manifests, a Helm template rendered into a ConfigMap, a Flux patch) had its hardening checks silently skipped while the scan reported clean. On three real repositories that deploy this way, 57 findings appear that no scan had ever reported: missing PodDisruptionBudgets, absent pod anti-affinity, missing liveness and readiness probes.

Dependency applicability stops being Go-only. npm, PyPI, Cargo, pub and RubyGems now climb the ladder from affected_version to symbol_used when the workspace imports the vulnerable package. It only ever climbs: a package is frequently reached through a dependency, which parsing the repository’s own files cannot see, so a negative answer establishes nothing and never refutes.

Three findings came from nox failing its own gate on an unrelated pull request.

Added

  • Dependency applicability for npm, PyPI, Cargo, pub and RubyGems (core/depimports), climbing-only by design. Maven, NuGet, Packagist and Hex are deliberately excluded: their imports name a namespace the manifest does not carry, so a match would be a guess.
  • IaC absence rules evaluate Kubernetes manifests embedded in YAML block scalars. Only the absence rules are re-run over an extracted document — pattern rules already match inside the block, so running them again would double-report — and a dedupe keyed on (rule, line) covers the absence rules whose anchors already reached inside.
  • A metamorphic relation, embed_in_block_scalar: wrapping a Kubernetes manifest in a block scalar must not change which rules fire. It reports nine violations against 1.33.0 and none against this release, and now runs on every pull request and in the weekly corpus audit against every rule.
  • nox plugin call accepts typed arguments: key:=<json> sends a bool, number, null, array or object. key= still sends a string unconditionally, so nothing that works today changes meaning.
  • A scan reports a required plugin it cannot invoke. A plugin whose tools are neither named scan nor declare requires_scan_context was loaded, counted as registered, and never called — indistinguishable from a detector that ran and found nothing. nox/red-team and nox/grc are both in that position.
  • The plugin invocation contract is documented in docs/plugin-authoring.md. It existed only in cli/plugin_scan.go and plugin/host.go, and two published plugins were built without it.

Fixed

  • IAC-464 matched the word namespace rather than the Namespace kind. Built on a dotall, case-insensitive prefix, it fired on the namespace: field that nearly every namespaced manifest carries: a ConfigMap declaring namespace: example was reported as “K8s Namespace defined”. On a real Kubernetes repository this is 9 findings to 1, and the medium-severity total 57 to 48. Severity drops to Info, which is what the description always said.
  • IAC-374 retires into IAC-360. The two declared a byte-identical pattern and description, so one restartPolicy: Never reported twice. The alias keeps waivers written against IAC-374 matching.
  • rules.go and rules_expanded.go share one conversion. Each had its own, and the second silently omitted retires, extraMetadata and the whole absence block — so a retirement declared in that file compiled, passed the dedup test’s validation, and was then dropped on the way to the engine, un-waiving findings in every consuming repository.
  • plugin_policy is a key nox recognises. The strict-config check decoded against core.ScanConfig while the schema lived in package plugin, so every scan using it reported the key as unrecognised over an impact line reading “they are ignored” — advice that, followed, would delete a sandbox boundary the operator had deliberately set.
  • nox plugin call --input is honoured after the positional arguments, where the usage string says to put it. flag.Parse stops at the first non-flag argument, so it fell through to the key=value loop, was accepted as a key named --input, and the tool ran with no input at all. A --prefixed argument that is not a known flag is now an error rather than a tool argument.
  • A nox:ignore directive inside a markdown inline code span is documentation, as it already was inside a fenced block.

Changed

  • Rule counts drop by one: 490 to 489 IaC rules, 226 to 225 expanded, 1535 to 1534 in the catalog. Coverage is unchanged — the retired condition is still reported, once.
  • TestStartBinary_TokenRoundTrip no longer asserts a 10-second cold start. Production allows 30 seconds by fallback and one to five minutes by track, so the test failed on a constraint no caller imposes — and only under -count=1, which is what the pre-push gate runs.
1.33.x 1 release · Sep 2026

v1.33.0

  • Added
  • Changed
  • Fixed

NOX Intelligence becomes the default advisory source. Every online nox scan now asks https://intel.klarlabs.de which advisories match each dependency and verifies the answer against OSV.dev; a withheld record is restored from OSV and reported as a degradation, and an unreachable service degrades to exactly the OSV.dev scan nox always ran. What leaves the machine is unchanged — the (ecosystem, package, version) list OSV.dev always received — and contribution stays a separate, opt-in decision. The complete account is docs/intelligence.md.

The rest of the release is what measuring on real repositories found. Taint analysis resolved imports and aliases — the ordinary from os import system, const { exec } = require(…) and (:require [clojure.java.shell :as sh]) were invisible, so the engine was inert on most real Python, Node and Clojure code — and a real-repo noise review took npm/cli from 238,770 findings to 11,083 with rule fixes that each carry a corpus before/after. IAC absence rules parse CloudFormation, Kubernetes and ARM documents instead of bounding a span with a regex, and resolve the companion object (a VPC’s flow log, a Deployment’s PodDisruptionBudget) across files. The rule-behaviour gate can now see IAC, Python and JavaScript, which it previously cleared by absence of coverage.

The release was gated on an end-to-end journey against pinned public repositories (npm/cli, pipenv, gin, koel, fluffychat) and the production service: 71 checks across the default path, opt-out, endpoint override, precedence, unreachable service, --offline, --no-osv, every output format and determinism. Three of the fixes below were found by it and by nothing else.

Added

  • scan.intelligence.disabled: true asks OSV.dev directly; the service is never contacted. --offline still asks nobody.

  • NOX_INTEL_ENDPOINT is honoured by nox scan, not only by the nox intel subcommands, so a self-hosted service is named once. Resolution order is disabled > scan.intelligence.endpoint > NOX_INTEL_ENDPOINT > compiled-in.

  • scan.osv.base_url names the reference database used for verification, for self-hosted OSV mirrors.

  • Tests that assert the three states from the wire: service and reference, reference only, nobody (TestScan_IntelligenceIsTheDefaultSource, TestScan_IntelligenceDisabledAsksTheReferenceDirectly, TestScan_OfflineAsksNobody).

  • docs/intelligence.md: what a scan sends, to whom, and how to turn it off at every level. Roadmap Phase 11 plans the gaps from where the market moves.

  • Pipfile.lock, uv.lock and pubspec.lock are parsed. The release E2E measured the gap: pipenv’s own lockfile (110 packages) produced zero components and zero advisories, and nothing said why — an unrecognised lockfile is not a degradation, it is simply not a lockfile. Hosted pub packages resolve to the pub ecosystem; editable/virtual/directory uv entries and path/git/sdk pub entries are skipped.

  • Taint analysis resolves imports and aliases. Python import os as o / from os import system, JS/TS const cp = require(…) / const { exec } = require(…) / import { exec } from …, Clojure :as/:as-alias, Elixir alias …, as: and C# using X = … all reach the catalog’s qualified sink. Before, a sink matched only when the local name equalled the module name: 1 of 3 Python idioms and 1 of 5 Node idioms fired. Expansion is additive — nothing that matched before can stop matching — and a call written once is one flow however many catalog spellings resolve for it. Rust already resolved use … as and is untouched; languages without a checked import syntax keep today’s behaviour rather than acquiring a guess (#561, #566).

  • Prompt-injection dataflow reaches Go and .NET: Chat.Completions.New, Messages.New, CreateChatCompletion, GenerateContent, InvokeModel are prompt sinks. With them, the Go extractor reads identifiers inside composite literals — a tainted value assembled into a struct, slice or map literal was invisible to every Go sink, not only the AI ones (#560).

  • capability.ConstantEvaluation is provided, across fifteen languages: Go by go/ast (const), twelve recognizer languages by keyword (const, final, val, readonly, …) at static strength, Python and Ruby by the bound-once ALL_CAPS convention at heuristic strength. A prompt-shaped name bound to an immutable literal no longer fires AI-006 (#558).

  • IAC absence rules parse CloudFormation, Kubernetes and ARM documents (core/rules/structural, yaml.v3, no new dependency) and read the attribute by path: 24 of 57 absence rules migrated, the other 33 placed and named. A YAML-anchored encryption block no longer counts as unencrypted, and IAC findings carry their first KindStatic evidence (#553).

  • Seven cross-resource IAC rules resolve the companion object by ref, label selector, namespace or ARM child linkage instead of searching the file for its name — so a PodDisruptionBudget selecting app: api no longer protects every workload in the file, four unprotected Deployments are four findings, and FlowLogsEnabled: false is not a flow log. Undecidable linkage falls back to the text path (#554). The index spans every file the scan read, so a budget in pdb.yaml clears the Deployment in deployment.yaml; the cross-file pass can only remove a finding, never add one (#559).

  • Secrets deduplication records what it dropped: a withheld claim on the dropped candidate and a relation to its survivor, so nox why can tell “found nothing” from “found it five times and reported it once”. A dedup drop is not a refutation — the dropped finding is true — and is not recorded as one (#556).

  • Taint recall across languages, measured per language: shell pipelines and wrappers (xargs, sudo, env) carry taint; Dart fields and containers carry it across methods; Clojure follows partial, comp and as->; a sanitizer on the binding line (n = int(request.args.get("n"))) counts in every recognizer language, as it already did in Go; catalog Source.not_for marks the sink classes a source’s value can never reach by type (#564).

  • The rule-behaviour corpus gains kubernetes/examples and aws-cloudformation-templates (#555) and certbot/certbot and shelljs/shelljs (#562). It carried no IaC document and no Python or JavaScript file, so “no rule-level change” after a change to those families was a pass by absence of coverage. ARM and Terraform remain uncovered, and the manifest says so.

Changed

  • nox intel login/register/add-operator/invite/enroll --endpoint defaults to the endpoint scans query rather than an empty string.
  • docs/design/intelligence-service.md no longer describes the service as proposed.

Fixed

  • An unreachable NOX Intelligence is reported as unreachable, not as withholding. nox-core v0.3.0 builds the verifying source so the service’s own network failure is seen for what it is: the scan carries an intel_unreachable degradation naming the endpoint, is answered by OSV.dev alone, and keeps every finding. Before, the empty answer of a dead endpoint was verified against the reference and reported as intel_suppression — “withheld 4 record(s) … cannot be trusted” — which indicted the service for being offline (TestScan_UnreachableIntelligenceIsAnsweredByTheReference).

  • PHP dependencies are queried for advisories at all. nox-core v0.2.4 maps composer to Packagist (and pub to Pub, hex to Hex); before, an ecosystem missing from the map was filtered out of the batch silently, so a composer.lock project reported zero advisories with no degradation. koel’s lockfile now reports four league/commonmark advisories.

  • DATA-001 findings are dropped on generated paths, alongside AI-* and MCP-*: a lockfile lists its authors’ e-mail addresses by design, and 239 DATA-001 findings on one composer.lock were the lockfile, not sensitive data in code. The same address in a source file is still reported, and the other DATA-* rules still fire in a lockfile: a real one there is a real leak.

  • nox scan --no-cache is accepted again, as its help text promises; it exited 2 with flag provided but not defined (#567).

  • Real-repo noise review, each with a corpus before/after in its commit: SEC-161 SRI hashes, SEC-046 PyPI tokens, SEC/IAC password patterns stopping at line end, DATA-001 e-mails in fixture trees, SLOP-001 Python imports, TAINT-005 setTimeout with a function, SEC-073/074/076/085 templated URL passwords, config-field matches ending on a comment line, multi-line match reporting. npm/cli 238,770 → 11,083, certbot 199 → 130, httpie 143 → 61, pipenv 209 → 113; all twenty precision suites green (#564).

1.32.x 1 release · Aug 2026

v1.32.0

  • Added
  • Changed
  • Fixed

Two programmes in one release: nox became evidence-native, and its dynamic validation gained a domain model for reachability, verification and reproduction. Nothing breaks — every nox scan behaves as before, and the new surfaces are opt-in — so this is a minor release despite its size. What changed is that nox now records why it believes each finding, can be asked, and can prove it did not silently turn an unknown into an all-clear.

Added

  • nox why <finding> answers eight questions about a finding, deterministically (#526, #528). What was observed, why it matters, what supports it, what argues against it, what was not evaluated, the potential impact, whether it affects this application, and what to do. The two in bold are the ones a scanner usually leaves out: an analysis that never ran is a gap, not a limit, and silence about it lets a reader assume everything was looked at. It reads only what the scan established — every sentence traces to a claim, a capability state, or a rule’s own metadata — so the same finding always produces the same answers. nox explain remains the model-written counterpart; only one of the two can be put in front of an auditor. Available over MCP as the why and analysis_capabilities tools.

  • nox scan --evidence-out evidence.json keeps the evidence, and nox replay re-derives the verdicts from it (#525). A scan gathers evidence for and against every candidate and discards it — carrying it inline costs about 2.4× the size of the findings. --evidence-out keeps it; nox replay re-derives every verdict from that file and nothing else, not the repository, not the rules, not the network. That is what makes “does this evidence still support this verdict?” answerable months later, when all three have moved on. A divergence under a newer adjudicator is reported as a change, not a defect.

  • nox why and nox attack plan name what nox could not establish. The reachability vocabulary (#533) distinguishes six propositions from “the package is linked” to “an attacker-controlled path was observed”, and a scan that stops climbing says where and why it stopped, with the analysis limitation named — reflection, dynamic loading, FFI (#535). A finding’s explanation closes with the cheapest open question something on this installation could actually answer (#543).

  • policy.uncertainty and policy.require_capabilities gate on what nox did not determine (#509, #510, #522). A project can declare that its triage depends on an analysis — reachability, taint — and be failed, loudly, when that analysis stops being answered. The gate reads what this scan actually established, not merely what the installed build could establish: a scan whose advisory source was unreachable no longer satisfies a reachability requirement it never exercised.

  • nox attack plan --evidence carries the scan’s evidence onto each hypothesis (#545). The scan produces the hypothesis; the attack fills in the observation. A plan now carries the typed subject, the scan’s ledger, the attacker-controlled input, a suspected trigger condition, the expected oracle, the assumptions nox is making, and the questions still open — so nox attack no longer rediscovers, badly, why nox thought a finding worth testing.

  • nox attack run reports hypotheses resolved per unit of effort (#547), not coverage. A run that fired many probes and decided nothing now reads worse than a small run that decided something, and a run that decided nothing says so rather than printing a number a reader might take for a clean result.

  • GitHub tokens are verified by their embedded checksum (#515). Several providers encode a checksum in the credential, and verifying one is deterministic rather than heuristic — the first claim in the secrets pipeline that can lift a finding off the “a pattern matched” floor honestly. A token whose checksum does not verify is deterministically not a credential of that provider.

  • A hardcoded JWT is verified by its structure (#549). The checksum path is nearly exhausted at GitHub: its CRC32 is one of very few schemes verifiable offline, and most providers can only be checked by calling their API, which nox’s architecture forbids. The honest next deterministic signal is structural — a JWT’s header base64url-decodes to JSON naming a signing algorithm, checkable with no network and no key. It establishes that the value is a JWT, never that its signature holds.

  • A surviving AI finding records what it was checked against (#551). The AI refiners recorded why they dropped a candidate and nothing about the ones that survived, so a reported AI finding’s ledger said only “the rule fired”. A survivor now carries the inspections it passed — it is in real code, and for a rule with a context requirement, that context was found. Every such claim is a heuristic deliberately, so this moves explanation and not confidence: nox why answers “what supports it” with something other than a tautology.

  • A served intelligence record is consumed as research, not as an opaque advisory (#550). intel.FromRecord turns a record into a ResearchProposition: the affected package’s import paths become the symbols the local applicability test asks about, the corroboration count and refuting claims cross the wire, and the maturity is read from the ledger’s strongest live claim — a retracted or disputed claim weighs nothing, the same rule the kernel applies to confidence. Intel evidence is about a package; only local analysis decides whether it applies here. The researcher-intake side is deliberately not built: a record cannot yet carry a trigger condition or a PoC hypothesis, because asserting those is a disclosure decision rather than an adapter.

Changed

  • A finding now carries two confidences, and they answer different questions (#523). Confidence is the analyzer’s calibrated judgement that a finding is a true positive — on nox’s own corpus, 37 true positives and no false ones, so it is accurate. EvidenceConfidence is what the recorded evidence supports, and it caps at MEDIUM for any static scan: the evidence model puts HIGH at the strength of a controlled reproduction or a confirming source, which reading code does not produce. They are kept apart because merging them — filtering on evidence strength as though it were calibration — would empty --min-confidence high on every project, permanently. --min-confidence filters on the first.

  • The shared kernel moved to nox-core v0.2.3 (#520–#547). Typed subjects, claim polarity, a claim lifecycle, producer authority, the reproduction hierarchy and subject-scoped confirmation now live in the kernel both the CLI and the intelligence service share. A retracted claim weighs nothing everywhere it is read, not only where it was first wired in.

  • The passive/active boundary is enforced by test, not only by convention (#542). nox scan reads; nox attack acts, requires --authorize, and its safe profile selects a network-less adapter. The scan pipeline carries no import of the attack package, checked by parsing its imports, because a convention is what a refactor does not consult.

Fixed

  • nox no longer reports its own baseline files as leaked credentials (#529). The tool-state exclusion (.nox, .claude, .roady, …) matched only at the repository root, so the same directory one level down was scanned — and a SHA-256 fingerprint in a nested .nox/baseline.json fired a high-severity “Cloudflare API Token”. On one real repository this was 107 false positives. Directory exclusions now match at any depth.

  • 36 vendor secret rules stopped matching any long string near a keyword (#530). A rule like SEC-542 with the pattern [a-zA-Z0-9]{32,} and the file-level keyword cloudflare meant, in practice, “this file mentions cloudflare somewhere and contains 32 alphanumeric characters somewhere”. The proximity control that fixes this already existed; a one-character bug in its pattern skipped exactly the unbounded quantifiers — the most dangerous form. Two rules were retired: SEC-542 (a duplicate of SEC-087, which detects the same token correctly) and SEC-524 (an Azure subscription ID is not a credential). Waivers written against SEC-542 keep working.

  • nox why . --offline no longer reads the flag as a finding selector (#535). Go’s flag parser stops at the first positional; the command now splits flags from positionals first, as nox show already did.

  • AI-006 no longer flags a constant sentence that contains the word “prompt” (#497). print in the rule’s call alternation also matches inside Fprintf, and the noun was then accepted anywhere in the call — including inside a string literal. fmt.Fprintf(os.Stderr, "…nobody can approve a browser prompt.\n") was a medium finding, and it cannot be a true one: a call whose arguments are all constants logs no value, so there is no prompt to leak. A match now survives only when the call actually logs something — the noun is an identifier, or code follows it inside the call (a concatenation, an f-string or template-literal hole, a printf argument). Five waivers in nox’s own CLI became unnecessary and were removed.

  • Hardcoded JWTs were dropped before they became findings (#549). The data-blob refiner discarded any string over 96 bytes as an opaque base64 payload, and a full JWT runs to hundreds of bytes — so nox could not see an entire credential class. The threshold’s own comment gave it away: it reasoned about “a JWT header segment” and missed that the whole token is long. lexctx.LooksLikeJWT closes it, in one place so the blob refiner and the secrets analyzer cannot disagree about what a JWT is. Surfacing them then exposed a dedup gap — three rules match a JWT and did not collapse — closed by making eyJ a canonical owner prefix. End to end, a hardcoded JWT goes from zero findings to one.

  • Every brace-enclosing absence rule silently missed CloudFormation written in YAML (#552). IAC-051 and its family bound a resource block with a span that walks out to the enclosing { }. YAML has none, so the span came back empty and the rule never fired — while the rules already listed *.yaml in their file patterns, so the format was always in scope. An unencrypted S3 bucket was flagged in a JSON template and missed in the identical YAML one. Fixed with an indentation-bounded enclosing span, a distinction a false positive taught: the block span of a Type: anchor is the scalar alone, so a sibling encryption property fell outside it and an encrypted bucket looked unencrypted. The JSON path is untouched.

1.31.x 1 release · Aug 2026

v1.31.0

  • Added
  • Changed
  • Fixed
  • Build

A source that stays silent about a CVE, and a kernel two repositories can share.

Added

  • VerifyingSource: “a superset of OSV” is now something the client checks, not something it accepts. The intelligence design threat-modelled poisoning by addition — Sybil reporters, malicious metadata — and left the worse half uncovered. A source that withholds a real CVE produces a clean scan, and nothing downstream can tell “no vulnerability” from “not told about the vulnerability”. An invented finding is noisy and eventually noticed; a missing one is silent forever.

    Every answer from the intelligence source is now asked of the reference in parallel and the difference classified. When the reference holds a record the intelligence source withheld, that is a SUPPRESSION: it raises degrade.IntelSuppression and returns the record anyway, so a source cannot make a vulnerability disappear by declining to mention it.

Changed

  • The shared kernel moved to github.com/nox-hq/nox-core. core/evidence, core/vulnsource and the degrade guards now live in their own module, pinned here at v0.1.1 — the tag that carries the Apache-2.0 licence. This exists so the CLI and the intelligence service can share one implementation without either depending on the other.

    This removes those import paths from this module. Nothing in the CLI, the MCP surface, the LSP or the output formats changes — a nox scan behaves exactly as it did — but code importing github.com/nox-hq/nox/core/evidence or github.com/nox-hq/nox/core/vulnsource must now import github.com/nox-hq/nox-core/... instead. These packages were never documented as a public API, which is why this is a minor release rather than a major one.

Fixed

  • scan --help did not mention --format, the flag that makes nox emit an SBOM. It and five others — --output, --rules, --quiet/-q, --verbose/-v, --version — are registered on the root flag set, while scan --help printed only the scan set. All six worked; none were listed where somebody would look for them, so nox scan . --format cdx writing a CycloneDX 1.5 document read as a capability nox did not have. Scan’s usage now renders them under a “Global flags” heading, from the same description constants the real registrations use.

  • vex init --product described itself as “typically the Go module path”. nox is not Go-only, and a bare module path is not what OpenVEX products look like: they are package URLs, or for a container its digest. A module path carries no version, so two releases produce VEX statements that cannot be told apart — the one thing a product identifier exists to prevent. The help now says so, and names the forms. No behaviour change.

Build

  • warden gates this repository. Lint runs at pre-commit; tests and the security scan run at pre-push, so a failing tree cannot reach the remote.
1.30.x 2 releases · Aug 2026

v1.30.1

  • Fixed
  • Added

Plugins that were configured, required, and silently not running.

Fixed

  • Plugins did not run in any workspace that configured scan.exclude. The exclusions were sent to plugins as a []string, which structpb.NewStruct rejects — and it converts the whole input map or none of it, so the wrong type did not drop the exclusions, it failed the InvokeTool request. Twelve of twenty installed plugins never ran, nox/sast and nox/taint-analysis among them. Excluding lockfiles is the first thing most repositories configure, so the reach was close to “any workspace that customised its scan at all”.

    Post-scan tools were unaffected: they carry a typed ScanContext and no input map, so nothing converts. That is why nox/reachability worked and nox/taint-analysis failed in the same scan, which made this look like one broken plugin rather than a host bug.

    buildInvokeRequest now normalizes at the boundary as well, so a caller writing the obvious Go type cannot reintroduce it. []byte is deliberately left alone — structpb encodes it as base64, and listifying it would change the wire form a plugin receives — and values structpb genuinely cannot represent still error, because those are real mistakes.

  • A required plugin that failed to run reported policy: pass. One that was never installed, whose binary was missing, or that failed to register already produced a degradation. One that registered and then failed every invocation produced only a diagnostic on stderr, so it reached neither [degraded], the findings JSON, the MCP surface, the LSP, nor --fail-on-degraded — whose help text promises to “exit non-zero if any check could not complete”. A repository could name a plugin in plugins.required, run nox scan as a push gate, and be told pass for as long as the plugin had been broken.

    The scan still succeeds and still exits 0 by default: running the plugins that worked is right, and promoting a partial run to a hard failure remains the operator’s call via --fail-on-degraded, which now covers this case. Only error-severity diagnostics are promoted — a plugin that skips a vendored file and says so is working as intended.

Added

  • plugins.required honours version constraints. *, 1.2.3, >=1.2.3, ^1.2.3 and ~1.2.3. nox plugin install nox/foo@0.5.0 was already accepted syntax, so operators wrote the same thing in .nox.yaml; the whole string was matched as a plugin name, so it could never resolve and nox reported “is not installed” for a plugin that was installed — with the effect that the plugin silently never ran.

    The constraint is enforced, not merely parsed: matching the name and ignoring the version would hand a repository whatever happens to be installed, which is the outcome a pin exists to prevent. An unsatisfied constraint degrades and names the version actually installed, unsupported grammar is an error rather than a silent pass, and a version that cannot be compared at all — a locally built plugin recording dev — says so instead of guessing.

    Below 1.0.0 the minor is the breaking axis, so ^0.2.0 admits 0.2.9 and not 0.3.0. Every plugin in the registry is currently 0.x, so the looser reading would have made the caret mean “any version” for all of them.

    Implemented without a semver dependency: nox is a security scanner, and a twelfth direct dependency to compare three integers is the worse trade.

v1.30.0

  • Added
  • Fixed
  • Changed

Dynamic exploit validation, and a run of fixes about controls that reported success without doing anything.

Added

  • nox attack — dynamic exploit validation for AI and agentic systems. Builds exploit hypotheses from static findings and the AI inventory, exercises a running target, and reports evidence-backed traces. plan is offline; run / replay / regress / mcp are ACTIVE, require --authorize, and never run as part of nox scan.

    Verdicts come from core/evidence, which owns the exploitability ladder (POTENTIAL / PLAUSIBLE / PREVENTED / INCONCLUSIVE / CONFIRMED) and enforces two rules nothing else may re-implement: CONFIRMED requires deterministic evidence at reproduction strength — no quantity of heuristics or LLM judgments reaches it — and independence counts distinct reporters, not repeated observations. Payloads are reflection-immune: a canary value never appears in what is sent, so an echoing target cannot fake a hijack.

    nox never reports “safe”. A target that was not reached, or a run that was cut short, is INCONCLUSIVE — never PREVENTED.

  • scan.include is implemented. An allow-list of glob patterns: when set, only matching files are scanned. It previously parsed and did nothing, so a scan narrowed to a subtree silently covered everything. See Upgrading.

  • Config nox parses but does not act on is now reported. Unrecognised keys were already reported (1.29.0); a key nox accepts and then ignores fails the operator identically — the policy written is not the policy in force. Both now surface as degradations from core, so the CLI, the MCP server, the LSP and findings.json all report them. Currently compliance.framework and cache (nox never caches a scan; --no-cache is likewise a no-op).

  • sdk.Fingerprint / sdk.RelativePath / sdk.RunFingerprintStability for plugin authors, so a plugin’s fingerprints do not move with the checkout.

Fixed

  • SEC-163 could never match a hex string (#467). It required entropy ≥ 4.5, but Shannon entropy over a 16-symbol alphabet cannot exceed log2(16) = 4.0. So the rule named for hex matched only candidates from richer alphabets and reported them under the hex description — one report showed it flagging a Go identifier while the 40-character fingerprints two files away went unreported. Entropy candidates now carry the set of tokenizer kinds that found them, rules declare candidate_kinds, and SEC-163 sits at 3.5, below the ceiling.

  • Plugin finding fingerprints no longer move with the checkout path (#454). They were derived from the absolute path, so no plugin finding could be baselined anywhere the path differed — every CI runner, every worktree gate, any two developers. Locally the baseline matched and the scan was green; the gate elsewhere reported hundreds of net-new findings for the same commit. Paths are made repo-relative, and slash-separated, before fingerprinting — the second half caught by the Windows job, where native separators made one file fingerprint differently on Windows than on Linux.

  • Lockfile parsers truncated silently at 64 KiB. 14 of 17 used bufio.Scanner at its default limit without checking Err(), so a long line ended the parse and returned what it had. A truncated dependency list is indistinguishable from a complete one, and everything after that line was missing from the SBOM and from vulnerability matching.

  • scan.exclude now reaches plugins. A plugin walks the workspace itself, so without the patterns it cannot honour the exclusions the operator wrote.

  • 18 shipped rules were invisible to the rule catalogue. Catalog() aggregated 8 of the 14 analyzers that publish rules, so AGENTFLOW/TAINT/CRYPTO/HARDEN/PERM/MEMSAFE rules produced findings while the MCP rules tool — and anything else reading the catalogue — denied they existed. Catalogue count 1519 -> 1537.

  • A policy gate let an unrecognised severity through. policy.Evaluate gated a finding only when it could rank its severity, so a finding whose severity it did not recognise slipped every gate and the scan exited 0. Reachable from config: an unvalidated scan.rules.severity_override of Critical (capitalised) made a finding invisible rather than raising it.

  • MCP-009 no longer flags code that detects injection as performing it (#474). The phrase it matches is unavoidable in a guardrail’s pattern list or an attack corpus, where the string is what is being searched for.

  • Matcher types that validated but matched nothing are rejected at load time. jsonpath, yamlpath and heuristic were accepted by rule validation and served by a stub returning nil, so a rule declaring one loaded, listed in the catalogue, ran against every file and found nothing. Not registering them at all is safer: the scan now fails loudly.

  • plugin.read_resource is no longer advertised. It was registered as a ReadOnly MCP tool and returned “not yet implemented” as a successful result, so an agent checking isError saw success. The plugin protocol has no resource-read RPC, so it could never have worked.

Changed

  • nox attack and the intelligence layer are separate concerns. Vulnerability intelligence is deliberately not in the CLI; see docs/design/intelligence-service.md for the boundary.
1.29.x 2 releases · Aug 2026

v1.29.1

  • Fixed

Two fixes about the same failure mode: a remediation that reports success without having remediated anything.

Fixed

  • nox fix applies each upgrade in the directory of the manifest its finding named. It ran every package manager in --root, which in a monorepo is a directory no dependency lives in — and npm does not fail there, it creates a package.json and a lockfile. The run printed applied, a phantom root manifest got committed, and the real dependency stayed vulnerable on the next scan. Findings already carry their manifest path, so it is now used: no manifest for that ecosystem in that directory means the upgrade is refused and counted, rather than silently retargeted at the root. A manifest path that climbs out of the project is refused outright. Dedup is per directory, because two workspaces on the same vulnerable version are two upgrades.

    The npm ecosystem is four package managers, so the tool now comes from the lockfile — npm, pnpm, yarn (classic and berry), bun. npm install in a pnpm workspace wrote a second lockfile and resolved a tree nothing installs from. pnpm gets --depth Infinity, because most advisories land on transitive dependencies.

  • An upgrade that rewrote nothing is no longer reported as applied. Package managers exit 0 without doing anything: pnpm answers “Already up to date” for a transitive dependency held down by a pnpm.overrides entry. nox printed applied over an unchanged lockfile, which is the worst possible report — the operator stops looking while the advisory stays live. The manifests are fingerprinted around each apply; an upgrade that moved nothing is reported as no change, with the likely cause, and counted as a failure.

  • A failing gate names the finding that failed it (#463).

Found by running nox fix across a fleet of monorepos: 12 of 16 repositories carried critical or high advisories, 8 of them in manifests a root-level run could never have reached.

v1.29.0

  • Added

Added

  • Unrecognised .nox.yaml keys are now reported as a degradation. A key nox does not understand was silently ignored, so a config could describe a policy that was not in force while the scan reported success.

    Two cases seen in the wild, both accepted without comment:

    • suppressions: is not a key nox has. Writing it suppressed nothing and passed — indistinguishable from a scan whose suppressions all applied.
    • a misspelled plugins.required (reqiured:) left every plugin undeclared, so none ran and their findings were absent rather than clean.

    Both now print on the same [degraded] channel as an undeclared plugin, naming the offending keys and the impact, on stderr and not suppressed by --quiet.

    Reported rather than fatal, deliberately: erroring would break every existing config carrying a stray key the moment a fleet upgrades, and a scanner that refuses to run is its own coverage loss. Verified against the real configs in this repo, fortify and statekit — none produce a warning.

1.28.x 1 release · Aug 2026

v1.28.0

  • Fixed
  • Security
  • Changed

Minor rather than patch: nox fix now fails a run it would previously have reported as successful, when a remediation lands a package below where it started. That is the intended behaviour and the point of the release, but it is a behavioural change for anyone scripting nox fix.

Fixed

  • nox fix could downgrade a dependency, reintroducing the vulnerabilities it was meant to remove. planUpgrades used each advisory’s fixed_in directly as the target and never compared it with the installed version.

    An advisory’s fixed_in is the version that closed that advisory, not the newest safe version, so a package with several advisories yields several fix versions — routinely including ones below what is already installed. The planner emitted one action per advisory and applied them in sequence, so the last one won by accident. That is order, not safety.

    felixgeelhaar/specular#51 was this: golang.org/x/crypto 0.54.0 → 0.51.0, putting nine critical advisories into a repo that scanned clean — in a PR titled chore(security): nox remediation.

    Candidates are now aggregated per package and the highest fix chosen, which clears every advisory below it in one move, and three refusals apply:

    • never a version at or below the installed one;
    • never a prerelease when the install is a stable release (felixgeelhaar/orbita#49 pinned google.golang.org/grpc to v1.81.0-dev, a real upstream tag but a development marker);
    • never a non-empty installed version nox cannot order — no ordering means no way to know the move is forward.

    An absent installed version is deliberately treated differently from an unparseable one: some scanners do not report it, and refusing there would silently stop remediating whole ecosystems. Absence is not evidence of a downgrade, so only the prerelease guard applies.

    Both incidents are covered by regression tests built from their real advisory sets.

  • nox fix now verifies what actually landed, not just what it planned. After applying, it re-reads the manifests and fails if any package ended up below where it started.

    Planning correctly is not landing correctly: go get resolves against the whole module graph, so a constraint elsewhere can pull a package under the requested version, and a package manager can simply do something other than what was asked. The planner cannot see either.

    Ecosystems whose resolved version nox cannot yet read are reported as unverified rather than counted clean — unchecked is not the same as verified, and a guarantee that quietly covers less than it claims is the failure mode this whole change exists to prevent.

Security

  • brace-expansion 5.0.8 → 5.0.9, closing a high-severity DoS advisory (#449).

Changed

  • github.com/openai/openai-go/v3 3.49.0 → 3.50.0 (#450).
1.27.x 1 release · Aug 2026

v1.27.0

  • Added
  • Fixed
  • Changed

The action can install the plugins a project requires.

Added

  • The GitHub Action installs plugins.required (#447)

    nox runs only the plugins named in plugins.required, reporting anything unlisted as [degraded] with its findings absent. The action installed nox and scanned, but never installed plugins — so a repository scanning through it could not satisfy a requirement at all. Declaring one made every run degraded; declaring none silently gave up the coverage.

    Both outcomes were reached in practice: nox-plugin-freshness deleted its requirement and ran without SAST, and klarlabs-studio/roady kept hand-rolled install steps rather than adopt the action.

    nox install already did this, so the action now calls it. A repository with no .nox.yaml is unaffected, an install failure is fatal rather than a silently reduced scan, and cosign is installed only when the project declares plugins.

Fixed

  • Plugin enrichments are serialized to findings.json (#441)

Changed

  • Dependency and action-pin remediation (#442, #443, #446)
1.26.x 1 release · Aug 2026

v1.26.0

  • Added

A shared CI workflow can finally declare the plugin it installs.

Added

  • NOX_REQUIRE_PLUGINS (#436) adds to plugins.required, comma-separated.

    A plugin only contributes findings when it is declared, and declaration lived only in a per-repository .nox.yaml — the wrong place for a fleet. A shared workflow installs the same analysis plugin everywhere and pins its version centrally, but could not say the one thing that makes it take effect, so every repository without its own .nox.yaml got reduced coverage. 1.25.2 made that visible; this makes it fixable in one place rather than thirty.

    It adds to what the repository declared and collapses duplicates, so setting it fleet-wide can only widen coverage — it can never silently drop a plugin a repository asked for.

    An environment variable rather than a flag because the callers are reusable workflows: a job-level env: reaches every nox invocation in the job without threading an argument through each call site.

1.25.x 3 releases · Jul 2026 – Aug 2026

v1.25.2

  • Fixed

Two false-positive reductions, and a scanner that now admits when it ran less than you asked for.

Fixed

  • An installed plugin no longer goes silently unused (#403, #434). A plugin contributed findings only if it was also named in plugins.required, and nothing recorded the difference — so nox plugin install nox/taint-analysis followed by nox scan . got part of the plugin’s coverage with no indication anything was missing.

    It looks like it worked, which is the dangerous part: the built-in Go taint model still reports TAINT-002 and TAINT-004, so only the plugin’s additional TAINT-003 is absent. Reduced coverage was indistinguishable from full coverage.

    Declaration still activates a plugin. What changed is that skipping one is visible — one degradation names the installed plugins that did not run and says how to enable them.

    Running every installed plugin regardless was implemented and measured first. It collapses the precision corpus, 1.0000 to 0.3394 with 72 new false positives from rules belonging to plugins installed for other purposes, so declaration was kept and the silence fixed instead.

  • Entropy rules stop flagging identifier shapes (#430). SCREAMING_SNAKE_CASE constants in template strings, lowercase dot-chains and template braces are excluded from entropy candidates. Measured on openai-agents-python: 77 findings removed, none added, of which 64 were Python test function names such as test_cloudflare_create_uses_client_timeouts reported as possible secrets. The predicates sit in isLikelyNotSecret, so SEC-161, SEC-162 and SEC-446 benefit alongside SEC-163.

v1.25.1

  • Fixed

One false positive, in the rule most likely to be pointed at a Go codebase.

Fixed

  • A method call is no longer read as a value by the entropy matcher (#432). extractAssignmentRHS treats : as an assignment operator, which is right for YAML and JSON (api_key: abc…) but also fires on a struct-literal field in Go, Rust or Swift:

    Hook: domain.PrePush.ConfigKey(),

    The “RHS” extracted there is the selector expression domain.PrePush.ConfigKey, which has no value at scan time. Three guards each just missed it: isTokenChar accepts ., so the whole chain is captured; isCamelOrPascalCase bails on the first non-alphanumeric, so a dotted identifier is never recognized as one; and at 24 characters its Shannon entropy is 4.085, over SEC-163’s context-boosted 4.0. The result was reported as a “high-entropy hex string” while containing no hex.

    A token immediately followed by ( is a call, and a literal secret is never followed by an open paren, so skipping those costs no recall. Measured on two corpora with both binaries built from the same tree: 5 findings removed on one repo and 1 on nox’s own, 0 added on either. Four of the six were base64.StdEncoding.EncodeToString.

    Two related problems are deliberately left open rather than folded in. A selector chain with no call parens (Hook: domain.Config.SomeKey,) still reaches the entropy check — filtering dotted identifiers generally is riskier than it looks, since a JWT is also dot-separated and its header segment is short enough to pass a naive identifier test. And hasSecretContext matches keywords by substring, so ConfigKeyword matches on key, as monkey and tokenize would; changing that shifts which lines are eligible at all and wants its own measurement.

  • git auto-gc no longer races t.TempDir cleanup (#427), which had been failing releases intermittently rather than failing anything real.

v1.25.0

  • Added
  • Fixed
  • Changed

Taint recall across nine languages, and the reason it took this long to notice the gaps: the guard that was supposed to catch them had never run.

Added

  • Twelve of the thirteen documented taint recall gaps are closed, across Clojure, C++, Dart, Elixir, Groovy, Kotlin, Objective-C, Perl, PowerShell, Ruby, Shell and Swift. Each was a construct where a real flow reached a real sink and nox stayed silent, and each was annotated in a precision suite as ground truth rather than left undocumented. Several turned out to share one cause, which is why they closed in groups:

    • Pipelines and threading. Elixir’s |> bound the value into the first stage only; PowerShell’s $x | Cmdlet was not modelled at all; Clojure’s ->/->>/some->/cond-> rewrite argument position at read time. All three now carry the value to the stage where the sink is. The shapes are not interchangeable — PowerShell cmdlets are paren-less, so peeling one pipe at a time swallows the rest of the chain, and Clojure needs the threaded value to have a NAME because the engine taints a variable at its binding. (#414, #415, #419)
    • Lambda and higher-order dispatch. Kotlin’s .let { } and Groovy’s .with { } are one concept — the lambda parameter aliases its receiver. Clojure’s apply and map pass the real callee as data, so the sink is never a literal call head. (#417)
    • Receiver and container binding. An assignment to a member field (task.arguments = [...] then a bare task.launch()) or a container element ($args{cmd} = $ENV{CMD}) bound no name, so the taint was lost at the store. Both bind now, field-insensitively, enabled per language as a corpus demands it. (#418, #420)
    • Cross-unit shared state. A source landing in a Ruby @ivar or a Perl our global in one method and read by a sink in another is now joined. Only syntactically shared names participate — a local never joins, so same-named locals in one file do not collapse into one variable. (#420)
    • Declarations that also assign. local x="$1", std::ifstream in(path) and %{"file" => path} = conn.params each bind a value the recognizer previously discarded. (#416, #417)

    All nineteen precision suites hold precision 1.000 with zero false positives. Validated against roughly 15,700 real files across nine languages — cloned projects including ring, reitit, compojure, clj-http, Phoenix, Plug, Ecto, apache/groovy, Spock, Grails, dart-lang and flutter — with findings diffed by fingerprint between builds.

Fixed

  • DATA-005 was titled “hardcoded public IP” and matched every dotted quad, so loopback and RFC 1918 addresses — the common case in real configuration — were reported. DATA-001 and DATA-005 also flagged the RFC 2606/5737 reserved documentation values that exist precisely so examples have an address to use. Candidates are now parsed and classified in code rather than by regex alternation. On the release corpus this is DATA-005 3 findings to 0 on one repository and DATA-001 112 findings to 2 on another. (#413)
  • A Clojure keyword inside a data literal was recorded as a source, so CONSTRUCTING a request map — what every test fixture, mock and benchmark in the ecosystem does — marked the value untrusted. A keyword is a source only in function position, (:headers req), which reads a request; as a key it builds one. Found on real projects; all twenty precision suites scored 1.000 on it. (#421)
  • Four shell sink-modelling defects exposed by the local fix, each a false positive on real scripts: isShellCommandByte stopped at - despite its own comment saying otherwise, truncating exec-add-path to the exec sink; exec matched an I/O redirection that executes nothing; curl --output "$path" had its output path treated as the SSRF-controlling value; and path-qualified commands (/usr/bin/curl) resolved to no callee. (#416)
  • A crash on malformed input. The shell double-quote scanner treated \ as escaping the next byte without checking one existed, running the index past end-of-line. A scanner must not panic on input it is pointed at. (#416)
  • The precision ratchet misread its own metric. findings-per-issue is a distance-from-1.00 measure, but was gated as strictly lower-is-better, so closing a false negative — which necessarily raises it toward the ideal — was reported as a regression. Every genuine recall fix would have needed laundering through a baseline regeneration. (#414)

Changed

  • The rule-behaviour diff now covers the taint engine. It runs two builds over pinned real repositories and reports what changed per rule, and it had never fired on a taint change: the path filter covered core/analyzers/** and core/rules/**, while every taint change lives in core/taint/**. Six taint pull requests merged unguarded. core/taint/** and core/lexctx/** are now in the filter, and the corpus — previously two Go/IaC repositories, which could not have shown a taint regression in any language — gains three entries chosen by measuring which produce signal, not by intuition. (#422)
  • The precision suites carry failing samples again. Eighteen of nineteen had reached recall 1.0, at which point a corpus can only detect regressions and can no longer say what the engine cannot do. Seven annotated false negatives now stand — Clojure partial/comp/as->, shell xargs/pipeline-fed commands, Dart cross-method fields and list elements — each verified to miss before being added, each naming what would close it. The corresponding recall numbers are deliberately below 1.0; precision is unchanged at 1.000. (#424, #425)
  • Three limits documented that no longer existed — shell arrays, shell ${var//a/b} transforms and PowerShell splatting — were corrected and pinned with regression samples. A documented gap that is not real is the same defect as an unguarded one, in the other direction. (#424, #425)
  • A partially tainted URL is still reported as SSRF, and docs/design/ sast-taint.md now says why, with the number that justifies it: across ~14,000 real files the engine emits 8 TAINT-006 findings in total, 2 of them this class. Narrowing it needs URL-structure modelling; the doc names what would change the decision. (#423)
  • tp_ssrf_field.dart was withdrawn. Its comment described a tainted URL in req.url fetched by client.send(req); its code used a constant URL and put the tainted value in a header, so the annotated CWE did not hold. The premise is not realizable in Dart — HttpClientRequest has no settable URL field, and across 1213 real Dart files there is not one request.url = ... assignment. Replaced by two gaps that are real. (#425)
1.24.x 1 release · Jul 2026

v1.24.0

  • Added
  • Fixed
  • Changed

Added

  • World-writable file and directory modes are now reported (PERM-001, PERM-002). A fleet that drops gosec in favour of nox loses G301 (MkdirAll), G302 (Chmod) and G306 (WriteFile) outright — 22 findings across 14 Go repositories with no nox equivalent. os.WriteFile, os.OpenFile, os.Chmod, os.Mkdir, os.MkdirAll and ioutil.WriteFile are matched on the Go AST, so a permission literal in a comment, a string, or a same-named project helper is not a finding. Both octal spellings, mode conversions (os.FileMode(0777), fs.FileMode(…)) and OR chains are evaluated. (#405)

    The threshold is the world-write bit (0o002), not gosec’s 0600/0750. Under gosec’s defaults 0o644 and 0o755 are findings, which in this repository alone would be 503 of them — those modes are Go’s own idiomatic defaults, so reporting them is a preference rather than a vulnerability signal, and it is why gosec’s permission rules are among the most-suppressed in the fleet. World-writable is different in kind: any local user can rewrite the contents, no idiom requires it, and the finding is actionable without knowing what the file holds. World-readable and group-writable are deliberately not reported. A world-writable directory carrying os.ModeSticky is the /tmp model and is not reported either.

    Medium severity at high confidence, matching gosec’s own rating for the family. Note that a gate keyed on net-new critical/high will not fail on these; lower the threshold or key on PERM-* to make them blocking. The mode must be a literal in the call — a named constant needs go/types and is not reported, which is the correct direction to fail.

  • HARDEN-001 / HARDEN-002 — TLS misconfiguration in Go source. Nothing in nox modelled tls.Config. Certificate validation could be switched off anywhere in a Go codebase and no rule said a word — a gap that matters now that teams are dropping gosec from their golangci config and running nox as the only code-level security tool. HARDEN-001 reports InsecureSkipVerify: true (high — verification off means the connection is authenticated against nobody, and only critical/high fails a typical CI gate); HARDEN-002 reports a MinVersion below TLS 1.2 (medium — RFC 8996 deprecates TLS 1.0/1.1, but the peer is still authenticated and a legacy floor is sometimes a defensible choice). Both cover the composite-literal and the post-construction assignment forms, resolve an aliased crypto/tls import, and carry gosec: G402 in metadata. (#405)

    This one is parsed, not pattern-matched. InsecureSkipVerify:\s*true also matches a comment — grpc’s xds credentials contain “InsecureSkipVerify needs to be set to true because …” — and no regex can tell true from a variable. The rule uses go/parser, which nox already depends on for Go taint extraction, so the check is exact and adds no dependency. Measured over 117,731 real Go files in a module cache: 72 findings, every sampled one a genuine assignment; a loose regex over the same corpus returns 82, of which 12 are comments.

    Deliberately not reported, so nobody has to discover it the hard way: InsecureSkipVerify: skipVerify (a variable’s value is not resolvable without go/types, and reporting it anyway would fire on every config-driven client), and _test.go files (tests legitimately dial httptest servers with self-signed certificates; a high-severity finding on each would get the rule suppressed wholesale). A struct that is not provably a crypto/tls.Config — a wrapper with its own InsecureSkipVerify field, or an assignment whose receiver type needs go/types — is still reported, at medium confidence.

  • Findings from HARDEN-* and CRYPTO-* now carry a family label in the scan summary (“Transport Security”, “Weak Crypto”) instead of falling into “Other”, where a broken primitive was invisible at a glance. (#405)

  • CRYPTO-002 — predictable randomness (CWE-338): a math/rand or math/rand/v2 draw used for a security-bearing value in Go. High severity, medium confidence. This is the class gosec reports as G404, which Go codebases lost when they dropped gosec from their lint config. (#405)

    It does not flag math/rand. math/rand is the correct tool for retry jitter, backoff spread, load balancing, sampling, shuffling and test fixtures, and those call sites outnumber the dangerous ones. The rule fires only where the names around the draw say the value is security-bearing — the variable, struct field, buffer, called function or enclosing generator function resolves to a word like token, secret, key, nonce, salt, password, session, iv, apikey, csrf, otp or auth — and a word like jitter, backoff, retry, sample, shuffle or cache anywhere in that context vetoes it outright. crypto/rand is never flagged: the file is parsed and the import path resolved, so rand.Read from crypto/rand (the fix this rule recommends, and identical in text) is distinguished from rand.Read from math/rand.

    Measured before shipping, on real code. Across the Go module cache, 851 non-test files that import math/rand produce 4 findings; a fleet of 23 Go repositories (7,720 files) produces 0, and every one of gosec’s 9 G404 reports in that same fleet is a documented-benign jitter, chaos-test or simulation call that this rule correctly stays silent on.

    It misses things, by construction. A generator whose variables and function are named neutrally is not caught, nor is a value that reaches a security name a statement later, nor a dot-imported math/rand. Recall was traded away for silence on purpose: this rule is intended to be safe to gate on, and the absence of a finding is not evidence that a file generates its secrets safely.

Fixed

  • CRYPTO-001 missed the one-shot digest call, which is the commoner one. The rule matched the CONSTRUCTOR form and nothing else, so md5.New() was reported and md5.Sum(b) — in the same file, same package — was not. sha1 behaved identically. Sum() is the more idiomatic of the two calls, so the shape being missed was the one people actually write: a scan of klarlabs-studio/agent-go reported 10 findings before and 18 after, every new one a Sum() call the rule had been silent about, and none removed.

    The constructor-only pattern existed for a good reason — matching the bare word md5 would flag a variable named md5sum and the word in a comment. The fix keeps that property by matching the package-qualified call (md5.Sum(/md5.New() rather than the algorithm name, so an identifier and prose still do not match. Both call shapes are now asserted together in one test so they cannot drift apart again. (#402)

Changed

  • CRYPTO-001 now covers all 15 languages core’s SAST supports, up from 5. The rule was Go, Python, JavaScript, TypeScript and Java only; C, C++, C#, Kotlin, Objective-C, PHP, Ruby, Rust, Shell and Swift were silent, as were the JS/TS variant extensions (.jsx, .mjs, .cjs, .tsx, .mts, .cts). Coverage now also includes 3DES and ECB mode alongside MD5, SHA-1, DES and RC4, and — as in Go — both the streaming and one-shot call shapes wherever a language offers both (MD5_Init and MD5(); MD5.Create() and MD5.HashData()).

    Two languages are deliberately narrower than they could be. PHP does not match bare md5()/sha1(): they are global functions, among the most-called in the language, and overwhelmingly used for cache keys, ETags and Gravatar hashes (which the Gravatar protocol requires to be MD5). Flagging them would put a finding in nearly every PHP file in existence, and a rule like that gets globally disabled — taking the genuinely broken mcrypt_* and openssl_encrypt(…, 'des-ecb', …) detections down with it. Shell does not match md5sum/sha1sum/shasum, which verify downloads against accidental corruption rather than an attacker; only openssl enc with a broken cipher is matched there.

    Patterns stay bound per extension. Digest::MD5 is a Ruby constant path, md5::compute is a Rust path and MD5( is an OpenSSL C function; a test feeds every language’s positive corpus through every other language’s extension and requires zero matches, so one language’s vocabulary cannot manufacture findings in another’s files. Two known-noisy shapes are excluded by name: Cipher.getInstance("RSA/ECB/OAEP…"), where JCA’s “ECB” is a historical misnomer for correct RSA padding, and printf("MD5 (%s)…"), which is OpenSSL’s own output format rather than a call.

    The rule reports zero findings on nox’s own repository, before and after. (#405)

  • MEMSAFE-001 — integer truncation that sizes memory. A value narrowed to a smaller integer type, or flipped signed→unsigned, and then used as a make() size or a slice bound. A wire-decoded length converted uint32→int32 goes negative above 2³¹ and panics the allocation; a length truncated to uint8 silently mis-frames anything longer than 255 bytes. Both were found in widely deployed networking libraries while validating the rule. Medium severity, so it reports rather than gates. (#405)

    This is deliberately not gosec’s G115. Measured before implementation, gosec’s own rule produced 96 findings across sixteen fleet repositories plus nox itself, with zero true positives — int32(len(out)) filling protobuf count fields, byte(addr>>24) extracting an IPv4 octet, byte(nano%10) formatting a digit. That is how a rule earns 63 blanket suppressions. Nox reports only the shape where truncation is a memory-safety bug, and stays silent on all 96. On segmentio/kafka-go, gosec reports 156 findings of which two are a real bug; this rule reports one, and it is the bug.

    Masks, modulo, logical shifts on unsigned values, comparison guards written through a conversion (if uint32(r) <= MaxLatin1), and len-derived values narrowed to 32 bits or more are all suppressed — each one a class observed firing on correct code. A bare index s[i] is not treated as a sink: it is syntactically indistinguishable from a map lookup, and Go bounds-checks slice indexes anyway.

    go/types was evaluated and rejected, with the cost written down in docs/design/go-integer-overflow.md. Full G115 parity needs type resolution, every Go importer needs a resolvable module graph and a toolchain, and nox scans checked-out trees offline in containers that have neither. The measured price is 64% of gosec’s findings on nox’s own source — all of which were false positives anyway. Conversions whose operand type is not provable from the enclosing function are not reported.

    G103 (unsafe), G602 (slice bounds) and G104 (unhandled errors) are not implemented, with reasons: all 20 fleet G103 findings are in generated protobuf code, G602’s useful half is this rule’s slice-bound sink, and G104 is already covered by errcheck in the fleet’s golangci config — duplicating it would reintroduce exactly the double-reporting that retired nox-plugin-sast.

1.23.x 1 release · Jul 2026

v1.23.0

  • Changed
  • Fixed
  • Added

Changed

  • Ten duplicate IaC rule IDs retired; one condition, one finding. Fourteen pairs of IaC rules fired on the same condition, so a single misconfiguration was reported twice — often at two different severities, which made a severity-keyed gate depend on which ID it happened to read. continue-on-error: true was both low (IAC-018) and medium (IAC-310); privileged: true was covered three times over. IAC-237, IAC-283, IAC-287, IAC-291, IAC-292, IAC-310, IAC-312, IAC-321, IAC-333 and IAC-337 are retired into the older rule that already reported their condition. Two of the fourteen were not duplicates but generic patterns swallowing specific ones (encrypt\w* over storage_encrypted, replicas over minReplicas); both rules survive, with the generic one bounded so the two no longer overlap. (#394)

    Existing waivers keep working. A retired ID stays valid in baselines, VEX statements, nox:ignore comments and scan.rules.disable — the rule that absorbed it reproduces its fingerprints, bounded by the retired rule’s own pattern so no waiver is widened. Nothing needs to be rewritten. What changes is the count and the ID: gates or dashboards keyed on a retired ID should move to the surviving one (see “Retired rule IDs” in docs/usage.md).

Fixed

  • nox scan --staged ignored every command-line flag. The staged scan was invoked with an empty options struct, so --offline, --no-osv, --rules, --baseline, --vex and the rest were silently dropped — while .nox.yaml was still honoured, because the staged run copies the config into its temporary worktree. Config therefore beat an explicit flag, with no error and no warning. This is the nox protect / install-hook pre-commit path, so a hook told to scan --offline could still reach the network for OSV lookups. The core had already grown RunStagedScanWithOptions; this call site was never updated to use it. (#362)

  • Flag-over-config precedence is now contract-tested across all thirteen settings that can be set in both places, each including the case that makes this class of bug possible: a flag passed explicitly with a value equal to its default, which a naive implementation cannot distinguish from an omitted flag. (#362)

Added

  • Release smoke test. Before a release publishes, the built binary scans a fixture carrying a hardcoded credential, a vulnerable dependency and an insecure Dockerfile, and asserts each rule family fires and that a VEX document is parsed and applied. Two regressions this month stopped the scan running entirely rather than reporting wrongly; an empty report is indistinguishable from a clean one at a glance. (#390)

  • Rule behaviour diff. Pull requests touching rules scan sha-pinned real repositories with the last release and the candidate build, and report the per-rule delta. Corpus precision is measured on labelled fixtures and does not predict real repositories. A scan that fails to run is fatal; a rule delta is reported for review rather than failing the build, since most rule changes are intentional. (#390)

  • A build-time guard rejects any two IaC rules that fire on the same input, so a new duplicate cannot be introduced. (#394)

1.22.x 2 releases · Jul 2026

v1.22.1

  • Fixed
  • Added

Fixed

  • nox scan -vex accepts OpenVEX v0.2.0 documents. The current spec — and what nox vex init output is fed back into — models vulnerability and products as objects ({"@id": "CVE-…", "name": "CVE-…"}) rather than strings. core/vex typed both as strings, so loading such a document failed with exit 2 before the scan ran, reporting no findings at all. Both shapes are now accepted, preferring @id over name. (#389)

Added

  • Release smoke test. The built binary is now scanned against a fixture carrying a hardcoded credential, a dependency with a known advisory, and an insecure Dockerfile before a release is published, asserting each rule family fires and that a VEX document is parsed and applied. Two regressions this month stopped the scan running entirely rather than reporting wrongly — an empty report is indistinguishable from a clean one at a glance. (#390)

v1.22.0

  • Added

Added

  • nox verify-secrets — asks the issuer whether a detected credential still works. A detected secret and a live secret are different findings. “This looks like a GitHub token” is a backlog item; “this is a working token” is an incident, because the credential is already public and deleting the file does not invalidate it. Only the issuer can tell the two apart, and the check needs no privilege beyond the leaked credential itself.

    nox verify-secrets --input out/findings.json
    
      SEC-003  config/app.js:1   LIVE   authenticates against the GitHub API (ghp_…)
      checked 1 credential(s); 1 still authenticate

    Exit status is 1 when anything still authenticates, so a pipeline can act on it without parsing output.

    This is deliberately not revocation. Revoking a credential means calling the provider’s API with another credential, usually one more privileged than the key that leaked — a nox that could revoke your AWS keys would hold AWS admin credentials and be a better target than the leak it defends against.

    Two properties hold the feature together and are enforced by tests. The endpoints are compiled in: verification transmits a live credential to a third party, which is defensible only because that party is the issuer, and a configurable endpoint would make this an exfiltration primitive inside a security scanner. The secret never appears in output — not in a message and not in an error; the transport failure is deliberately not wrapped, because a url.Error carries the request and the request carries the credential.

    Anything other than a clear yes or no — rate limiting, an outage — reports unknown. Calling a live credential revoked because the issuer was briefly unreachable would be worse than not checking at all.

    findings.json still contains no secrets: a finding carries a file and a column range, and verification re-reads the file at that location, so reports remain shareable. A location that no longer fits the file is an error rather than a best guess.

    Covers GitHub tokens (SEC-003, SEC-213, SEC-435, SEC-495, SEC-496). AWS is absent deliberately: verifying an AWS key needs SigV4 request signing and the paired secret access key, which is a different shape of work rather than another entry in a table. (#386)

1.21.x 1 release · Jul 2026

v1.21.0

  • Added
  • Fixed

Added

  • nox fix --outdated now covers seven ecosystems, up from Go alone: Go, npm, PyPI, Cargo, RubyGems, Composer and NuGet.

    Go resolves through go list -m -u -json all, which already understands replace directives, retractions and the module graph. The rest query their own registry directly, so planning needs no toolchain installed; only applying an upgrade shells out to the native command.

    Currency needs two things vulnerability scanning does not, which is why the dependency analyzer could not simply be reused. Directness: packages are parsed from lockfiles, which are flat and contain the whole transitive closure, so upgrading one writes an explicit requirement for something the project never imports. Names therefore come from the manifest (package.json, Cargo.toml, Gemfile, composer.json, *.csproj, requirements.txt) and resolved versions from the lockfile — a manifest range like ^4.18.0 is not a version, and one with no lockfile entry is skipped rather than assigned a version it does not have. The latest version, which only a registry can answer.

    Every registry invites the wrong answer in a different way, and each is pinned by a test: npm publishes channels under dist-tags where only latest is stable; crates.io reports max_version (including prereleases) beside max_stable_version; Packagist returns newest-first but mixes in dev-<branch> aliases and release candidates; and NuGet returns an ascending list with prereleases interleaved, so the last element is often a beta. A package with no stable release yields no suggestion at all.

    Anything that could not be checked is reported rather than assumed current. A registry that cannot answer produces a degraded: line and never an empty string — "" is indistinguishable from “already current”, so one rate-limited registry would otherwise mark a whole project up to date. The same applies to a manifest that exists but cannot be parsed (which would otherwise drop its entire ecosystem from the run without a word) and to a directory containing no supported manifest at all (which reported everything as current having examined nothing). The all-current message is suppressed whenever any check failed to complete: there is a real difference between “checked seven ecosystems and everything is current” and “found nothing to check”, and only one of them is good news. (#383)

    Maven and Gradle are parsed by the scanner but deliberately have no currency resolver: maven-metadata.xml has no single “latest stable” and Gradle has no canonical upgrade command, so applying would mean rewriting build files — a different risk class from running bundle update. (#378, #381)

Fixed

  • Two defects that only live requests could surface, both invisible to stubbed tests. npm’s full packument for typescript and @types/node each exceed 8 MB, truncating the response and reporting every dependency as un-checkable; nox now requests the abbreviated document. And crates.io answers HTTP 403 to clients that send no User-Agent, which Go’s HTTP client does not set by default — so every Cargo lookup failed in reality while passing every test. Both now have regression tests. (#381)
1.20.x 1 release · Jul 2026

v1.20.0

  • Added
  • Fixed

Added

  • nox fix --outdated — an opt-in dependency currency pass. By default fix upgrades a dependency only when a VULN-001 finding names a fixed_in version: it acts on evidence of a vulnerability, not on the passage of time. That is the right default for a security tool, but it means fix cannot stand in for a version bumper, because a package that is merely old is never touched.

    The gap was observable rather than theoretical. nox-remediate ran green across all 18 plugin repositories on 2026-07-22, and every one of them was still on github.com/nox-hq/nox v1.17.0 days later — working exactly as designed, and not doing the currency job at all, because it was never that job.

    --outdated is deliberately a separate flag. A security fix is something an operator wants applied without argument; routine version churn is a choice with its own risk of breaking a build. Folded together, you could no longer tell from the fact that fix changed something whether there had been a vulnerability — so currency upgrades report as OUTDATED and never as VULN-001.

    Narrow on purpose: Go only (go list -m -u -json all is an authoritative answer to “what is newer”); direct dependencies only, since indirect ones are go mod tidy’s business; major bumps held unless --include-major; and it never downgrades — a replace directive or a retracted version can make go list report an “update” that is not newer, which is the defect fixed for VULN-001 in #372 and must not reappear on a new path.

    This is the first thing in fix that reaches the network, which is unavoidable: whether a newer release exists cannot be answered offline. It runs only behind the flag and never during a scan, so the offline-first guarantee for scanning is unchanged. (#378)

Fixed

  • Documented that --outdated, like --content, is a mode rather than a modifier: it returns before the dependency and Action passes, so nox fix --outdated --actions does the currency pass only and silently skips Action pins. Two invocations are needed for both.
1.19.x 2 releases · Jul 2026

v1.19.1

  • Fixed

Fixed

  • nox plugin list now says whether an installed plugin will actually run. Installing a plugin puts it on the machine; listing it under plugins.required in the project’s .nox.yaml is what enables it for a scan. That separation is deliberate — nox’s first design constraint is that the same inputs produce the same outputs with no hidden state, so a globally installed plugin that ran automatically would make findings depend on which plugins happen to be present, and two developers on one repository would get different results.

    Nothing said so at the point it mattered. nox plugin install reported success, nox plugin list showed four columns all describing a plugin that was about to do nothing, and the scan came back quiet — which reads as “the plugin found nothing” rather than “the plugin never ran”. The degradation model already reports required-but-not-installed, unusable-binary and integrity-mismatch; installed-but-not-listed was the one state with no signal, and unlike the others it is the expected result of following the install instructions.

    plugin list gains an ACTIVE HERE column reflecting the current directory’s plugins.required, plus a note when any installed plugin is inactive. plugin install prints the .nox.yaml snippet to add, and only when the project does not already require it. Scan behaviour is unchanged. (#377)

  • The documentation was teaching the model that caused the confusion. The README called declaring plugins “Recommended”, which reads as a best practice rather than the thing that enables a plugin, and then offered “One-shot install (no manifest)” as an alternative path — which a reader takes to mean scanning works without the manifest. It does not. The commands in that section (search, info, list, call, update, remove) genuinely need no manifest because they address a plugin by name and never load .nox.yaml; only scan consults it. Reworded in the README and docs/marketplace.md.

v1.19.0

  • Fixed

Six fixes, every one of them a case where nox reported something that was not true, or failed for a reason that had nothing to do with security. Four of the six blocked a repository outright.

Fixed

  • A throttled version lookup no longer fails the security gate. The action resolves version: latest through the GitHub API, and GitHub answers 403 when rate-limiting. resolve_version was a bare curl -fsSL, which exits non-zero on any 403 — so the action died before it ever reached fetch_asset, the function directly below it that had already been hardened against exactly this throttle. The symptom was distinctive and uninformative: Nox PR Gate failing in nine seconds with one log line, curl: (22) The requested URL returned error: 403, followed by a second red check when the SARIF upload found no file. Two failed checks, no scan performed, neither of them about security.

    Underneath it, the reason the throttle was reachable at all: action.sh has always sent Authorization: Bearer ${GITHUB_TOKEN} when that variable is set, but action.yml’s env: block never mapped it. A composite action’s step sees only what that block maps, so the header expanded to nothing on every run, and both the version lookup and the asset download went out anonymous — against the 60-requests-per-hour budget shared by every job on the runner’s IP address. A burst of CI runs hits that routinely.

    Both halves are fixed: the lookup retries on the same schedule as the download, and the token now reaches the script. If you would rather not depend on the lookup at all, pinning an explicit version: skips it entirely — the failure message now says so. (#375)

  • VULN-001 no longer tells you to downgrade. The remediation advice reported the first version listed as fixed for an advisory, which is not necessarily one that is newer than what you have installed — for a vulnerable package on a maintained older line, the suggestion could move you backwards. Fix versions are now resolved against the affected-range intervals and the installed version, so the recommendation is always forward. (#372)

  • One dataflow reported from both ends is now one finding. A taint path discovered from its source and again from its sink produced two findings for a single flow, inflating counts and, on a gated repository, presenting the same problem twice. Flows are deduplicated on rule, path, source line, sink line and source variable. (#373)

  • A location-less finding no longer breaks the whole SARIF upload. GitHub rejects an entire submission — every finding in it — when any result carries an empty artifactLocation.uri, with locationFromSarifResult: expected artifact location. One finding that could not be tied to a file therefore discarded the report. Findings without a location now emit no locations array at all, which SARIF permits, and the rest of the report uploads. (#370)

  • An explicit -format is no longer overridden by .nox.yaml. A flag given on the command line lost to the config file, so the one way to override a project setting for a single run did not work. The flags now default to empty so that “absent” is distinguishable from “set to the default”, and an explicitly-passed value wins. Same fix for -output. (#371)

  • Five rule-precision defects that put false high/critical findings on the gate. Each fired on ordinary code containing no credential and no vulnerability, and all five land in the high/critical band the shared CI gate fails on, so each one blocked a repository outright.

    SEC-147 matched the Resend prefix at the seam of an identifier — TestSessionStore_LoadsLegacySnapshot… supplies re_ and 38 alphanumerics — and reported a high-severity API key on a Go test function name. SEC-003 and SEC-213 had the same seam for ghs_. All three now require a word boundary on the left; an issued key never continues a preceding word.

    SEC-435 required its gh[pousr]_ prefix and exactly ONE further character, so any five-character run beginning ghs_ was a GitHub token — including "ghs_fake_install_token", a shape GitHub does not issue. It now requires the issued shape (prefix plus 36 alphanumerics), which costs no detection: every well-formed token is already covered by SEC-003/SEC-213/SEC-215/ SEC-216/SEC-217.

    SEC-004 reported critical on an HTML placeholder= attribute holding -----BEGIN RSA PRIVATE KEY-----. That is the hint shown in an empty field, telling a user what to paste; the key material is theirs and arrives at runtime. Matches inside display-text attributes (placeholder, aria-label, alt, title, label) are dropped. value= and every other attribute are untouched — a key really can be pasted into one of those.

    SEC-240, the Terraform password-field rule, fired on a Go doc comment: its separator alternation includes ,, so // "bot_token" pops a password input, "imap_password" pops … parses as a field, a separator and a quoted value. A comment holds prose describing configuration, not configuration. The assignment-shaped rules — derived from the rule table, not a hand-kept list — no longer match inside comments. Provider rules are unchanged: a full token in a comment is a real leak and is still reported, as is a credential genuinely left in a commented-out assignment, which the generic keyword rules still catch.

    AI-049 (“AI output passed to eval/exec”, CWE-95) fired on textbook parameterised SQL — db.Exec(`INSERT INTO schedules (id, prompt) VALUES (?, ?)`) — because the AI-vocabulary token it gates on was a column name inside the query text. database/sql’s Exec evaluates SQL, not code, so CWE-95 cannot apply to it. A call executing a SQL statement is dropped. The discriminator is the SQL text, not the receiver: db.Exec(model_output), a model emitting raw SQL that is then executed, still fires. (#374)

1.18.x 1 release · Jul 2026

v1.18.0

  • Fixed
  • Changed
  • Added

Fixed

  • IAC-348 no longer fires on artifacts.when: always. The rule matched the two words with a bare pattern, but everything it says is about job execution — “CI job runs regardless of previous failures”, with a remediation warning about deployment jobs running after test failures. Under artifacts: the same words mean upload the artifacts even when the job failed, which for a scanner is the point: the run you most want results.sarif from is the one that failed the gate. nox flagged its own GitLab example for doing the right thing. A job-level when: always still fires, and a file containing both keeps the job one — the same context-confusion class as IAC-193 on shell: bash.

  • nox fix --actions no longer breaks reusable workflows, and no longer pins prereleases. Two defects, both of which fired on this repository. It rewrote a reusable-workflow reference to a digest, but slsa-verifier resolves a trusted builder’s identity from the ref, so pinning slsa-github-generator by SHA makes it unverifiable; upstream requires a tag “contrary to the GitHub best practice for third-party actions … but intentional due to limits in GitHub Actions”. And tag filtering used a substring match, so v2.1.0-rc.3 passed as v2.1.0, tied it once the suffix was discarded, then won the specificity tiebreak on dotted components — so a release candidate was chosen over its own release. Reusable workflows (any path under .github/workflows/) are now left alone; an action published in a subdirectory is still pinned.

  • SLSA provenance is generated again, and its absence now fails the build. The pin above had made the generator unverifiable, and the run failed in a way where every job except an internal final step reported success. Nothing downstream inspected the release, so v1.14.0 through v1.17.0 shipped with no attestation at all while the project advertised SLSA Level 3. Those releases have been backfilled. A new job now reads the release and fails if no in-toto asset is present, because a green provenance job is not evidence that provenance exists.

  • A failing weekly dependency-CVE audit now raises an issue. It runs only on the schedule, so its failures went to the Actions tab and nowhere else — when it began failing on a real advisory it failed every Monday for weeks with nothing surfacing it. One issue is reused rather than filed weekly.

  • The VS Code extension declares the editor version it actually needs. It advertised ^1.75.0 while its own vscode-languageclient dependency required ^1.82.0; VS Code gates installation on that field, so users on 1.75–1.81 could install an extension that failed at activation.

Changed

  • The VS Code extension moves to vscode-languageclient 10 and TypeScript 7, with moduleResolution: node16 — the legacy node setting predates exports maps, so v10’s vscode-languageclient/node subpath was invisible to the compiler. engines.vscode rises to ^1.91.0 accordingly, landed together with the dependency so the manifest is never ahead of what it can serve.

  • The build moves to Go 1.26.5, and the workflows read the toolchain from go.mod rather than repeating a hardcoded version in three places.

Added

  • CI type-checks the VS Code extension. It previously had none: no workflow ran npm ci or tsc, so every dependency bump to it merged on a green tick that had never compiled it — which is how a ReDoS advisory reached this repository through its toolchain. The job caught a broken bump on its first run.

  • Dependabot configuration. dependabot-auto-merge.yml had been present with no dependabot.yml, so it sat there with nothing to merge and no update was ever proposed. Covers the Go module, GitHub Actions, the extension’s npm tree, and the Dockerfile base image.

1.17.x 2 releases · Jul 2026

v1.17.1

  • Fixed
  • Added
  • Changed

Backfilled on 2026-07-26: this release shipped without notes. Everything below was in the tag and has been in users’ hands since; it is recorded here because a release with no changelog entry is one nobody can audit.

Fixed

  • SLSA provenance was silently absent from six releases. slsa-verifier resolves a trusted builder’s identity from the ref, so pinning slsa-github-generator by commit SHA makes the attestation unverifiable — contrary to the usual best practice for third-party actions, and stated as a MUST by the generator’s own documentation. The generator’s subjects, generator and upload-assets steps all reported success and only the final step failed, so v1.14.0 through v1.17.0 published with no provenance at all while the project advertised SLSA Level 3. The workflow now references the generator by tag. Verify any release with gh release view <tag> --json assets and look for multiple.intoto.jsonl: a green workflow run is not evidence the asset attached. (#354)

  • nox fix --actions caused that, and no longer can. Two defects in the pin resolver, both of which fired on this repository. It rewrote reusable workflow references to a digest, which is what unpinned the SLSA generator; reusable workflows are now never rewritten. And its tag filter used a substring match, so v2.1.0-rc.3 matched a search for v2.1.0, parsed to the same version, and won the specificity tiebreak — meaning a release candidate could outrank the release. Prereleases can no longer outrank a release. (#356)

  • The VS Code extension declared a version it could not honour. engines.vscode advertised ^1.75.0 while its own vscode-languageclient dependency required ^1.82.0. VS Code gates installation on that field, so anyone on 1.75–1.81 could install the extension and have it fail at activation. (#358)

  • The extension could not resolve modern package exports. It compiled under TypeScript’s legacy node module resolution, which predates the exports field, so vscode-languageclient/node was invisible to it (TS2307). Now node16. (#359)

Added

  • CI for the VS Code extension. It had none: no workflow ran npm ci or tsc, so every change to it — and every dependency bump — merged on a green tick that had never compiled it. That is how GHSA-mh99-v99m-4gvg (brace-expansion ReDoS) reached this repository, found later by a self-scan rather than by CI. The type-check job caught a real breakage on its first run. (#357)

Changed

  • Go 1.26.5, and the last hardcoded toolchain pins removed. (#360)
  • Dependency updates: vscode-languageclient 10.1.0, typescript 7.0.2, @types/node 26.1.1, golang.org/x/sync 0.22.0, golang.org/x/term 0.45.0, openai-go 3.46.0, and base-image bumps for golang and distroless. (#341, #345, #346, #348, #349, #363)

v1.17.0

  • Changed
  • Added

Changed

  • Reachability is a regular plugin; nox no longer bundles anything. The release archive contained one plugin, registered automatically on first run. That was never a coherent shape: the plugin system exists for optional, independently-versioned extension, so a plugin present in every release is neither optional nor built in, and it paid the costs of both — a process boundary and a sandbox policy for code as trusted as nox itself, plus release coupling and a separate “bundled” trust level with failure modes of its own.

    Two of those were live. The record named the binary inside the install prefix, which the package manager deletes on upgrade, so it dangled — the defect 1.16.3 repaired, though the record still sat at the mercy of the next upgrade. And the release pre-hook built the plugin with no GOOS/GOARCH, compiling once for the linux/amd64 release runner while the archive step copied that single binary into every platform’s archive: on a current darwin/arm64 install the shipped plugin was an ELF x86-64 executable sitting beside a Mach-O arm64 nox, and running it exited 126. For as long as bundling existed, the plugin could not execute at all on four of six platform archives — which is why the stale path went unnoticed, since the fallback was doing the work regardless.

    Reachability now installs like every other plugin, from the registry it is already published to and signed in: nox plugin install nox/reachability, or by declaring it under plugins.required in .nox.yaml, which the scan auto-installs.

    This removes reachability from a fresh offline install. An existing install has its bundled record retired on first run with a notice naming the install command, rather than left claiming a plugin nox no longer ships. On Linux the bundled binary did work, so for those installs this is a real change in behaviour and is reported rather than silent; a plugin the operator installed themselves is never touched.

Added

  • Dependabot configuration. dependabot-auto-merge.yml had been present without a dependabot.yml, so it sat there with nothing to merge and no update was ever proposed against this repository. Every action here is pinned to a commit SHA — nox ships IAC-013 to flag mutable tags — and a SHA pin is frozen by definition, so the pins aged silently: this repository was on actions/setup-go v6.4.0 while the plugin repositories had moved to v7.0.0. Covers the root Go module, GitHub Actions, the VS Code extension’s npm tree, and the Dockerfile base image.
1.16.x 4 releases · Jul 2026

v1.16.3

  • Fixed

Fixed

  • A bundled plugin is re-pointed when the binary moves. Bundled plugins were registered once and never revisited, and the recorded path names the install prefix of that release — a Homebrew Cellar directory, say — which an upgrade deletes. Nothing recovered from that, and almost nothing reported it: nox doctor said “binary missing” while nox scan said nothing at all and silently fell back to whatever else provided the plugin, or ran without it. Observed on a real install, where bundled reachability stayed registered against a deleted 0.8.1 prefix, so every scan quietly used a community build several versions behind the one shipped. Bootstrap now re-points a bundled record whose recorded path no longer matches the shipped binary and re-records its digest, so the integrity gate does not then refuse the correctly-located file. Only records nox created itself (trust level bundled) are touched — a plugin the operator installed deliberately is never overwritten. The gap was invisible because the test seam restated the production loop rather than calling it, so the test exercised a copy with the same defect and passed; it now calls the same function bootstrap runs.

v1.16.2

  • Fixed

Fixed

  • Ansible rules no longer fire on GitHub Actions files. Every Ansible rule is scoped to *.yml / *.yaml, which is every YAML file in a repository — workflows and composite actions included. So shell: bash, which a composite action step is required to declare, matched IAC-193 “Ansible task uses shell module”. A GitHub Actions file is not an Ansible playbook, so the finding is categorically wrong rather than merely less severe; these are dropped rather than downgraded like the other GitHub Actions false positives, because a downgrade still leaves an operator triaging a rule that could never apply. Composite actions are now covered as well as workflows: the existing context pass keyed off the .github/workflows/ prefix alone, which left action.yml — exactly where shell: is mandatory — uncovered. Real Ansible playbooks still fire; detection is by path, and a playbook is an ordinary .yml that never lives at .github/workflows/ or action.yml.

v1.16.1

  • Fixed

Fixed

  • scan.exclude now binds analysis plugins. A plugin’s scan tool is handed only the workspace root and walks the tree itself, so it never saw the exclusions the operator wrote. Requiring the code-analysis plugins on nox’s own repository took a clean grade-A self-scan (3 findings) to grade F (47), and 38 of those 47 were on paths .nox.yaml explicitly excludes — principally the intentionally-vulnerable fixture corpora that exist to be found by the precision and metamorphic harnesses, not by the self-scan. Plugin findings are now filtered host-side through the same matcher the walker uses, rather than by passing the patterns down: a plugin is third-party code and cannot be relied on to honour an exclusion it is merely told about.

  • Plugin findings are recorded relative to the scan root, like every other finding. They carried absolute paths, so the same file appeared under two spellings. The unused-waiver check groups by path, so a file with both core and plugin findings was evaluated twice, each pass testing every waiver in the file against only its own subset — reporting four live waivers on this repository’s Dockerfile as dead. The v2 fingerprint also hashes the path, so a plugin finding’s identity embedded an absolute machine path and no baseline could match it on another machine or in CI. A repository-scoped finding (one naming the workspace root rather than a file, such as a missing private registry configuration) is now canonicalised to the empty path the suppression pass already reads as “repository-scoped rather than located”, instead of failing to read a directory and degrading a healthy scan.

  • One gated plugin no longer disables every other plugin. Registration was all-or-nothing per phase: requiring all installed plugins hit one declaring needs_confirmation, and the rejection aborted registration for the rest, so the scan fell back to built-in findings having silently run none of them — the exact failure degradations exist to prevent. Each plugin now registers independently and a rejected one is degraded on its own, naming the policy it violated.

  • nox:ignore written in documentation is no longer parsed as a waiver. Because a waiver that matches nothing is reported, prose describing the syntax produced false “waives X but matched no finding” degradations against correct source — and that signal is the one used to find genuinely dead waivers. A directive’s grammar is nox:ignore <IDs> [-- reason]: free prose after the rule IDs means the line describes a directive rather than issues one. A directive inside a string literal is a program printing the syntax, and one nested inside a comment that already began the line is an example — the DocExample marking that already covered markdown fenced blocks now covers source comments too. Both guards fail toward keeping the directive, so an unrecognised language behaves exactly as before.

  • Dead waivers are found in every scanned file, not only files that already had a finding. The check was driven by findings grouped by path, so a waiver in an otherwise-clean file was invisible — which is exactly where a dead waiver hides, since the usual way one dies is the finding it covered getting fixed. Ten such waivers in nox’s own source are removed here, each verified inert by deleting it and re-scanning. Removing a waiver can only cause a finding to be reported, never hidden. Files without a nox: marker are skipped before parsing, so the sweep costs no measurable scan time.

v1.16.0

  • Added

Added

  • nox confirm — an opt-in, active loop that turns a static AI prompt-injection finding into a confirmed (or refuted) exploit. Static analysis can prove that untrusted input reaches an LLM; it cannot prove the model obeys it. nox confirm closes that gap: it reads a prior scan’s findings.json, selects the AI findings (AGENTFLOW-001, TAINT-AI-001, AI-PI-*), fires an adversarial corpus at a running target, and writes confirmations.json with a per-finding CONFIRMED / UNCONFIRMED verdict — separating a true positive from a false positive that static analysis alone cannot. The verdict is reflection-immune: a response is scored a hijack only on a signal the model had to produce (the uppercase transform of a lowercase seed the payload carried, or a secret canary that lives only in the app’s trusted system header and is never sent), so an app that merely echoes the payload can never false-confirm — an invariant asserted at startup, with the command failing closed if it is ever broken. This is a distinct, active command, never part of nox scan: it refuses to run without --authorize, the --target is operator-supplied, and nox states plainly that isolating the target is the operator’s responsibility. Exit 1 on ≥1 confirmed exploit makes it CI-gateable. See docs/confirm.md.

  • Remote, signature-verified distribution for the predictive slopsquat feed (SLOP-002). scan.slop.feed now accepts an https:// URL in addition to the bundled feed. A remote feed is fetched, verified against both a SHA-256 content digest and an Ed25519 signature bound to an operator-pinned key, then cached content-addressed (mirroring the plugin-registry cache) — a tampered payload, a wrong signing identity, or an unsigned feed under require_signature is rejected and recorded as a degradation, never trusted. It fails closed and stays offline-friendly: a verified feed serves from cache with no network, a fetch error falls back to last-known-good, and offline with no cache refuses rather than silently scanning without the dimension. Off by default — no feed configured is byte-identical to before. A scheduled slopfeed-publish workflow regenerates, signs, and publishes the feed and its public key as release assets, giving a stable URL operators pin by key. Ed25519-with-a-published-key rather than cosign keyless, deliberately: keyless verification needs a network round-trip at scan time, which would break the offline/deterministic guarantee. See docs/slopsquat-feed.md.

1.15.x 1 release · Jul 2026

v1.15.0

  • Added
  • Security
  • Fixed

Added

  • Predictive slopsquatting (SLOP-002), via a signed, versioned feed. Where SLOP-001 reactively flags an import of a package that exists nowhere, SLOP-002 flags an import whose name matches a curated list of high-risk targets an LLM is likely to hallucinate and that are currently unregistered — a name an attacker could claim to catch hallucinated installs. The feed is a versioned JSON document bound by a SHA-256 content digest with an optional Ed25519 signature (the same trust model as the plugin registry), parsed fail-closed: an unknown schema, a digest mismatch, or a required-but-invalid signature disables the dimension and records a visible degradation rather than crashing or trusting bad data. Off by default — with no feed configured, behaviour is byte-identical to before. Enable via .nox.yaml scan.slop: {feed: bundled}; a 62-name PyPI/npm feed ships bundled, and cmd/slopfeed regenerates it (read-only registry queries, never accusing a registered package). Learning stays central and signed; enforcement stays local, offline and deterministic.

  • nox mcp baseline / nox mcp drift — rug-pull detection for MCP servers. nox mcp baseline -- <server-cmd> captures a reviewable baseline of an MCP server’s tool manifest to .nox/mcp-baseline.json (sorted, diffable, commit it); nox mcp drift re-captures and reports drift — an added/removed tool, a changed description, a widened schema — as findings (MCP-DRIFT-001..006, a new code-exec tool critical, a poisoned description high) that flow through the normal findings.json / SARIF path, exiting non-zero for CI. This is the safe form of on-device adaptation: the baseline is data you diff and review, not a mutating rule. Both commands launch a user-supplied server subprocess and print a sandbox reminder — untrusted servers must be isolated; nox does not sandbox for you.

Security

  • brace-expansion in the VS Code extension is bumped past a newly-published ReDoS. GHSA-mh99-v99m-4gvg affects every brace-expansion up to 5.0.8, so the 2.1.2 pin an earlier release added (to fix a different brace-expansion advisory) became vulnerable when this one was published. Reached transitively through minimatch in the extension’s build toolchain — not the extension’s runtime — but a high-severity advisory nonetheless. The overrides entry now forces ^5.0.8; the extension still compiles clean and nox’s own self-scan returns to grade A.

Fixed

  • A blank line before a Dockerfile instruction no longer mis-locates the finding or changes its fingerprint. 16 regex-pattern IaC rules anchored with (?im)^\s*KEYWORD, and \s matches newlines — so under multiline matching a blank line before the flagged instruction (e.g. USER root, ADD, a sudo RUN) let ^\s* begin the match on the preceding blank line. The finding was reported on the blank line, and its matched content gained a leading newline that perturbed the v2 fingerprint — a false-positive/false-negative pair under a semantics-preserving edit that also broke baseline matching. Anchored with ^[ \t]*, which cannot cross a newline (the same correction the absence anchors got). Found by nox’s own metamorphic corpus oracle.

  • AI prompt-injection findings are now role-aware — untrusted input reaching the user role behind a static system prompt no longer fires. Reaching an LLM is necessary but not sufficient for a prompt-injection: TAINT-AI-001 and AGENTFLOW-001 fired whenever untrusted input reached a chat call, so the recommended pattern — user input placed in the user role while the system message stays static — was reported identically to the vulnerable pattern that interpolates it into the system role. Findings now carry a sink_role (system/developer/user/unknown) and suppress only the safe case: user role with a static, untainted system message present. Every system- or developer-role placement still fires, and ambiguity fails toward reporting — a dynamically-built message array, an unreadable role, or a non-Python call site keeps the finding, because a missed system-role injection is worse than an extra one. Python call sites only for now; other languages keep their prior always-report behavior (#319).

  • A data: URI payload is no longer mistaken for a secret. A base64-encoded image inlined in HTML/CSS/Markdown reported as vendor API keys — nox’s own dashboard, which carries a base64 PNG logo on one 28 KB line, produced 8 high-severity “LOB / ELK API Key” findings from inside the image. The suppression for embedded blobs already existed but consulted a lexer, which returns no language for markup and stylesheets, so it never ran on the file types most likely to hold an inline image. The ;base64, marker is unambiguous in raw bytes and now needs no language. Scoped to the payload span: a real credential elsewhere in the same file is still reported (#308).

  • Dockerfile “missing instruction” rules are no longer fooled by a comment or a blank line. Two defects, opposite directions. A comment that merely mentioned an instruction disabled the check: # no HEALTHCHECK needed satisfied IAC-121, and # LABEL maintainer is deprecated satisfied IAC-124 — a false negative, the check silenced by a comment claiming the instruction exists. And a blank line before a COPY --chown fired IAC-123: the anchor ^\s*COPY matched from the preceding blank line because \s spans newlines, so the span was computed from the wrong line and the --chown went unseen — a false positive on idiomatic Dockerfiles. Instruction properties are now anchored to line start, and the anchors use [ \t]* rather than \s* (#311).

  • A keyword in a comment no longer satisfies an absence rule’s required property. The wider form of the rule above: a file-span rule whose property is a free keyword was satisfied by that keyword appearing in any comment. IAC-153 (“artifact upload without attestation”) never fired when a trailing comment said # attested elsewhere. The matcher now strips # comments before the property check — a comment cannot fulfil a “must be present” requirement. Conservative by construction: a # inside a quoted value, a JSON string or a URL fragment is never stripped, so no absent-property false positive is introduced (#314).

1.14.x 1 release · Jul 2026

v1.14.0

  • Added
  • Changed

Added

  • The GitHub Action exposes fail-on-degraded. nox has had the flag since 1.11.0, but the Action never mapped it, so a workflow could not make “a check did not complete” fail the build without dropping to a raw run step — the gate most likely to rely on it was the one that could not reach it. An invariant test now fails the build if any declared Action input is not wired through to action.sh, which is the defect class that hid this one: an input can be declared and documented while silently never reaching the scanner.

Changed

  • An exit 2 under fail-on-degraded no longer reports itself as a crash. nox returns 2 both for a hard error and for an incomplete scan, and the Action annotated both as “Nox scan failed with exit code 2” — sending the reader to look for a failure that did not happen, when the scan had in fact run and written its reports. The annotation now names the degradation case and points at meta.degradations.

  • Dependency upgrades in the shipped binary: google.golang.org/grpc 1.82.0 → 1.82.1 and golang.org/x/text 0.38.0 → 0.39.0, applied by nox’s own remediation run.

1.13.x 7 releases · Jul 2026

v1.13.6

  • Fixed
  • Changed

Fixed

  • nox fix --actions could pin a GitHub Action backward, to an older commit than the one already running. It asked the GitHub Releases API for the latest version and consulted tags only as a fallback “for repos without Releases”. That precedence is wrong: a Release is an announcement, but an Action is consumed by tag, so a repository that tags v1.0.1 without cutting a Release is already serving it to every workflow pinned to @v1. Against such a repository nox planned a pin to the stale Release’s commit and labelled it with the moving tag — a silent downgrade, performed by the feature whose entire purpose is supply-chain pinning. Both sources are now compared and the newer wins; on an exact version tie the more specific tag is preferred, because only it still means the same commit tomorrow.

  • Scanning a single file ignored that file’s nox:ignore comments. nox scan main.go joined every relative path onto the target as if it were a directory, producing main.go/.nox/baseline.json and main.go/main.go. Neither can exist. The baseline miss was reported as a degradation, but the failed re-read meant inline waivers were never parsed, so the scan reported findings the operator had explicitly waived — with the explanation buried in a message about a path they never wrote. Scanning one file is what a pre-commit hook or editor integration does. Relative paths now resolve against the file’s directory, matching what config loading already did.

  • A dependency CVE in the VS Code extension. brace-expansion 2.1.1, reached through minimatch, is vulnerable to exponential-time expansion of consecutive non-expanding {} groups (GHSA-3jxr-9vmj-r5cp). Pinned to 2.1.2 via an overrides entry rather than by adding a dependency the extension does not import.

Changed

  • nox no longer excludes its own CI workflows from its own scan. The exclusion existed for one narrow reason — pinned commit SHAs and GITHUB_TOKEN references read as high-entropy secrets — but it dropped the whole file, silencing every analyzer rather than the noisy one. It hid 19 IaC findings on nox’s own workflows, including a high-severity mutable-tag pin. Now scoped to the secrets analyzer alone.

  • Cross-platform tests run before merge, not after. The matrix was selected by event, so macOS and Windows ran only on push to main; a Windows regression could not be observed until it had already landed, and a red main blocks nothing. Three Windows-only test defects had kept CI failing for over 100 consecutive runs. The matrix is now selected by what a change touches.

v1.13.5

  • Fixed

Fixed

  • The GitHub Action no longer fails a scan because a download was throttled. It fetched the nox binary and checksums.txt in a single unauthenticated attempt, so an HTTP 403 — which GitHub returns when many jobs pull release assets at once — failed the whole step. A burst of CI runs across the plugin fleet triggered exactly that, turning a security gate red for a reason unrelated to security. Both downloads now retry with backoff and send the token when one is available. A download that keeps failing still fails, with the final status reported.

v1.13.4

  • Fixed

Fixed

  • nox plugin entry emitted signature URLs for files that are never published. It wrote the cosign v3 names — a detached checksums.txt.sig and checksums.txt.sig.bundle — but the plugin release workflows sign with cosign v4, which writes a single checksums.txt.sigstore.json and no detached signature. Registry entries therefore pointed at assets that did not exist, the signature download returned 404, the artifact was classified unverified, and nox plugin install was blocked by the default trust policy. Every plugin version published after the move to cosign v4 was uninstallable. The bundle URL is now the v4 name and the dead .sig URL is dropped.

  • SARIF now carries security-severity, so GitHub Code Scanning can classify nox alerts. SARIF’s level only distinguishes error/warning/note, and nox emitted nothing else, so every alert arrived in Code Scanning with no security severity: the UI could not filter or sort by it, and severity-based alert rules had nothing to match on. Every rule descriptor now carries a CVSS-style score banded the way GitHub reads it — critical 9.5, high 8.0, medium 5.5, low 2.0. Info is deliberately omitted rather than scored 0, which would render as “low” and overstate it.

v1.13.3

  • Fixed

Fixed

  • Documentation that demonstrates nox:ignore is no longer reported as a pile of unused waivers. 1.13.2 began reporting waivers that suppress nothing, which is what you want for a mistargeted or stale waiver — but it also flagged every fenced code block showing the syntax, because sample secrets in docs are deliberately too short to match a rule. A directive inside a fenced code block in a markdown file is now recognised as illustrative and skipped by that report. It still suppresses a real finding on its target line, so a genuine waiver written inside a doc keeps working.

v1.13.2

  • Fixed

Fixed

  • A nox:ignore that suppresses nothing is now reported. A dedicated directive applies to the next non-blank line, so a reason that wrapped onto a second comment line made the waiver land on that continuation comment — the finding below stayed reported, and nothing indicated the suppression had missed. A mistyped rule ID, or a waiver left behind after the finding was fixed, failed just as silently. Unused waivers now surface as a suppression degradation naming the file, line and rule. Correctly-applied waivers and deliberately-expired ones stay quiet (verified: nox’s own self-scan, which carries many nox:ignore comments, reports none).

v1.13.1

  • Fixed

Fixed

  • The GitHub Action failed on any scan that produced no findings. The action counted findings with grep -c … || echo "0"; because grep -c exits non-zero when there are zero matches, the fallback fired on top of grep’s own 0 and wrote a two-line findings-count value to $GITHUB_OUTPUT, failing the step with “Unable to process file command ‘output’ … Invalid format ‘0’”. Any repository whose scan found nothing — for example a PR-scoped, changed-files gate on a change touching only go.mod/go.sum — saw its Nox gate go red for no real reason. The count is now always a single clean integer.

v1.13.0

  • Security
  • Fixed

A large security-hardening and correctness release. Two adversarial audits of the scanner and its plugin trust boundary drove every change below; each fix ships with a test that fails without it, and the false-positive precision suite stays at 1.000/1.000 throughout.

Security

  • The plugin↔host channel is now authenticated. A plugin subprocess binds a gRPC server on a loopback port for the duration of a scan. That port was unauthenticated, so any other local process — or, behind a permissive firewall, any LAN peer — could connect and drive the plugin’s tools. The host now passes a fresh per-launch token to the plugin and presents it on every call; the plugin rejects anyone else.
  • Plugin binaries are re-verified before they run. The scan path trusted ~/.nox/state.json wholesale and launched whatever binary it named. It now re-checks the executable against a digest recorded at install and refuses to run a plugin whose binary changed since — so tampering can only stop a plugin, never escalate one.
  • nox plugin update and required-plugin install now enforce the trust policy. Only nox plugin install checked signature/trust violations; the update and .nox.yaml auto-install paths were fail-open, so a higher version in a configured registry (or a stale/MITM’d unsigned index) could be installed unverified. All three paths now share one gate and fail closed.
  • Secret redaction of plugin output no longer has blind spots. Secrets routed through a plugin’s structured fields (a finding’s file path, an AI component’s name/type/path, a diagnostic source, a graph node path) bypassed redaction entirely; and the GitHub-token pattern missed gho_/ghu_/ghr_ and fine-grained github_pat_ tokens. Both are fixed.
  • An unrecognized plugin risk_class now fails closed. A non-canonical value (RUNTIME, exec, a trailing space) slipped past the risk-class ceiling instead of being rejected.
  • Archive extraction is bounded. Plugin artifact extraction had no cap on decompressed size, so a small archive could expand to hundreds of gigabytes and fill the disk. Extraction is now capped (total bytes and entry count) and aborts before writing an over-budget archive.
  • MCP dashboard responses honor the output-size limit. The dashboard tools returned the entire findings/inventory as unbounded HTML, bypassing the 1 MB cap every other tool enforces; they now return a structured “too large” notice pointing at summary/list_findings.

Fixed

  • Source under prompts/ and agents/ directories is now scanned. Any real source file (.go, .ts, .py, …) under a directory named prompts or agents was misclassified as an AI component and silently skipped by taint, SAST, and agent-flow analysis — the very code that most needs it. Such files are now classified as source; genuine prompt/agent config artifacts still are not.
  • Go and Ruby components in an SBOM are no longer dropped from vulnerability scanning. Package-URL types (golang, gem) were not normalized to the ecosystem names OSV expects (go, rubygems), so every Go and Ruby component parsed from a CycloneDX/SPDX input was silently excluded from the OSV query.
  • MCP rug-pull and server/tool-shadowing detection now runs. MCP-015/023/024 were implemented and unit-tested but never invoked in a scan; a relational pass now wires them in.
  • 57 previously-dead IaC rules now fire. They used a regex feature Go’s engine rejects, so they silently never matched; a new block-scoped absence matcher restores them, guarded against hardened configs.
  • Five taint/SAST recall holes are closed (inline source-as-sink-argument, attribute-accessor sources, PHP concatenation and same-line function bodies, JavaScript per-function scoping) with no new false positives.
  • Vendor secret rules match the credential value, not the config key name, so they no longer false-positive on documentation while missing the real secret.
  • Reports are valid and reproducible. SARIF/SBOM validity fixes; CycloneDX output is deterministic when one CVE affects several packages; SPDX licenseDeclared is always a valid expression; the HTML report honors SOURCE_DATE_EPOCH.
  • requirements.txt strict </> bounds parse correctly, so those packages are no longer dropped or mis-named (and thus missed by CVE lookup).
  • --tracked-only no longer inverts its scope on a non-git target, per-finding metadata is isolated, and AI inventory output is deterministic.
  • Policy-gate keywords are validated (an invalid fail_on no longer silently disables the gate) and de-duplication no longer drops distinct findings that share a fingerprint.
  • A latent absence-matcher span bug that would absorb sibling YAML sequence items is fixed before any rule could hit it.
1.12.x 3 releases · Jul 2026

v1.12.2

  • Fixed

Fixed

  • Some secrets were reported multiple times. Five vendors had two or three built-in rules that were byte-for-byte the same detection — identical description, identical pattern — so a single credential surfaced as several findings under different rule IDs (a Bugsnag key as three, a Heroku key as two), all with the same description and no way to tell them apart. The redundant rules are merged; where they carried different keywords those are combined into the surviving rule, so no file loses coverage. A before/after scan of a real repository produces an identical finding set. Two tests now prevent duplicate rules from recurring.

v1.12.1

  • Fixed

Fixed

  • 129 vendor secret rules fired on ordinary code. Rules whose pattern is only a character class and a length — [a-zA-Z0-9]{32} and similar — were gated by keyword at file level, so a single mention of a vendor anywhere in a file turned every run of characters of that length into a high-severity credential finding: comments, identifiers, JSON values. On nox’s own source one rule produced 34 such findings, two of which had blocked this project’s own PR gate.

    These rules now require the vendor keyword near the match, and the matched value to have the entropy and shape of a credential. Neither test alone is enough — a hostname sits right beside its keyword, and a real key can score lower entropy than a long code identifier — so both are applied.

    Measured on nox’s own source at --severity-threshold high, SEC-* findings dropped from 45 to 12 with no loss of recall: the precision suite still scores 1.000/1.000, and every one of the 129 rules still detects a credential-grade token of its own shape. A small residual remains on values whose character profile is genuinely ambiguous with a lowercase key (run-together hostnames); it is bounded by a test rather than removed, because the only discriminator that removes it also rejects real keys.

v1.12.0

  • BREAKING CHANGES
  • Fixed
  • Added

BREAKING CHANGES

  • Post-scan plugin tools are now subject to policy. InvokePostScan called the gRPC client directly and applied no policy at all — no per-tool safety check, no read-only gate, no rate limit, no timeout, no secret redaction. A non-read-only post-scan tool now runs only if the operator opts in with plugin_policy.max_risk_class: active.

    This affects nox/remediate, whose apply_code and verify_code tools are declared non-read-only and will be blocked under the default passive policy until opted in. That is the documented behaviour, and blocking a tool that rewrites source until an operator asks for it is the correct default.

  • Plugin fingerprints change value. The namespace separator moved from : to NUL. Anything baselined against a 1.11.x plugin fingerprint needs re-baselining.

Fixed

  • nox’s “never auto-applies fixes” guarantee was bypassable. A plugin declaring a tool with requires_scan_context and read_only: false ran it regardless of policy, because the post-scan path applied none. Counting enforcement sites, that path had zero against the normal path’s eight. Both now share one authorisation routine, so a future third invocation path cannot quietly skip the checks.

  • Every plugin enrichment and graph was silently discarded. Secret redaction rebuilds the response and copied findings, packages, AI components and diagnostics — but not enrichments or graphs. Reachability annotations and call graphs never arrived, with no error. The post-scan bypass had been masking this, which is why post-scan enrichments appeared to work and scan-tool ones did not. Graph node properties, file paths and edge labels were being dropped by the same code and are now carried through, with their free text redacted.

  • Plugin fingerprint namespacing was defeated by delimiter injection. plugin:<name>:<ruleID> meant plugin acme with rule sql:injection and plugin acme:sql with rule injection collided, letting one plugin suppress another’s finding through first-wins deduplication.

Added

  • yarn, pnpm and poetry lockfiles are parsed. These ecosystems previously produced an empty dependency inventory; 1.11.1 made that visible, and this closes it. Verified against the live OSV API: a yarn project pinning lodash 4.17.15 reports 6 vulnerabilities with severities, fix versions and CVE aliases where it previously reported none.

    Covers yarn v1 and berry, pnpm lockfile v5 and v6+, and poetry’s [[package]] tables — including the range-versus-resolved-version trap in yarn descriptors and the [metadata] table that poetry interleaves between package blocks.

1.11.x 2 releases · Jul 2026

v1.11.1

  • Fixed
  • Added
  • Known gaps

Retro-tagged on 2026-07-28. No v1.11.1 tag was pushed at the time, so the changelog documented a version that could not be fetched; the tag now points at the commit that added this section. No release ARTIFACTS were published for 1.11.1 then and none exist now — its changes reached users inside 1.12.0.

A post-release review found that three of 1.11.0’s own promises did not hold. Each had full unit-test coverage of the function involved while the defect sat at the call site — the same shape as the bug 1.11.0 was written to fix.

Fixed

  • Critical dependency advisories reported as medium, again. The CVSS-v4 severity fallback added in 1.11.0 never received data: advisory hydration copied summary, aliases and CVSS entries but not the source database’s severity label, and /v1/querybatch supplies none of those fields. A CRITICAL advisory scored medium, so critical/high gates did not fire on it. Severity mapping also returned on the first CVSS entry it saw rather than the first it could score, letting an unscorable CVSS v2 vector override an accurate label.

  • --fail-on-degraded could not fire for plugin failures, despite naming them in its help text. A required plugin that was not installed was silently skipped, so a CI job listing a security plugin, failing to install it, and running with the flag exited 0 with a clean report.

  • yarn, pnpm and poetry projects scanned clean while nothing was read. 1.11.0 exempted unparsed lockfiles from degradation reporting to stop go.sum warning on every Go repository; that exemption silently covered three lockfiles nox cannot parse and has no substitute for. Only genuine redundancy is exempt now. A new invariant test fails the build if a lockfile is added to discovery without either a parser or a recorded, reported gap.

  • A suppression with an unparseable expiry became a permanent waiver. # nox:ignore SEC-001 -- expires:2026-13-01 (month 13) was accepted, the expiry text stripped from the displayed reason, and the finding hidden forever. The waiver is now not applied, the finding is reported, and the bad date is named. This one is long-standing and failed toward hiding findings.

  • --fail-on-degraded discarded every report. It exited before report generation, so a pipeline that tripped the flag lost findings.json, the SARIF and the SBOM — including the findings it had collected. Reports are now written first, and the degraded exit outranks the policy exit code.

  • Silent supply-chain and CVE-variant data failures. Typosquatting and known-malicious-package detection returned nothing when their embedded datasets failed to load; a whole-database parse failure disabled every VARIANT-* rule. All are reported now.

  • Dockerfile read and parse failures are reported, in the same analyzer that already reported lockfile failures.

Added

  • findings.json records incomplete checks under meta.degradations. The consumers that most need them — CI jobs, dashboards, MCP clients — never see stderr, and for them an empty findings list was indistinguishable from a scan that never looked.

Known gaps

yarn.lock, poetry.lock and pnpm-lock.yaml are still not parsed. They are now reported as blind spots on every scan that encounters them rather than passing silently; parsers are the fix and are not in this release.

v1.11.0

  • BREAKING CHANGES
  • Added
  • Fixed

BREAKING CHANGES

This is a minor release carrying breaking changes. The module path stays github.com/nox-hq/nox — a v2 tag would break go get for library consumers without a /v2 path rename — so the breaks are listed here rather than signalled by the version number. Read this section before upgrading.

  • --vex and --terraform-plan now fail when the path cannot be loaded. Previously a missing or malformed file was silently ignored, so a typo’d path produced a clean-looking scan with no waivers applied and no plan scanned. Pipelines relying on a broken path being skipped will now exit 2.

  • Plugin track safety profiles are enforced. A plugin’s policy is now its registry track’s profile merged with .nox.yaml plugin_policy, rather than DefaultPolicy() for everything. This loosens the default posture for tracked plugins — a dynamic-runtime plugin gets localhost access without the operator configuring it. Set plugin_policy.ignore_track_profiles: true to restore the previous behaviour. Plugins installed with --local, or before this release, have no recorded track and stay on the strict default.

  • Go API. Host.MergeResults and Host.MergeAllResults take the producing plugin’s name; Host.InvokeAll returns []AttributedResponse instead of []*pluginv1.InvokeToolResponse; ProtoFindingToGo takes a plugin name. ScanOptions.NoCache is removed — nothing read it.

  • -no-cache is now a documented no-op. The flag is still accepted so existing scripts keep working, but the scan pipeline consults no incremental cache and never did.

Added

  • A scan now reports what it could not check. Graceful degradation is only safe if it is visible, and it was not: an OSV outage, a dead plugin, an unparseable lockfile, a corrupt baseline and an unreadable file during suppression all produced a clean exit 0, indistinguishable from a real pass.

    ScanResult.Degradations records each incomplete check with what failed and what may now be missing from the results. The CLI prints them to stderr even under --quiet — quiet suppresses noise, not a warning that the results are partial — and --fail-on-degraded makes CI treat “could not check” as failure, for runners where OSV is firewalled or a required plugin is absent.

    [degraded] vulnerability lookup failed for 1 packages: ...
      impact: dependency vulnerabilities are under-reported; this scan cannot
              confirm the absence of known CVEs

    Silence is preserved where nothing is wrong: a missing baseline is normal before the first baseline write, and files nox deliberately does not parse (go.sum, yarn.lock) are not degradations. A warning that fires on healthy scans is one operators learn to ignore.

  • --fail-on-degraded — exit non-zero if any check could not complete.

  • plugin_policy.ignore_track_profiles — force every plugin onto the strict default policy regardless of track. Required because the override semantics are one-directional: an operator can widen an allowlist but cannot empty one, so without this there is no way to revoke a profile’s grant.

Fixed

  • Plugin-supplied fingerprints were trusted verbatim. Plugin findings merge into the same FindingSet as core findings, deduplicate first-wins, and baseline and VEX suppression key on the same value — so a plugin could claim a core finding’s fingerprint and erase it, or claim a baselined one and hide itself. Fingerprints are now derived host-side and namespaced by plugin, so a plugin cannot reach outside its own findings while its findings stay individually baseline-able.

  • The plugin gRPC socket bound to all interfaces. net.Listen("tcp", ":0") with insecure credentials meant any local process — and depending on the host firewall, any peer on the LAN — could invoke plugin tools for the lifetime of a scan. Now binds 127.0.0.1:0.

  • Track safety profiles were documented but never applied. The authoring guide presented the per-track table as the policy in force while the host enforced DefaultPolicy() for everything, so a dynamic-runtime plugin was rejected at registration for declaring the localhost access its track was documented to grant. The track is read from the registry entry the plugin was installed from and never from the plugin itself — a self-declared track would let a plugin choose its own sandbox.

  • Warnings reached nobody. The CLI never configured an slog handler, so every slog.Warn in the codebase went nowhere — including the OSV degradation warning added in 1.4.0 specifically to prevent silent failure. NOX_LOG_LEVEL now controls verbosity.

  • license.deny / license.allow produced no findings. The config parsed cleanly and was never wired to the dependency analyzer. Fixing it exposed a second bug: npm license detection returned early when the root package.json was missing, skipping node_modules entirely.

  • --rules was silently ignored under --staged and --history. The staged scan dropped its options wholesale; the history scan never read them.

  • Analyzers ignored context cancellation. All four non-deps analyzers accepted a context and never consulted it, so a cancelled scan kept reading files until the walk finished. They now check between artifacts.

  • A corrupt baseline was silently ignored, changing what the gate enforces under baseline_mode with no indication. A missing baseline stays silent.

  • OSV advisory hydration could be blanked by a proxy. Any JSON object decodes into the advisory type, so an intercepting proxy answering 200 with unrelated content produced a well-formed but empty record. Responses whose ID does not match the request are now rejected. Severity also falls back to the source database’s label for CVSS v4-only advisories, which previously collapsed to medium.

  • The registry deprecated flag did nothing. Carried in the index since 1.4.x but absent from the entry type, so plugin search and plugin install went on recommending retired plugins silently. Now surfaced in search, install (advisory, never blocking) and info.

1.10.x 1 release · Jul 2026

v1.10.0

  • Fixed
  • Changed
  • Added
  • Removed

Fixed

  • Go dependency scanning reported mostly wrong versions, at uniformly wrong severity, with no way to act on the result (#248). Three bugs, each of which hid the others.

    Versions came from go.sum. It is not a lockfile — it hashes the entire module graph, every version the resolver ever considered. Across 28 repositories 5,263 findings came from it; on one repository 148 of 148 named a version the build does not use (x/net flagged at a 2019 pseudo-version while the build selects v0.56.0). Projects had responded by excluding go.sum outright, trading the noise for a total blind spot on Go dependencies. Versions now come from go.mod — what Minimal Version Selection actually chose — consulting go.sum only for transitives that module graph pruning omits, and only for entries carrying a source hash (a /go.mod-only entry means the code was never downloaded).

    Severity and remediation were never populated. Every finding was medium with an empty summary — not one high or critical — so a critical dependency CVE could never trip a high/critical gate. OSV’s /v1/querybatch returns only {id, modified}, and OSV publishes CVSS as vector strings that the severity mapper could not parse; both had to be fixed for either to matter. Advisory detail is now fetched per distinct ID, and CVSS v3.x base scores are computed from the vector per the specification. This also restores the fix-version remediation field, which had never emitted anything because it reads data querybatch does not return.

    Advisories were matched per module, not per package. OSV scopes Go advisories to import paths: GO-2026-5932 affects only golang.org/x/crypto/openpgp, yet was reported against builds linking only chacha20/cryptobyte. Affected paths are now intersected with the packages the build links. Findings that are provably unreachable are demoted to info with reachable=false and the affected paths recorded — never dropped, since a silent disappearance is indistinguishable from a scanner that missed something. Where an advisory carries no import metadata, or the build cannot be enumerated, the finding is left exactly as before.

    Measured: vorhut 376 → 19 findings with every in-build module retained, mnemos 148 → 0, and severities spread across 1 critical / 24 high / 57 medium / 3 low where everything had been medium.

    Behaviour change: repositories with high or critical dependency vulnerabilities will start failing an enforcing gate. Those vulnerabilities were always present and were being reported as medium.

  • nox cache clear now clears the registry cache too (#246).

  • Removed an 8.9 MB binary committed to the repository (#241).

Changed

  • The plugin registry moved to its own repository, Nox-HQ/registry. The index, the sync tool and the marketplace builder left core, which was cataloguing seven other repositories: it needed a GitHub token, knew every plugin’s release cadence, and failed CI when an unrelated repository published. Plugin availability was also coupled to core’s default branch, since the index was served from raw.githubusercontent.com/nox-hq/nox/main/....

    nox now only consumes the published index over HTTP.

    Existing installs migrate automatically. The old URL is written into ~/.nox/state.json, and bootstrap only adds a default source when none exists — so the dead URL would otherwise have persisted and 404ed on every search and install. nox now detects it, rewrites it, and says so on stderr. A source you have deliberately re-pointed is left alone.

Added

  • CRYPTO-001 — broken cryptographic primitives (MD5, SHA-1, DES, RC4) across Go, Python, JS/TS and Java (#242).
  • TAINT-007 — open redirect (CWE-601) as a taint-gated sink for Go, Python, JavaScript, Java, PHP and Ruby (#243).

Removed

  • nox/sast is retired and removed from the plugin registry. Seven of its nine rules duplicated classes core’s taint engine already detects (SQLi, XSS, path traversal, command injection, deserialization, SSRF, SSTI) under a second rule-ID namespace, so enabling it reported the same vulnerability twice. Its two additive rules are now in core: weak crypto as CRYPTO-001, and open redirect as TAINT-007 — the latter taint-gated, where the plugin matched a regex against the shape of the code.

    Existing installs keep working; the plugin is simply no longer offered.

1.9.x 3 releases · Jul 2026

v1.9.2

  • Changed
  • Note on why this version exists

Changed

  • Bundles nox-plugin-reachability v0.7.0 (multi-language reachability: Rust, Java, Ruby, C#), built from the published module rather than an in-tree copy — so the bundled binary and the standalone release are the same artifact.
  • Removes the duplicated plugins/ tree. Every plugin now lives solely in its own repository (#238).

Note on why this version exists

v1.9.1 shipped correctly signed, but its Homebrew formula was not published: the tap credential was rotated while that release was running, so the job read the old value, warned, and skipped the formula — the graceful degradation added in #237 working as designed, with unlucky timing.

A release cannot be re-run to publish the formula afterwards (GoReleaser rejects re-uploading existing assets with 422 already_exists), so this release carries the formula update. brew install nox now resolves 1.9.2 rather than 1.8.0.

v1.9.1

  • Fixed

Fixed

  • A stale Homebrew tap token no longer skips cosign signing. The tap formula update ran inside the same goreleaser invocation as everything else, so an expired TAP_GITHUB_TOKEN (401) failed the release job — and sign, docker and update-major-tag all declare needs: release, so all three were skipped. v1.9.0 was published with unsigned artifacts, no container image, and the floating v1 tag left behind. The workflow now probes the credential first; a bad token degrades to a warning and a skipped formula update while the release, signing, image push and tag move proceed (#237).

v1.9.0 should not be used. Its artifacts are unsigned. Use v1.9.1.

v1.9.0

  • Added
  • Changed
  • Fixed
  • Added (tooling)

Added

  • Per-tool safety requirements (ToolDef.safety, sdk.ToolSafety(...)). Safety was declared per-plugin and validated at registration, so a plugin bundling tools with different needs had to declare the union — the strictest requirement of any one tool — and that union then gated every tool it ships. nox/red-team could not run its read-only analyze under a passive policy purely because it also ships validate.

    Registration now asks whether at least one tool is usable under the policy; the binding check is ValidateToolInvocation, applied by the host to the tool actually being called. A tool that declares no safety block inherits the plugin-level one, so existing plugins are unaffected.

    Note that this is deliberately not derived from the existing read_only flag: that means “does not mutate the workspace”, not “passive”. nox/llm-triage declares a read_only tool that sends source code to an external chat endpoint, so inferring passiveness from it would have granted egress to precisely the tool that exfiltrates source. The two checks are independent and a tool must satisfy both.

Changed

  • nox/red-team declares per-tool safety: analyze passive, validate active with network and confirmation. nox/k8s-runtime is intentionally unchanged — both scan and drift genuinely need the cluster API, so neither can honestly declare itself passive.

Fixed

  • Plugin modules no longer drift out of the build. Each plugin under plugins/ is a separate Go module with a replace to the root, and nothing in CI built them — go build ./... stops at the module boundary. A root dependency bump left 5 of 7 failing with every check green. All tidied, plus a plugin-modules CI job running go mod tidy -diff, build and test per module (#236).
  • Per-tool safety survived registration. RegisterPlugin/RegisterBinary rebuild the manifest from Info before validating it, and the conversion dropped ToolDef.safety — silently reverting to the plugin-level ceiling (#236).
  • IAC-351 no longer fires on id-token: write. The unanchored TOKEN pattern matched as a suffix of the YAML key, producing spurious critical findings on standard GitHub Actions OIDC permissions. Measured on this repo’s own workflows: 2 false positives before, 0 after (#233).

Added (tooling)

  • nox plugin install --local <path> <name> registers a locally built plugin binary for development. Recorded with trust level local and no digest so it is never confused with a verified marketplace artifact; safety policy applies unchanged (#236).

Changed

  • Cleared the 45 pre-existing golangci-lint issues. None were bugs; 23 fixed, 2 declined with reasoning recorded at the site, and hugeParam/rangeValCopy disabled in config after review — taking their advice would trade a no-mutation guarantee for eliding sub-200-byte copies (#235).
  • Bumped go.klarlabs.de/mcp to v1.24.0 (#232).
1.8.x 1 release · Jul 2026

v1.8.0

  • Added
  • Changed

Reconstructed after the fact: v1.8.0 was tagged and released without a CHANGELOG entry. Contents below are taken from the commits in v1.7.1..v1.8.0 rather than from a record written at the time.

Added

  • Structured content from MCP read/report tools (#230) — server tools now return structured output alongside text.

Changed

  • Bumped go.klarlabs.de/mcp to v1.21.0 (#227) and v1.22.0 (#229).
  • Bumped golang.org/x/net to 0.55.0 in nox-plugin-grc (#228).
1.7.x 2 releases · Jul 2026

v1.7.1

  • Fixed

Fixed

  • Restored the nox scan --baseline <path> flag as an optional override. It was removed in favour of auto-discovering .nox/baseline.json, which is a good default but a breaking change on its own: a workflow still passing -baseline errored (exit 2), and under a nox scan … || true pipeline that silently disabled scanning. Auto-discovery remains the default; when set, the flag (and its config equivalent policy.baseline_path) points the baseline at a non-default location — an explicit flag takes precedence over config. An unrecognized scan flag now prints an actionable hint (run 'nox scan -h') instead of a bare parser error, so a removed/typo’d flag can’t quietly become a silent-disable trap again.

v1.7.0

  • Added
  • Changed
  • Fixed
  • Plugins

This release turns nox’s SAST layer from a 3-language proof of concept into a 21-language deterministic taint engine, and raises measured precision from 0.30 to 1.00 on an honest, self-defending corpus.

Added

  • Real SAST taint analysis across 21 languages. A pure-Go lexctx lexical classifier plus an intraprocedural (and same-file interprocedural) dataflow engine track untrusted input from sources to dangerous sinks, honoring per-vuln-class sanitizers. Covered: Python, JavaScript/TypeScript, Go, PHP, Java, C#, Rust, Ruby, C/C++, Scala, Kotlin, Perl, Swift, PowerShell, Shell, Lua, Dart, Objective-C, Elixir, Clojure, and Groovy. Go uses the stdlib go/ast parser (pure-Go, no CGo); every other language uses a deterministic line/statement recognizer — no tree-sitter, no native dependencies, single static binary. Vulnerability classes: SQL injection (TAINT-001), command injection (TAINT-002), XSS (TAINT-003), path traversal (TAINT-004), unsafe deserialization / code injection (TAINT-005), and SSRF (TAINT-006). Memory-safety bugs are explicitly out of scope for the taint model (a different analysis).
  • Interprocedural taint (same-file). Function summaries propagate taint across helper calls within a file (source() -> wrap() -> os.system() is caught through the wrapper), with the call path named in finding metadata.
  • agentflow analyzer for agentic dataflow. AGENTFLOW-001 flags untrusted input reaching an LLM prompt (OWASP ASI01 / CWE-1427); AGENTFLOW-002 flags LLM output flowing into a dangerous sink (ASI02 / CWE-77).
  • Honest SAST precision harness. nox bench --precision <dir> scores a labeled corpus against ground truth (per-rule and overall precision/recall/F1, findings-per-issue density, noise ratio). A ratchet with per-rule floors, an anti-cheat self-test (the corpus provably cannot fake a perfect score), a determinism guard, and a CI precision gate keep the number honest on every PR.

Changed

  • SAST precision raised from 0.30 to 1.00 by fixing six false-positive classes at the engine level (not by curating the corpus): secret over-firing (specificity dedup + canonical-owner resolution), placeholder/example-credential allowlisting, an AI-002 prompt-context gate, decoded-base64-blob entropy suppression, a cross-analyzer same-vuln-class dedup, and SRI-hash recognition.
  • lexctx now classifies Go source, activating comment/string/data-blob false-positive suppression for .go files.

Fixed

  • Removed a set of contextual/common-word keywords (show, hide, on, part, num) from the shared identifier-keyword set where they silently suppressed real variable/function reads across languages.

Plugins

  • nox-plugin-llm-triage grounding. The optional LLM triage layer is passed an immutable rule ID / file:line / snippet and may only return a verdict and rationale — it can never invent or relocate a finding.
1.6.x 1 release · Jul 2026

v1.6.0

  • Added

Added

  • Slopsquatting / hallucinated-package detection (SLOP-001). A new deterministic, offline analyzer flags source-code imports of a package that is not declared in any dependency manifest, is not a language standard-library module, and is not a first-party local module — the slopsquatting attack surface (an LLM hallucinates a plausible package name, a developer installs it, and an attacker who pre-registered the name runs code). Covers Python (.py/.pyi) and JS/TS (.js/.jsx/.mjs/.cjs/.ts/.tsx/.mts/.cts), with embedded stdlib/builtin lists, an import→distribution name map (yaml→pyyaml, cv2→opencv-python, …) and PEP 503 normalization to keep false positives low. Declared set parsed from package.json, package-lock.json, requirements*.txt, pyproject.toml (PEP 621 + Poetry), and Pipfile. Never contacts a registry. Tagged owasp-asi04 / owasp-llm03.
  • CVE-variant detection + nox variants. A new analyzer flags first-party code that reproduces the root-cause pattern of a known CVE — variants a version-based SCA can’t see because there’s no vulnerable dependency, just the same insecure shape written locally. Ships signatures for Log4Shell JNDI (CVE-2021-44228), PyYAML full-loader (CVE-2020-14343), tar extractall without filter (CVE-2007-4559), Zip Slip (CVE-2018-1002200), Jinja SSTI (CVE-2019-10906), and child_process shell interpolation (CVE-2021-21315), each with a safe-form exclusion. nox variants [CVE-ID] [path] reports variants (optionally filtered to one CVE); nox variants --list enumerates the signatures.
  • Dependency-provenance checks (PROV-001/PROV-002). A new deterministic, offline analyzer for the supply-chain provenance gap a version-based SCA misses (OWASP ASI04 / SLSA): PROV-001 flags a dependency pulled from a VCS repo, raw URL, or tarball instead of a signed registry; PROV-002 flags a VCS dependency pinned to a mutable ref (branch/tag) instead of an immutable commit SHA. Parses package.json and requirements*.txt; registry ranges and #sha256= hash-verified URLs never fire. Live sigstore/SLSA attestation verification needs the network and is left to an opt-in plugin — this analyzer makes no network call.
  • Agent-config coverage for A2A and DXT (AGENT-005/AGENT-006). AGENT-005 (ASI07) flags an A2A agent card (agent.json / .well-known/agent.json) that declares an empty or none security scheme while advertising skills — an unauthenticated inter-agent endpoint. AGENT-006 (ASI02) flags a DXT desktop-extension manifest (manifest.json / *.dxt) that interpolates a ${user_config.*} value into the server executable or a shell -c string — command injection at launch. Both are scoped to avoid false positives (a populated securitySchemes block and a ${user_config.x} passed as a discrete argv element are silent).
  • JetBrains plugin (editors/jetbrains). A thin client over the nox lsp language server for IntelliJ IDEA Ultimate, GoLand, PyCharm Professional, WebStorm, and the other paid JetBrains IDEs (platform LSP API, 2023.2+), with a documented LSP4IJ path for the free Community editions. Mirrors the VS Code extension: deterministic, offline diagnostics on open/save.
  • Optional LLM-triage plugin (nox-plugin-llm-triage). An opt-in escape hatch for teams that want an LLM’s judgment on top of the deterministic core: it consumes the scan’s findings, sends each plus a code snippet to an OpenAI-compatible chat endpoint, and attaches a true/false-positive verdict as an enrichment on the original finding. It never changes or gates the scan result. Active egress — refuses to run without authorize: true. The core stays deterministic and offline whether or not it is installed.
1.5.x 1 release · Jul 2026

v1.5.0

  • Added
  • Fixed

Added

  • VS Code extension (editors/vscode). A thin client over the nox lsp language server: surfaces nox findings inline (squiggles, hover, Problems panel) as you open and save files. Deterministic and offline — it runs the local nox binary, no code leaves the machine. Delivers the editor-integration half of #47 on top of nox lsp; a JetBrains plugin is the same shape.
  • Post-scan plugins run automatically in nox scan. Plugins whose tools need the findings the scan just produced — those declaring requires_scan_context — now run as part of the pipeline (before refinement, so their findings are deduped, suppressed, and policy-gated). The reachability plugin is the motivating case: listed in plugins.required, it now classifies the scan’s vulnerabilities as reachable / unreachable / undetermined without a separate manual nox plugin call, feeding the --sort priority deprioritization of likely-false-positive unreachable vulns.
  • nox lsp — Language Server for editor diagnostics. Runs a minimal LSP server over stdio (JSON-RPC 2.0 with Content-Length framing) that scans the active file on open/save and publishes nox findings as textDocument/publishDiagnostics. Deterministic and fully offline — it just runs the scanner on the one file, maps each finding’s location/severity/RuleID onto an LSP diagnostic (critical/high→Error, medium→Warning, low→Information, info→Hint), and sorts them stably by line, column, then rule id. Hand-rolled JSON-RPC (no third-party LSP library, no network); didClose clears diagnostics and scan errors publish an empty set instead of crashing.
  • nox scan --sort priority — reachability-aware finding prioritization. Orders findings.json by what’s most actionable — severity first, then reachability, then confidence — instead of the default rule/path/line order that buries criticals. Paired with the reachability plugin (which enriches VULN findings with a reachable flag), a confirmed-reachable vuln rises and a likely-false-positive unreachable one sinks to the bottom, so the report leads with real risk. Deterministic (stable location tiebreak); the default order is unchanged, preserving baselines and diffs.
  • nox fix --content — deterministic patches for mechanical misconfigurations. Reads findings.json and rewrites the flagged line to its one unambiguous secure value: Kubernetes hardening flips (privileged: true→false, runAsNonRoot: false→true, hostNetwork/hostPID/hostIPC/allowPrivilegeEscalation/ automountServiceAccountToken, readOnlyRootFilesystem), Terraform (storage_encrypted/enable_https_traffic_only, protocol "HTTP"→"HTTPS", acl "public-read"→"private"), CI (continue-on-error: true→false), and Dockerfile ADD→COPY. Template-free and no LLM — only rules with a single correct answer are fixed; anything needing a choice (a UID, a pinned digest, an allowlist, a rotated secret) is deliberately never touched. Previews the diff by default and applies nothing; --content --write applies.
  • nox baseline init — one-command adoption for a debt-laden repo. Scans, records every current finding as accepted baseline debt (reported by severity), and prints the “gate the change, not the history” policy to add (fail_on + baseline_mode: warn). Refuses to clobber an existing baseline (use update, or --force). See the new docs/adoption.md, which ties together baseline init, per-severity budgets, --tracked-only, and --offline into a five-minute adoption path.
  • Broader language coverage: JS/TS module + JSX variants, plus Kotlin, Swift, PHP. .tsx/.jsx/.mjs/.cjs/.mts/.cts are now classified as source, and every rule scoped to *.ts/*.js is auto-expanded to the variants — so a React/Next.js AI app’s .tsx components (where prompts are built and the model is called) are scanned instead of silently skipped. .kt/.swift/.php join the source set too.
  • OWASP Top 10 for Agentic Applications (ASI01–ASI10) mapping. Findings against agentic surfaces now carry their owasp-asi* control (in SARIF properties.tags and finding metadata), the way they already carry OWASP LLM and MCP Top 10 tags. Mapped: ASI01 Agent Goal Hijack (AGENT-001/004, prompt- injection rules), ASI02 Tool Misuse (AGENT-002/003, tool-exposure rules), ASI03 Identity & Privilege Abuse (MCP authz/SSRF), ASI04 Agentic Supply Chain (MCP tool-poisoning, rug-pull, shadow servers, model supply chain), ASI05 Unexpected Code Execution. Runtime/multi-agent categories nox can’t statically detect (ASI06/07/08) are deliberately left unmapped rather than over-claimed.
  • Per-severity policy budgets (policy.budget). The gate can now tolerate a bounded amount of new debt per severity before failing — e.g. budget: {medium: 5, low: 20} fails only on the 6th new medium while still failing on any new high/critical. It refines fail_on: a severity at/above the threshold with no budget entry defaults to 0 (fail on the first, unchanged), so an empty budget reproduces the previous gate exactly. Lets a team adopt a strict threshold on a debt-laden repo without baselining every finding.
  • Proof-of-offline attestation in the report. findings.json meta now carries an "offline" boolean recording whether the scan ran under the zero-network guarantee (nox scan --offline: no OSV, no API, no token, no telemetry). A reviewer or CISO can confirm straight from the artifact that the scanner never touched the network — backed by the enforced egress test (TestOSVDisabled_NoNetworkEgress), not just a claim. --offline also prints an [offline] confirmation line. This is the differentiator vs. LLM-powered scanners that ship your code to a model provider.
  • nox scan --tracked-only. Restricts the scan to git-tracked files (git ls-files), excluding untracked working-tree files (scratch files, build output, un-added drafts) and submodule contents. Scans exactly what is committed — the same set a reviewer sees — so a CI gate is reproducible and doesn’t flag a developer’s local scratch file. Ignored outside a git repo.
  • Agent-config artifact scanning (AGENT-001..004). The files that steer a coding agent are an execution surface, not just docs — a poisoned rule file or an over-broad permission grant silently changes what the agent runs, reads, and exfiltrates. nox now scans Cursor/Cline rules (.cursorrules, *.mdc), CLAUDE.md/AGENTS.md/GEMINI.md, Claude Code skills (SKILL.md), and agent settings (.claude/settings.json), classifying them as AI components. New rules: AGENT-001 instruction-override / prompt-injection directives in a rules file (OWASP LLM01), AGENT-002 settings that disable the human-in-the-loop permission gate (bypassPermissions, --dangerously-skip-permissions, autoApprove), AGENT-003 wildcard tool grants ("Bash(*)"), and AGENT-004 exfiltration / concealment directives. filePatterns are scoped to exact agent-config filenames (never *.go/*.md broadly), so ordinary source and documentation are untouched. (#145)

Fixed

  • Entropy secret detectors no longer flag natural-language prose (#104). The generic SEC-161 (high-entropy assignment) and SEC-163 (high-entropy hex) detectors fired on long English sentences, SQL, error-message format strings, and prompt templates — high aggregate entropy but not credentials. A compact secret token (API key, hash, base64/hex blob) never contains internal whitespace, so candidates with a space or tab are now rejected. On a prose-heavy codebase this removes the dominant false-positive class (a real case had 109 of 129 findings as prose FPs); real whitespace-free secrets are still detected (the base64/hex tokenizers extract the token itself).
  • nox scan <file> scans a single file. A file target used to fail (it looked for <file>/.nox.yaml and the walker skipped its own root, finding nothing). Now it loads config from the file’s directory and scans just that file — the basis for fast pre-commit hooks and editor integrations.
  • scan.exclude is now a hard exclude that survives --changed-since. Since the tracked-file fix (#142), a file listed in scan.exclude that was also git-tracked got re-scanned — the tracked-file override treated the config exclude like a .gitignore pattern. So an explicit exclusion (e.g. a rule-definition file full of expected-false-positive patterns) was silently ignored in --changed-since scans, which is what failed nox’s own PR gate on every rule change. Config excludes are now a separate hard rule the tracked override never resurrects.
  • Unsafe-output-handling rules no longer fire on documentation. AI-009/012/ 015/018 (eval/exec, DB query, innerHTML, file path from LLM output) target real code sinks but matched prose in docs that quote those sinks — most visibly nox’s own CHANGELOG entry cursor.execute("SELECT " + completion), which tripped AI-012 (high) and failed the PR gate on every change that edited the changelog. A markdown file can’t execute, so these rules now skip docs and test files (joining the existing prose/logging noise-glob set); real source is unaffected.
  • Tracked files under a gitignored directory are now scanned. git never ignores a file it already tracks, even when a .gitignore pattern matches it — but nox applied ignore patterns purely from the filesystem and skipped them, a scanner blind spot for any repo that gitignores a directory yet commits sources into it (e.g. pet-medical ignores mobile/ but tracks ~80 files under it, none of which were scanned — a committed secret there would go undetected). The scan now consults git ls-files: a tracked path is scanned even under an ignored directory, while genuinely-ignored (untracked) files stay skipped. Outside a git repo, behavior is unchanged. Note: repos with tracked files under ignored directories will see new findings on the next scan and should refresh their baseline. (#142)
1.4.x 3 releases · Jul 2026

v1.4.2

  • Fixed

Fixed

  • .gitignore is now honored when scanning from inside a git worktree. In a linked worktree (and submodule) .git is a gitdir-pointer file, not a directory, so loading .git/info/exclude failed with ENOTDIR — an error that discarded every pattern already read from .gitignore, leaving the walker with zero ignore rules. A scan run from a worktree therefore found strictly more than the same HEAD scanned from the real checkout (a mobile/-ignored subtree reappeared: 721 vs 640 findings), so a baseline written from a plain directory never matched a worktree rescan. info/exclude is now resolved via the worktree’s commondir (git shares it across worktrees), and a non-directory path component contributes no patterns instead of nuking the set. Dir and worktree scans of the same HEAD are now identical. (#140)

v1.4.1

  • Changed

Changed

  • nox fix --actions now SHA-pins mutable tag refs, not just outdated ones. A uses: owner/action@v7 that already tracks the latest release is still a mutable ref — a supply-chain risk and a frequent PR-review flag. --actions now rewrites any tag ref to @<sha> # <tag>, pinning to the same-major latest release (or to the tag’s own commit when a newer major is being held back). Already-SHA-pinned, up-to-date refs remain untouched, so remediation PRs are no longer blocked by “still using a mutable tag” review comments. Behavior for the dependency pass (default nox fix) is unchanged.

v1.4.0

  • Added

Added

  • nox fix remediates GitHub Actions pins. --actions (alongside the package-dependency pass) or --actions-only scans .github/workflows and .github/actions, resolves each uses: action to its latest release via the GitHub API, and rewrites outdated pins to @<sha> # <tag> (SHA-pinned, best practice). Major-version jumps are skipped unless --include-major; branch pins (e.g. @main reusable workflows) are left alone. Needs GITHUB_TOKEN. This lets nox own dependency and CI-action remediation, replacing dependabot.

Releases before 1.4 predate the stable finding schema and the signed marketplace. Their notes are on GitHub Releases.

Get notified when a release ships

Roughly one email a month — new rule families, marketplace plugin releases, and the changes worth acting on.