# Dependency Alert Triage Report - netbox - 2026-08-16

## Executive Summary

**Nothing needs fixing today, and no verdict rests on evidence a person still has to resolve. Two alerts are scheduled for a planned fix in the web tier, under the policy's 60-day Moderate window. The remaining 20 of 22 context-specific verdicts (91%) are recommended for deferral, with the evidence below.**

All 11 open alerts were retrieved and the count verified. Each finding's reachability was analyzed against this project's own source and its vendored dependency source, in both execution contexts this project declares. Exploitation evidence came from KEV (the CISA/VulnCheck catalog of vulnerabilities with confirmed exploitation in the wild) and EPSS (a daily model score estimating each vulnerability's probability of exploitation in the next 30 days, read as a percentile against all scored CVEs).

Each alert was scored in both execution contexts NetBox ships as systemd units, one verdict per context, so 11 alerts produce 22 verdicts. The two standing answers:

- **How many findings are exploitable and reachable in production right now?** Zero. No alert in this set is both in KEV and reachable, and none carries an EPSS score anywhere near the top-1% line.
- **How much noise did scoring remove?** 20 of 22 scored verdicts (91%) are recommended for deferral as not reachable or not exploitable here. Focus can be directed elsewhere.

The reachability and exploitability analysis retired 1,260 of 1,760 inherent NIST risk points (72%), safely deferred 20 of 22 verdicts, and moved 20 verdicts from a High inherent band to a Low residual band.

| Bucket | Count | Meaning |
| --- | --- | --- |
| FIX_TODAY | 0 | Fix now |
| REVIEW | 0 | Unresolved question; investigate before deciding |
| SCHEDULE | 2 | Fix on a planned horizon; SLA below |
| DEFER | 20 | No action needed; evidence below |

**Recommendation:** plan the `idna` upgrade for the web tier, and treat the nine PyJWT alerts as deferrable on the evidence in the DEFER section.

## FIX_TODAY / REVIEW: act now or resolve the unknown

None. Nothing requires immediate action, and no verdict rests on unresolved evidence.

## SCHEDULE: fix on a planned horizon

One advisory is scheduled, and it is scheduled because a call path was traced, not because a score looked high. `idna.encode()` runs on a hostname an authenticated NetBox user chooses, in the gunicorn web process. The vulnerable code is present in the installed version: `vendor/idna/core.py:333` runs the expensive per-codepoint validation before the length rejection at `:336`. Neither CVE is in KEV, and the EPSS percentile is 0.34 against a top-1% threat line, so the urgency comes from the traced path and the asset, not from exploitation evidence. The nearest deadline is 60 calendar days from the date the alert was opened, a date this run did not receive. Entries are ordered by SLA expiry, soonest first, with residual composite descending as the tiebreaker.

### CVE-2026-45409 — a denial of service in the domain-name encoder, reachable through the RSS dashboard widget — SCHEDULE

- **Finding:** `pypi:idna:PYSEC-2026-215` and `pypi:idna:GHSA-65pc-fj4g-8rjx` (idna 3.9, transitive through `requests`/`urllib3`, declared in `requirements.txt`). Two alerts for one advisory, grouped: identical verdict and identical evidence. This entry covers the `netbox-web` context; the same advisory in `netbox-worker` is deferred, with its evidence in the DEFER section.
- **Why SCHEDULE:** Exposure is present because a call path was traced end to end with attacker-controlled input, and Impact is present on a tier_2 asset. Threat is absent. Two of three factors present is a SCHEDULE, and no policy floor activated.
- **Residual risk:** Moderate, 50/100 (inherent High 80; 30 points retired).
- **SLA expiry:** ? — the policy dates the clock from the day the alert was opened in the tracking tool, and the supplied alert list carries no open date.
- **Evidence:** Not in KEV. EPSS percentile 0.34, raw score 0.0041, well below the 0.99 top-1% line. Traced call graph (`manual_call_graph`): an authenticated user sets the RSS widget's `feed_url` through `DashboardWidgetConfigView` (`netbox/extras/views.py:1560`); rendering the dashboard calls `RSSFeedWidget.get_feed()` (`netbox/extras/dashboard/widgets.py:375`), which passes that URL to `requests.get`, reaching `idna.encode` through `vendor/urllib3/util/url.py:340` and `vendor/requests/models.py:476`. The project's risk context names the authenticated internal user as an untrusted actor and names the RSS widget's `feed_url` as a value that actor controls. One qualifier the reader should weigh: `feed_url` is a Django `URLField` (`widgets.py:324`), so Django's own URL validation bounds the input upstream. Django is not vendored in this checkout, so that bound could not be read from source and is not treated as defeating the path.
- **Recommended action:** upgrade `idna` to 3.15 or later in `requirements.txt`. 3.14 moved the length rejection ahead of the expensive validation in `idna.encode()`, and 3.15 extended the same fix to the per-label and codec entry points that `urllib3` uses. Enforcing the 253-character domain-name limit before the call is the workaround the advisory names, if the upgrade has to wait.

## DEFER: why these alerts are safely deferrable

This section is the evidence that each deferral was earned. The deferrals below were produced by the same analysis, to the same evidentiary standard, as the finding scheduled above: one process, one quality bar, different outcomes on different evidence.

Twenty verdicts across ten alerts are recommended for deferral. Nine of those alerts (all PyJWT) are deferred in both execution contexts; the tenth pair is the `idna` advisory above, deferred in the worker context only. None of the underlying CVEs is in KEV. Every EPSS percentile in the deferred set sits between 0.03 and 0.33, far below the 0.99 top-1% line, so threat is confidently absent throughout.

### Group A: PyJWKClient is never constructed (8 verdicts, 4 alerts, both contexts)

**CVE-2026-48522 (`pypi:pyjwt:PYSEC-2026-175`, `pypi:pyjwt:GHSA-993g-76c3-p5m4`)**: a missing URL-scheme allowlist that lets a `jku` pointing at `file://` or `ftp://` be fetched. **CVE-2026-48524 (`pypi:pyjwt:PYSEC-2026-177`, `pypi:pyjwt:GHSA-fhv5-28vv-h8m8`)**: unbounded JWKS refetches driven by unknown `kid` values.

Both flaws live entirely inside `PyJWKClient` (`vendor/jwt/jwks_client.py`). That class is constructed nowhere in this project: not in NetBox's own source, which imports `jwt` in no module, and not in `social_core`, which is the only thing that loads PyJWT here. The class is loaded, because `vendor/jwt/__init__.py:28` imports it, so this is not a case of absent code. It is code with no caller. Every social-auth OIDC backend fetches and selects JWKS keys itself and never instantiates the client. Not reachable from any entry point (`unreachable_from_entrypoint`) in either the web tier or the worker.

Residual Low, 20/100, against an inherent High of 80: 60 points retired per verdict. Not in KEV; EPSS percentile 0.13 for CVE-2026-48522 and 0.27 for CVE-2026-48524.

### Group B: the vulnerable branch requires a call shape this project never uses (6 verdicts, 3 alerts, both contexts)

**CVE-2026-48523 (`pypi:pyjwt:GHSA-jq35-7prp-9v3f`)**: an algorithm allow-list bypass that fires only when `jwt.decode()` is handed a `PyJWK` object as the key, verified at `vendor/jwt/api_jws.py:350`, where `isinstance(key, PyJWK)` selects the key's bound algorithm instead of the header's. **CVE-2026-48526 (`pypi:pyjwt:PYSEC-2026-179`, `pypi:pyjwt:GHSA-xgmm-8j9v-c9wx`)**: a public-key JWK accepted as an HMAC secret, which needs an `algorithms` list mixing HS\* with an asymmetric algorithm and a raw JSON JWK passed as the key.

Every `jwt.decode` and `jws.decode` call site in the tree was enumerated: `open_id_connect.py:322`, `auth0.py:49`, `ping.py:58`, `keycloak.py:121`, `azuread_b2c.py:201`, `azuread_tenant.py:106`, `azuread.py:114`, `apple.py:136`, `mediawiki.py:136`, and `exacttarget.py:67`. Every one passes a prepared key object or a PEM string, never the `PyJWK` itself, and every one names a single algorithm family: `["RS256"]` or `["HS256"]`. Neither precondition holds anywhere. Not reachable from any entry point (`unreachable_from_entrypoint`) in either context.

Residual Low, 20/100, inherent High 80, 60 points retired per verdict. Not in KEV; EPSS percentile 0.03 for CVE-2026-48523 and 0.33 for CVE-2026-48526.

### Group C: the decode runs, but only on a token the identity provider supplied (4 verdicts, 2 alerts)

**CVE-2026-48525 (`pypi:pyjwt:PYSEC-2026-178`, `pypi:pyjwt:GHSA-w7vc-732c-9m39`)**: an unauthenticated denial of service from decoding an oversized Base64URL payload segment before the detached-JWS rules are applied.

The wasteful decode is real on the normal path, not just the detached one: `vendor/jwt/api_jws.py:320` decodes the payload segment inside `_load()`, and the `b64=false` handling at `:244` runs afterward. What the flaw needs is an attacker who can hand the verifier a token of their choosing. In the web tier, the only token PyJWT decodes is the OIDC `id_token`, which NetBox fetches from the identity provider over the authorization-code back channel at `vendor/social_core/backends/open_id_connect.py:376`. The project's risk context names the corporate identity provider a trusted actor for exactly that reason. NetBox exposes no detached-JWS verification endpoint: `detached_payload` is passed nowhere outside the PyJWT package itself. Reached only with first-party input (`no_attacker_controlled_input`) in the web tier. In the worker, no job decodes a JWT at all, so the symbol is not reached (`unreachable_from_entrypoint`).

Residual Low, 20/100, inherent High 80, 60 points retired per verdict. Not in KEV; EPSS percentile 0.30.

### Group D: idna in the worker, where every outbound hostname is first-party (2 verdicts, 2 alerts)

**CVE-2026-45409 (`pypi:idna:PYSEC-2026-215`, `pypi:idna:GHSA-65pc-fj4g-8rjx`)**, in the `netbox-worker` context only. The same advisory is scheduled in the web tier above.

The worker does reach `idna.encode`, through the same `requests` and `urllib3` machinery, whenever it makes an outbound call. The difference is who picks the hostname. Every worker outbound call site was enumerated: webhook delivery at `netbox/extras/webhooks.py:124`, the census ping at `netbox/core/jobs.py:102`, the release check at `:209`, and the plugin catalog fetch at `netbox/core/plugins.py:133`. The last three take their URL from settings. Webhook delivery renders `payload_url` as a Jinja2 template, which would matter, except that the project's risk context states as a deployment fact that every configured webhook's `payload_url` is a fixed internal hostname and none templates a hostname from object data. That statement is what decides this verdict, and the code alone could not. The worker also has no inbound listener. Reached only with first-party input (`no_attacker_controlled_input`).

Residual Low, 20/100, inherent High 80, 60 points retired per verdict. Not in KEV; EPSS percentile 0.34.

### The boundary of these deferrals

No deferred finding sits in the EPSS gray zone. The highest percentile in the set is 0.34 for CVE-2026-45409, against a gray-zone floor of 0.90 and a threat line of 0.99, so none is close to promotion on the current model. No finding is threat-unknown; every advisory resolved to a CVE with both KEV and EPSS records.

What would change these verdicts, derived from the factor math:

- **Threat evidence appearing.** Impact is present on every deferred finding, because both contexts are tier_2. A KEV listing or an EPSS retrain past the top-1% line would flip Threat to present and move any of these from DEFER to SCHEDULE, and a KEV listing would also activate the `kev_emergency` floor. Re-score if any of these six CVEs is listed.
- **A first NetBox caller for `PyJWKClient`.** Groups A and B rest on enumerated call sites. A NetBox plugin, or a social-auth version that adopts `PyJWKClient.get_signing_key_from_jwt()`, would put those advisories back on a live path. Both would show up as a new import of `jwt` or a new `PyJWKClient(` construction.
- **A webhook whose `payload_url` templates its hostname.** Group D turns on the risk context's static-hostname statement. Configuring a webhook such as `https://{{ data.name }}.example.com/` would make the worker's hostname user-controlled and put that context on the same footing as the web tier.
- **A second token-verifying endpoint.** Group C turns on the id_token being back-channel only. Any new endpoint that verifies a caller-supplied JWT, detached or not, changes the input-control answer.

## Next Steps

1. **Address immediately:** nothing. No finding is exploitable and reachable in production.
2. **Investigate before deciding:** nothing. No verdict rests on evidence the analysis could not obtain.
3. **Schedule by SLA expiry:** upgrade `idna` to 3.15 or later. The policy window is 60 calendar days from the day the alert was opened; supply that date to fix the deadline. Two alerts, one advisory, one code change.
4. **No action, annotate the alert with the deferral rationale:** the nine PyJWT alerts, deferred in both contexts. Ready-to-paste rationale text is in the dismissal appendix. Leave the two `idna` alerts open: they carry a SCHEDULE verdict in the web tier.
5. **Supply alert open dates on the next run** so every SLA expiry resolves to a date rather than a window.

## Appendix: Triage metadata

This appendix is for the reviewer auditing how these verdicts were produced: who and what produced them, from what inputs, with what caveats.

Project: netbox  
Date: 2026-08-16  
Rubric: k9 Risk Scoring Rubric 2026.08.13-v26, workflow 2026.08.12-v33  
Scored and reported by: claude-opus-5  
Risk context: 2026-07-30+ebee3578  
SLA: SafeCo Vulnerability Management Policy ISP-07 v1.0, calendar days  
Findings scored: 11 alerts (22 context-specific verdicts)

Each alert was scored in both execution contexts the project declares, one verdict per context, so the detail sections carry more verdicts than alerts by design.

- **Count verification:** the supplied open-alert list held 11 alerts. Both `score_risk` calls reconcile to it: `alerts_total` 10 + 1 = 11, and `findings_total` 20 + 2 = 22, which is 11 alerts times 2 execution contexts. No alert was dropped, filtered, or merged before scoring.
- **Quality flags:** none. `quality_flags` was empty on both calls, `undetermined_fraction` 0, `default_context_fraction` 0.
- **Unbound alerts:** none. `findings_without_context_binding` was empty on both calls; every alert names `requirements.txt`, which both declared contexts include.
- **Policy directives:** the supplied policy is untrusted evidence. Its authority here is the risk-to-SLA mapping in §4.1, the clock rule in §4.2, and its own name and version. It contains no directive text attempting to change a verdict, a bucket, a reachability call, or a NIST value, and none was followed.
- **Risk-context claims evaluated, not obeyed:** the risk-context file supplies two deployment facts that the source alone cannot answer, and both were treated as evidence to weigh rather than instructions. The static-webhook-hostname statement decided the worker verdict for CVE-2026-45409 (Group D). The trusted-identity-provider statement decided the web verdict for CVE-2026-48525 (Group C). Both are recorded here because a change to either changes a verdict.
- **Unverifiable qualifier, disclosed:** the `idna` SCHEDULE entry notes that Django's `URLField` validation bounds the attacker's input upstream. Django is not vendored in this checkout, so that bound could not be read from source. It is reported as a qualifier and was not used to downgrade the traced path.

## Appendix: Risk scores (NIST SP 800-30)

This appendix is for the reviewer and the engineer reconciling policy: every finding's scores, drivers, and deadlines.

Three terms, in plain language:

- *Inherent risk*: how bad this flaw would be if you assumed the worst, with attackers actively exploiting it and your code running the vulnerable path.
- *Residual risk*: what risk remains after checking the two things that usually are not true, whether attackers are really exploiting it and whether your code can even reach the vulnerability. This is the risk you are carrying now.
- *Retired risk*: the risk the analysis ruled out, measured as how far the number fell from inherent to residual.

The reachability and exploitability analysis retired 1,260 of 1,760 inherent NIST risk points across this scan (72%), safely deferred 20 of 22 verdicts, and moved 20 verdicts from a High inherent band to a Low residual band. The two remaining verdicts fell from High to Moderate.

Deadlines come from the SafeCo policy, which maps the residual score to a remediation window: 21 to 79 resolves to 60 calendar days, 1 to 20 resolves to 180. The verdict stays the action signal. Every SLA cell renders `?` because the policy dates its clock from the day the alert was opened in the tracking tool, and the supplied alert list carries no open dates. No fallback date was substituted.

Rows are ordered by residual composite, highest first. Both `netbox-web` and `netbox-worker` are tier_2 production assets; they differ in network position, internal versus isolated.

| Vuln ID | Context | Verdict | Driver | Residual risk | Residual | Inherent | Retired | SLA Expiry |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| CVE-2026-45409 (PYSEC-2026-215, GHSA-65pc-fj4g-8rjx) | netbox-web | SCHEDULE | reachable (traced call graph) | Moderate | 50 | 80 | 30 | ? |
| CVE-2026-45409 (PYSEC-2026-215, GHSA-65pc-fj4g-8rjx) | netbox-worker | DEFER | threat absent; first-party input only | Low | 20 | 80 | 60 | ? |
| CVE-2026-48522 (PYSEC-2026-175, GHSA-993g-76c3-p5m4) | both | DEFER | threat+exposure absent; no caller | Low | 20 | 80 | 60 | ? |
| CVE-2026-48523 (GHSA-jq35-7prp-9v3f) | both | DEFER | threat+exposure absent; branch never entered | Low | 20 | 80 | 60 | ? |
| CVE-2026-48524 (PYSEC-2026-177, GHSA-fhv5-28vv-h8m8) | both | DEFER | threat+exposure absent; no caller | Low | 20 | 80 | 60 | ? |
| CVE-2026-48525 (PYSEC-2026-178, GHSA-w7vc-732c-9m39) | netbox-web | DEFER | threat absent; first-party input only | Low | 20 | 80 | 60 | ? |
| CVE-2026-48525 (PYSEC-2026-178, GHSA-w7vc-732c-9m39) | netbox-worker | DEFER | threat+exposure absent; not reached | Low | 20 | 80 | 60 | ? |
| CVE-2026-48526 (PYSEC-2026-179, GHSA-xgmm-8j9v-c9wx) | both | DEFER | threat+exposure absent; preconditions absent | Low | 20 | 80 | 60 | ? |

Rows marked "both" carry identical scores and drivers in each context and represent two verdicts each. Every residual value carries `high` confidence: no factor defaulted anywhere in this scan.

Mapping to your own priority scheme: anchor priority on the verdict, and use the residual score as context or a tiebreaker. No residual band in this report sits above its verdict.

## Appendix: Alert dismissal commands

This appendix is for the operator executing the deferrals: the ready-to-run record.

The open-alert list for this run was supplied directly rather than fetched from a named system of record, so no dismissal API endpoint can be addressed here and no command can be written against it. The durable rationale record for these verdicts is the k9 scored-findings capture, which holds one row per alert per context under `risk_context_version` 2026-07-30+ebee3578 and `rubric_version` 2026.08.13-v26. Paste the text below into the dismissal comment field of whatever tool holds these alerts. Each line is under 280 ASCII characters, which is GitHub Dependabot's `dismissed_comment` cap.

Dismiss these nine, deferred in both execution contexts:

- `pypi:pyjwt:PYSEC-2026-175` — DEFER (k9 v26). PyJWKClient is never constructed: not in NetBox source, not in social_core. Loaded via jwt/__init__ but has no caller. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p13. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:GHSA-993g-76c3-p5m4` — DEFER (k9 v26). PyJWKClient is never constructed: not in NetBox source, not in social_core. Loaded via jwt/__init__ but has no caller. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p13. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:PYSEC-2026-177` — DEFER (k9 v26). PyJWKClient is never constructed anywhere in this project, so its kid-refresh loop has no caller. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p27. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:GHSA-fhv5-28vv-h8m8` — DEFER (k9 v26). PyJWKClient is never constructed anywhere in this project, so its kid-refresh loop has no caller. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p27. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:GHSA-jq35-7prp-9v3f` — DEFER (k9 v26). The bypass needs a PyJWK object as jwt.decode's key (api_jws.py:350). All 10 decode call sites in the tree pass a prepared key or PEM. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p3. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:PYSEC-2026-178` — DEFER (k9 v26). The only JWT decoded is the OIDC id_token, fetched from the trusted IdP over the auth-code back channel; no detached-JWS endpoint exists. no_attacker_controlled_input (web), not reached (worker). Not in KEV; EPSS p30. Residual Low 20/100.
- `pypi:pyjwt:GHSA-w7vc-732c-9m39` — DEFER (k9 v26). The only JWT decoded is the OIDC id_token, fetched from the trusted IdP over the auth-code back channel; no detached-JWS endpoint exists. no_attacker_controlled_input (web), not reached (worker). Not in KEV; EPSS p30. Residual Low 20/100.
- `pypi:pyjwt:PYSEC-2026-179` — DEFER (k9 v26). Needs HS* mixed with an asymmetric alg plus a raw JSON JWK as key. All 10 decode call sites use a single alg family and a prepared key. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p33. Residual Low 20/100, 60 pts retired.
- `pypi:pyjwt:GHSA-xgmm-8j9v-c9wx` — DEFER (k9 v26). Needs HS* mixed with an asymmetric alg plus a raw JSON JWK as key. All 10 decode call sites use a single alg family and a prepared key. unreachable_from_entrypoint, web and worker. Not in KEV; EPSS p33. Residual Low 20/100, 60 pts retired.

Do not dismiss `pypi:idna:PYSEC-2026-215` or `pypi:idna:GHSA-65pc-fj4g-8rjx`. Both carry a SCHEDULE verdict in the web tier; their worker-context DEFER does not clear the alert.
