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.
-
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.
-
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.