Reachable Risk sample reports

NetBox v4.6.5

11 open dependency alerts on NetBox v4.6.5, scored in both places its code runs. Two need scheduling.

Run date
2026-08-16
Scale
11 alerts, 22 context-specific verdicts, 2 execution contexts (web, worker)
Rubric
2026.08.13-v26
Workflow
2026.08.12-v33
Scored by
claude-opus-5
Risk context
2026-07-30+ebee3578
SLA policy
SafeCo Vulnerability Management Policy (ISP-07) v1.0 (fictional stand-in)

Report (Markdown)risk-context.yaml (the context we gather together)

The SLA policy is a fictional stand-in so the samples stay comparable; deadlines in a real run come from your own vulnerability management policy. The published risk-context.yaml is a copy of the run file with prose comments edited for publication; every declared context, path, asset context, actor and capability is identical.

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.