Safeguard
Vulnerability Research•

Backstage's August Security Release: 43 Advisories, a 40-Day Scanner Gap, and Four Still Missing

Backstage published 43 security advisories from 28 Aug. Scanners saw most 40 days later and four still not at all. What to upgrade and how to check.

Safeguard Security Team
Security Research
12 min read

On 28 August, Backstage shipped release 1.54.6 with 37 security advisories in one day. That's more than the 31 the project had published from its first advisory in June 2021 to July 2026. Six more followed in September. GitHub's advisory database and OSV added most of them on 7 October, 40 days later, so npm audit and most scanners had nothing to report for almost six weeks. As of 17 October, four of them still aren't there at all.

If you run Backstage, the release notes are the place to start, but it's a long list. This guide is for platform teams that run a Backstage instance. It covers what the 43 advisories touch, the timeline from fix to scanner, three patterns worth understanding beyond the version bump, and a script that checks a lockfile against Backstage's own advisory feed, including the four your scanner can't see.

The short answer

Between 28 August and 16 September 2026, Backstage published 43 security advisories: 1 critical, 16 high, 23 medium and 3 low, mostly in the Scaffolder (13), TechDocs (12) and the catalog (6). GitHub's database and OSV added 38 of them on 7 October and one on 15 October. Four, including two high-severity TechDocs issues (CVE-2026-88064 and CVE-2026-106556), weren't in either database on 17 October, so npm audit doesn't report them. Upgrade to at least @backstage/backend-defaults 0.17.8, plugin-scaffolder-backend 4.1.0, plugin-techdocs-node 1.15.6 (or 1.14.8 or 2.0.1) and plugin-catalog-backend 3.9.1, and check your lockfile against the project's own advisory feed rather than the database alone.

What the 43 advisories touch

AreaAdvisoriesHighestWhat they coverFixed in (minimum)
Scaffolder13CriticalTask data and logs exposing secrets, task authorization, SCM credential fallback, input and path checks in Bitbucket, Confluence and Sentry actionsplugin-scaffolder-backend 4.1.0, plus module releases
TechDocs12Highmkdocs.yml validation (8 high), file reads through snippets and the local publisher, static content authorizationplugin-techdocs-node 1.15.6, plugin-techdocs-backend 2.2.4
Catalog6HighPlaceholder URL validation, property permissions, location types, GitLab, Bitbucket Server and cloud storage ingestionplugin-catalog-backend 3.9.1, plus module releases
Backend services3HighService credential restrictions lost through delegation, excluded actions still callable, cloud storage URL readersbackend-defaults 0.17.8
Auth3HighUnverified OIDC email used for sign-in, negative email verification ignored, Cloudflare Access audienceOIDC provider 0.4.20, plugin-auth-node 0.6.15 or 0.7.5, Cloudflare Access provider 0.5.0
Kubernetes3MediumSensitive data in resource queries, entity validation, cluster auth modesplugin-kubernetes-backend 0.21.10
Proxy2MediumInput validation, overlapping routes with different credentialsplugin-proxy-backend 0.6.18
Search1MediumPermission filtering in search enginesplugin-search-backend 2.1.6, Elasticsearch module 1.8.7

All but one require some privilege in their CVSS vectors, usually a signed-in user, and many depend on configuration, so the severity on the label is a ceiling for your instance, not a measure of it. Most advisories also list workarounds, which matter if you can't upgrade every plugin at once.

How long scanners waited

DateEvent
28 AugustBackstage 1.54.6 released; 37 repository advisories published with fixed npm versions
11 and 16 SeptemberSix more advisories (auth, proxy, TechDocs, and the Sentry and Bitbucket Server modules)
16 SeptemberFirst CVE record of the batch published (CVE-2026-88064)
6 and 7 OctoberCVE records for nearly all the rest published (CVE-2026-106495 followed on 12 October)
7 OctoberGitHub's database and OSV add 38 advisories
15 OctoberGitHub's database and OSV add CVE-2026-106495
17 OctoberFour advisories still in neither database

So most fixes were on npm from day one, and the advisories were public on GitHub, but a team relying on npm audit or a database-fed scanner heard nothing for 40 days. The four still missing are GHSA-292q-8mf5-h6g3 (CVE-2026-88064, high), GHSA-g2hj-v2j6-vfvg (CVE-2026-106556, high), GHSA-vmqh-7cj9-mvcj (CVE-2026-106507, medium) and GHSA-8429-xpcj-3cg5 (CVE-2026-106497, medium). Their CVE records exist, and OSV carries them as CVE entries with Git commit ranges. But npm scanners match by package name and version, and those entries have neither, so they don't fire.

We don't know why these four didn't come through with the rest. The practical point is the same either way: for a project that publishes advisories on GitHub, the project's own feed is ahead of the database, sometimes by weeks.

Three patterns worth knowing

TechDocs builds untrusted config on your server. Eight of the TechDocs advisories are high-severity ways around the validation of a repository's mkdocs.yml, through markdown_extensions, themes, plugins and the sanitizer itself. One (CVE-2026-106510) gives OS command execution on the build host to any user who can register a catalog entity. The fixes landed in stages, with the last on 25 September in plugin-techdocs-node 1.14.8, 1.15.6 and 2.0.1. The release notes also ask TechDocs users to move to pymdown-extensions 10.21.3 or newer, normally through mkdocs-techdocs-core 1.7.0 or newer. The structural fix is older: Backstage's documentation already recommends building docs in CI and setting techdocs.builder: 'external', so the Backstage server only serves finished HTML. Then a hostile mkdocs.yml runs in a CI job for its own repository, not on the portal.

Scaffolder tasks hold secrets, and other users could read them. The one critical advisory, CVE-2026-106501, lets a user who can read another user's Scaffolder task receive internal execution data, which in some setups includes credentials for an external service. Three more cover secrets in task logs and failure events. The workaround is worth keeping after the upgrade: give the scaffolder.task.read permission the isTaskOwner condition, so people only see their own tasks.

A filter that hides an action isn't one that blocks it. Backstage's actions registry lets plugins expose actions that other services, such as Backstage's MCP actions backend, can list and call. Operators can narrow that set with backend.actions.pluginSources and include or exclude rules in backend.actions.filter. Before backend-defaults 0.17.8, those settings were applied only when listing actions. The invoke endpoint didn't check them, so an authenticated user who knew an excluded action's ID could still call it (CVE-2026-106495). Action permissions and downstream authorization still applied, which is why it's rated medium. The fix runs the same check on both paths:

// DefaultActionsRegistryService, after the fix (commit a8fb34a)
if (!this.isActionAllowed([req.params.actionId, action])) {
  throw new NotFoundError(`Action "${req.params.actionId}" not found`);
}

The same pattern turns up wherever a tool list for an AI agent is built from configuration. Trimming the list the agent sees is useful, but the place that runs the tool has to enforce the same rule.

Check your lockfile against Backstage's own feed

This script reads package-lock.json or yarn.lock (classic or Berry), fetches the published advisories from Backstage's repository through the GitHub API, and lists every advisory that matches an installed @backstage/* version. With --osv it also asks OSV which advisories it returns for each version, and marks the ones it doesn't. It uses the Python standard library. Unauthenticated GitHub API calls are limited to 60 an hour, so set GITHUB_TOKEN in CI or save the feed and pass --advisories:

#!/usr/bin/env python3
"""Match @backstage/* packages in a lockfile against Backstage's own advisory feed.

Reads package-lock.json or yarn.lock, fetches the published repository
advisories for backstage/backstage from the GitHub API (or a saved copy),
and with --osv reports which matching advisories OSV's npm data does not
return for that package version. Standard library only.
"""
import argparse, json, os, re, sys, urllib.request

FEED = ("https://api.github.com/repos/backstage/backstage/"
        "security-advisories?state=published&per_page=100")
UA = {"User-Agent": "backstage-advisory-check", "Accept": "application/vnd.github+json"}

def get(url, data=None):
    headers = dict(UA)
    if os.environ.get("GITHUB_TOKEN") and "github.com" in url:
        headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]
    req = urllib.request.Request(url, data=data, headers=headers)
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r), r.headers.get("Link", "")

def fetch_feed():
    out, url = [], FEED
    while url:
        page, link = get(url)
        out += page
        m = re.search(r'<([^>]+)>;\s*rel="next"', link)
        url = m.group(1) if m else None
    return out

def parse_lock(path):
    found = set()
    text = open(path, encoding="utf-8").read()
    if path.endswith(".json"):
        for key, meta in json.loads(text).get("packages", {}).items():
            name = key.split("node_modules/")[-1]
            if name.startswith("@backstage/") and "version" in meta:
                found.add((name, meta["version"]))
        return found
    # yarn.lock, classic or berry: a header line, then an indented version line
    name = None
    for line in text.splitlines():
        if line and not line.startswith(" "):
            m = re.match(r'"?(@backstage/[^@"\s]+)@', line)
            name = m.group(1) if m else None
        elif name:
            m = re.match(r'\s+version:?\s+"?([^"\s]+)"?', line)
            if m:
                found.add((name, m.group(1)))
                name = None
    return found

def vkey(v):
    core, _, pre = v.partition("-")
    nums = [int(x) for x in core.split(".")[:3]]
    # a prerelease sorts before its release
    return nums + ([0, pre] if pre else [1, ""])

def satisfies(version, rng):
    ops = {"<": lambda a, b: a < b, "<=": lambda a, b: a <= b,
           ">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
           "=": lambda a, b: a == b}
    v = vkey(version)
    for alt in rng.split("||"):
        # conditions may be comma or space separated; "=<" appears in old entries
        conds = re.findall(r"(<=|>=|=<|=>|<|>|=)?\s*(\d[\w.-]*)", alt)
        if conds and all(ops[{"=<": "<=", "=>": ">="}.get(op, op or "=")](v, vkey(ver))
                         for op, ver in conds):
            return True
    return False

def osv_ids(pkgs):
    body = {"queries": [{"package": {"ecosystem": "npm", "name": n}, "version": v}
                        for n, v in pkgs]}
    res, _ = get("https://api.osv.dev/v1/querybatch", json.dumps(body).encode())
    return {p: {x["id"] for x in r.get("vulns", [])} for p, r in zip(pkgs, res["results"])}

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("lockfile")
    ap.add_argument("--advisories", help="saved feed JSON instead of the live API")
    ap.add_argument("--osv", action="store_true", help="flag advisories OSV does not return")
    a = ap.parse_args()
    feed = json.load(open(a.advisories)) if a.advisories else fetch_feed()
    pkgs = sorted(parse_lock(a.lockfile))
    seen = osv_ids(pkgs) if a.osv and pkgs else {}
    hits = []
    for adv in feed:
        ids = {adv["ghsa_id"], adv.get("cve_id")}
        for vuln in adv.get("vulnerabilities") or []:
            for name, ver in pkgs:
                fixed = vuln.get("patched_versions") or ""
                if name != vuln["package"]["name"] or not satisfies(ver, vuln["vulnerable_version_range"]):
                    continue
                # a few repo advisories have open-ended ranges; trust an explicit fixed range
                if re.search(r"[<>=]", fixed) and satisfies(ver, fixed):
                    continue
                gap = a.osv and not (ids & seen.get((name, ver), set()))
                hits.append((adv["severity"], adv["ghsa_id"], adv.get("cve_id") or "-",
                             f"{name}@{ver}", fixed or "none",
                             " NOT IN OSV" if gap else "", adv["summary"]))
    order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    print(f"{len(pkgs)} @backstage packages, {len(feed)} advisories in feed")
    for h in sorted(set(hits), key=lambda h: (order.get(h[0], 9), h[3], h[1])):
        print(f"{h[0].upper():8} {h[1]} {h[2]:16} {h[3]} fix {h[4]}{h[5]}\n         {h[6]}")
    gaps = sum(1 for h in set(hits) if h[5])
    print(f"{len(set(hits))} matches" + (f", {gaps} not returned by OSV for that version" if a.osv else ""))
    return 1 if hits else 0

if __name__ == "__main__":
    sys.exit(main())

We tested it on two lockfiles generated with npm install --package-lock-only --ignore-scripts. The first pins the versions a portal might have run in August: backend-defaults 0.17.7, plugin-techdocs-node 1.15.3, plugin-scaffolder-backend 4.0.4, plugin-catalog-backend 3.9.0 and the OIDC provider 0.4.19. The second uses current versions. We saved the feed on 17 October and filtered the first run to the critical finding and the database gaps:

$ python3 backstage_advisory_check.py aug-portal/package-lock.json \
    --advisories feed-2026-10-17.json --osv | grep -E -A1 "^CRITICAL|NOT IN OSV|matches"
CRITICAL GHSA-g2v8-7jhw-pp8p CVE-2026-106501  @backstage/plugin-scaffolder-backend@4.0.4 fix 4.1.0
         Sensitive information exposure in Scaffolder
--
HIGH     GHSA-292q-8mf5-h6g3 CVE-2026-88064   @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
         Improper input validation in TechDocs MkDocs configuration
--
HIGH     GHSA-g2hj-v2j6-vfvg CVE-2026-106556  @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
         Configuration bypass in TechDocs mkdocs.yml sanitization
--
MEDIUM   GHSA-8429-xpcj-3cg5 CVE-2026-106497  @backstage/plugin-catalog-backend@3.9.0 fix 3.9.1 NOT IN OSV
         Inconsistent catalog property permission evaluation
--
MEDIUM   GHSA-vmqh-7cj9-mvcj CVE-2026-106507  @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
         TechDocs arbitrary file read via mkdocs snippets
--
27 matches, 4 not returned by OSV for that version

$ python3 backstage_advisory_check.py oct-portal/package-lock.json \
    --advisories feed-2026-10-17.json --osv
32 @backstage packages, 74 advisories in feed
0 matches, 0 not returned by OSV for that version

On the same August lockfile, npm audit reported 23 of the 27 Backstage advisories, missing the same four. The script exits with status 1 when anything matches, so it can gate a CI job. One caution: a few older repository advisories have loose version ranges, so the script trusts an explicit fixed range where the advisory gives one.

What to do

  1. Upgrade the backend packages to at least the versions in the short answer, plus any Scaffolder, catalog or auth modules you use. The table above lists the minimums, and the script will flag the modules.
  2. Move TechDocs builds to CI with techdocs.builder: 'external' if you haven't, and update the build image to mkdocs-techdocs-core 1.7.0 or newer.
  3. Limit who can register catalog entities, since several advisories start there.
  4. Add isTaskOwner to scaffolder.task.read unless people genuinely need to see each other's tasks.
  5. Review action filters and pluginSources if you expose actions to MCP clients, and upgrade backend-defaults so the filters actually block.
  6. Watch the project's advisory feed, not only npm audit. Our post on what npm audit misses covers the wider gap, and how GitHub's advisory database is curated explains where the delay comes from.

The fair view

A release like this is what good security work looks like from the outside. Backstage fixed 37 issues at once, with backports to older plugin release lines, wrote an advisory with workarounds for nearly every one, and kept fixing as follow-up problems turned up in September. Releasing everything together also gave operators one upgrade instead of a drip of 37. None of the advisories credit an outside reporter, so this reads as a deliberate review of the codebase, though the advisories don't say how the issues were found.

The gap is downstream. A platform that holds SCM tokens, cloud credentials and deployment permissions for a whole engineering organization waited 40 days for its scanners to notice, and four advisories still aren't visible to them. That's not a reason to distrust the database, which got 39 of 43 right. It's a reason to read the source when the software matters: one API call to Backstage's advisory feed would have told you on 28 August.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.