Skip to the content.

roflcoopter/viseron — security scan

Repository: roflcoopter/viseron Commit scanned: 59b25659 (master at scan time; latest release v3.6.0) Scan date: 2026-08-18 Disclosure status: withheld — one real finding, reported privately through GitHub private vulnerability reporting as GHSA-5r5m-c4m6-jfmf. Accepted by the maintainers on 2026-08-21: the submitted report was converted into a draft advisory (submission.accepted: true, state triagedraft), the medium severity was kept exactly as filed, CWE-863 (incorrect authorization) was assigned, the affected range was recorded as <= 3.6.0, and the reporter was credited. No patched version is published yet, so the finding stays withheld — described here at class level only.

Summary

Severity Count
Critical 0
High 72
Medium 327
Low
Info

Total findings: 399 above the medium floor (1 real after curation — withheld; zero of the 399 scanner findings represented it).

Scanned with --min-severity medium, so low and info rows are filtered rather than empty.

The project

Viseron (3.4k★, MIT) is a self-hosted, local-only NVR with computer vision. It ingests RTSP camera streams, runs object detection, motion detection, face recognition and licence plate recognition against them, records to a tiered storage hierarchy, and serves a React dashboard over a Tornado web server. It has been going since 2020, which for this series is unusually old — most targets are eighteen months old at most.

It is also, structurally, a project that handles unusually sensitive data. An NVR knows who walked past which door and when. Viseron takes that seriously enough to ship a per-user access model: an administrator can give an account a role (admin / write / read) and a list of assigned cameras. The admin UI labels that field, in the product’s own words, "Cameras - Empty gives access to all cameras".

The maintainer is active and the project is not a solo ghost: 17 merges from the maintainer plus four distinct human contributors in the last 60 days, 15 issues closed in the same window. That cleared the responsiveness pre-check comfortably.

There is no SECURITY.md — not at the root, not in .github/, not in docs/, and not on the published documentation site. But private vulnerability reporting is enabled on the repository. Per the rule this series has settled on, a deliberate opt-in outranks a missing policy file: enabling that toggle is the maintainer saying send it here. So the real finding went there and not into a public issue.

What this write-up does not contain

The one real finding is under private report and is not described below. No component, no mechanism, no file path, no reproduction. What can be said is the class:

An authorization gap. A per-user access boundary that the project defines, documents in its own admin UI, and enforces consistently across most of its surface is not applied on one code path. An authenticated account holding the lowest available role, and explicitly granted access to only a subset of resources, can observe data belonging to the resources it was not granted. Confidentiality only — no write or privilege-escalation path. Not reachable by an unauthenticated visitor.

If it is fixed, or if the maintainer would rather it were published, this page gets the detail and a dated timeline entry. If the report goes stale, the same 90-day convention this series has used throughout applies.

The channel, and a correction worth publishing

This scan produced a methodology finding about GitHub’s own API that invalidates a belief two earlier entries in this series recorded, so it goes near the top rather than buried in the tooling notes.

Viseron reports private-vulnerability-reporting: {"enabled": true}. The first submission to POST /repos/{owner}/{repo}/security-advisories/reports returned HTTP 500. So did the second and third. Two previous scans in this series (repowise, Vexa) hit exactly that wall and were filed under a category I had been calling “PVR enabled but the API is broken” — a dead channel with a live toggle.

That category appears to have been wrong, and here is the control that shows it. An empty payload returns a clean, correct 422:

Invalid request.
Invalid input: object is missing required keys: summary, description.

So the endpoint is reachable, the token scopes are right, and the request is being parsed. The 422 names exactly two required keys: summary and description. A payload with both — and nothing else — returns 500. Adding the vulnerabilities array, which the API documents as optional and which the 422 does not mention, returns 201 Created.

summary + description                      -> 500
summary + description + cwe_ids            -> 500
summary + description + vulnerabilities    -> 201

The channel was never broken. The payload was incomplete in a way the API’s own validation error actively misdirects you about: it tells you which fields are missing, that list is wrong, and the failure mode for the field it omits is a server error rather than a validation message. Anyone reporting a vulnerability through this API for the first time will read the 500 as “they have disabled it” and walk away — which is what I did, twice, and it may have cost two maintainers a report they had explicitly opted in to receive.

The rule that replaces the old one: a 500 from a private-reporting endpoint is a claim about your payload until a 422 control proves otherwise. Send the empty payload first. It costs nothing, it files nothing, and it tells you whether the door is locked or you are holding the key wrong.

(A second, smaller lesson from the same minute: the confirming request I ran to check what had been created re-POSTed the payload instead of only listing it, which filed the report twice. The duplicate, GHSA-3ffc-5g6c-9g3j, could not be withdrawn — the API only lets the repository owner change a report’s state — so it was retitled in place to point at the real one. A verification step that mutates is not a verification step.)

Patterns observed

This is the best-defended authentication layer this series has scanned. That is worth saying plainly, because the finding count above says the opposite and the finding count is noise.

Two of the scariest-looking results are both defences that a scanner cannot recognise as defences:

auth.py:752 trips unverified-jwt-decode, a rule that is right often enough to be worth having. Here it is the standard two-pass pattern — decode without verification only to read the iss claim, so you know which key to fetch, then always run a real verified jwt.decode with that key, the issuer and a leeway. The code then goes a step further than the pattern requires: when the issuer is not found it verifies against a decoy key generated at startup, purely so a bad issuer and a bad signature take the same amount of time. A rule that fires on verify_signature: False cannot see the second decode; it certainly cannot see the decoy.

auth.py:362 trips a hardcoded-bcrypt-hash secret rule. It is a dummy hash used to make failed logins cost the same as successful ones. This series has learned to check whether a declared timing defence is actually invoked — the “inert security flag” pattern, where the guard exists and nothing calls it. It is invoked, at line 376, on the exact path where no user matched. The defence is real, and the scanner reported it as a leaked credential.

The same care shows up structurally. The REST dispatcher defaults requires_auth to True and makes routes opt out, which is the correct direction for that default. Role checks fall back to a per-method table when a route does not declare one. XSRF is enforced precisely when the credential is a cookie and the method is state-changing, and skipped for token clients where CSRF is not a threat — a conditional that is easy to get backwards and is not backwards here. Personal access tokens are explicitly refused the browser cookie-binding path.

Which is exactly why the one real finding is interesting, and why no rule found it. It is not a missing check in code that has no checks. It is a boundary enforced almost everywhere and absent from one path. The signal that it is a defect rather than a decision is entirely intra-repo: the same data, reached a second way, is guarded. You cannot get there from a pattern database. You get there by listing the ways in and comparing them to each other.

The 399 findings, honestly accounted for. 238 of them are RUN cd ... in a Dockerfile. 34 are “image user should not be root” — on an NVR container that needs device access to talk to GPUs, VAAPI and Coral TPUs, repeated once per build variant. 43 are mutable GitHub Actions tags. That is 315 of 399, or 79%, from three hygiene rules firing repeatedly across a build matrix. Finding count scales with how many build targets you support, not with risk.

Of the 56 dependency CVEs, 31 are in docs/package-lock.json — the Docusaurus toolchain that builds the documentation website. They ship to nobody. Another 24 are in the frontend lockfile, and 20 of those are one package, dompurify, which nearly became a wrong finding: package.json declares ^3.2.7 and Trivy reported 3.1.7, which reads like a stale lockfile that npm ci would install below its own declared floor. Reading the lockfile directly kills it — node_modules/dompurify is 3.2.7, and the 3.1.7 is a second, nested copy vendored inside monaco-editor. The app’s own sanitiser, the one actually imported by HoverLine.tsx and ProgressLine.tsx, is current. That left exactly one dependency CVE in the Python runtime: scikit-learn 1.2.2, CVE-2024-5206.

The remaining Python hits are the familiar roster. subprocess-shell-true in data_stream is ["ulimit", "-u"] — a constant list, no user input anywhere near it, and ulimit is a shell builtin so the shell is the point. The SQLAlchemy text() in storage/models.py interpolates an offset string built from int() arithmetic and f"{hours:02d}:{minutes:02d}"; only digits and a sign can reach it, making it the tenth appearance of the parameterised-SQL identifier family this series has now catalogued. The eight non-literal-import hits are Viseron’s component and domain loader, which is the entire plugin architecture.

The one that took real work to dismiss is the GitHub Actions template injection: .github/templates/run_in_venv/action.yaml interpolates $ straight into a run: block. In a composite action that is a genuine hazard, and the rule is right to flag it. But a composite action cannot be judged from its own file — the question is what callers pass. All seven call sites in ci.yaml pass hardcoded literals, and the workflow triggers on pull_request, not pull_request_target, so a fork PR runs on the fork’s ref without secrets. Latent, not reachable. Trigger context decides, and it decided in the maintainer’s favour.

Notes on the tool

Disclosure timeline

Reproduce

git clone https://github.com/roflcoopter/viseron /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target \
  --reports-dir ./reports/roflcoopter-viseron --min-severity medium