From b428088ec7f9977fca1d7e68830fa21982f0e250 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 07:28:31 +0200 Subject: [PATCH 1/6] feat(advisories): branch-aware version evaluation, validated on the real corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nextcloud advisories describe several parallel maintenance branches in one record, and the version-range field cannot express that. Measured over the 161 vulnerability entries in the live nextcloud/security-advisories feed (captured as tests/fixtures/nextcloud-advisories.json): 66.5% several lower bounds, no upper bound 23.6% a single lower bound, no upper bound 5.0% a single upper bound 1.2% several upper bounds Neither boolean reading of that comma is correct: AND — the CURRENT behaviour of isAffected() — collapses Mail's '>= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0' to '>= 4.3.0', so an instance on 3.6.0 is told it is SAFE. A false negative, the worst direction for a security check, and it applies to two thirds of real advisories. OR turns Talk's '< 21.1.10, < 22.0.11, < 23.0.3' into '< 23.0.3', so a correctly-patched 22.0.11 is reported VULNERABLE. The structure the data actually has is one patch per release branch, so the branch decides: branch is major.minor; a branch with a listed patch is judged only against that patch; a branch with none falls through to the nearest higher patch on the same major; and a version below EVERY published patch is affected even when the only exit is a major upgrade. That last rule is a correction the corpus forced. The first draft refused to cross a major on the grounds that it recommends a migration — which reports User OIDC 2.0.0 (patches 3.0.0/4.0.0/5.0.0, no 2.x fix) as safe. The test that asserted the old rule now asserts the opposite and says why. The two properties are swept over the entire corpus rather than spot-checked: - no instance sitting ON a published patch is ever reported affected (458 probes) - every instance one patch level below a patch is reported affected (412 probes, skipping constructed versions that are themselves patches) Controls: a naive "nearest greater patch, ignore branches" implementation fails the first sweep; the sweeps assert a non-trivial probe count so an emptied fixture cannot pass by checking nothing. No behaviour change yet — nothing constructs BranchAwareRange. Wiring it into AdvisoryService lands with the source that actually supplies patched-version lists, so the semantics change and the data arrive in one reviewable step. --- lib/Service/Advisory/BranchAwareRange.php | 173 ++ tests/fixtures/nextcloud-advisories.json | 1748 +++++++++++++++++ .../Service/Advisory/BranchAwareRangeTest.php | 222 +++ 3 files changed, 2143 insertions(+) create mode 100644 lib/Service/Advisory/BranchAwareRange.php create mode 100644 tests/fixtures/nextcloud-advisories.json create mode 100644 tests/unit/Service/Advisory/BranchAwareRangeTest.php diff --git a/lib/Service/Advisory/BranchAwareRange.php b/lib/Service/Advisory/BranchAwareRange.php new file mode 100644 index 00000000..59321277 --- /dev/null +++ b/lib/Service/Advisory/BranchAwareRange.php @@ -0,0 +1,173 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +/** + * Decides whether an installed version is affected by an advisory, using the + * advisory's list of PATCHED versions rather than its prose version range. + * + * WHY NOT JUST EVALUATE THE RANGE. Nextcloud's published advisories describe + * several parallel maintenance branches in a single record, and the range + * field cannot express that. Measured over the 161 vulnerability entries in + * the live `nextcloud/security-advisories` feed (2026-08-21): + * + * 66.5% multiple lower bounds, no upper bound + * e.g. Mail '>= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0' + * patched '3.7.25, 5.5.16, 5.6.20, 5.7.13' + * 23.6% a single lower bound, no upper bound + * 5.0% a single upper bound + * 1.2% several upper bounds + * e.g. Talk '< 21.1.10, < 22.0.11, < 23.0.3' + * patched '21.1.10, 22.0.11, 23.0.3' + * + * Neither boolean reading of that comma is right: + * + * AND (the previous behaviour) collapses Mail's four clauses to '>= 4.3.0', + * so an instance on 3.6.0 is told it is SAFE. A false NEGATIVE, which is the + * worst direction a security check can fail in. + * + * OR turns Talk's clauses into '< 23.0.3', so a correctly-patched 22.0.11 is + * reported VULNERABLE. A false positive, which trains admins to ignore the + * badge. + * + * The structure the data actually has is one patch per release branch. So the + * branch decides: + * + * - branch is `major.minor`; + * - if the installed version's OWN branch has a patch listed, that patch + * alone decides — at or above it means fixed, below it means affected; + * - only a branch with NO patch listed falls through to the nearest higher + * patch within the same major; + * - a version newer than every patch for its major is not affected. + * + * Validated against the whole corpus: 0 false positives in 458 probes (an + * instance sitting exactly on a patched version), and 0 misses in 412 probes + * (an instance one patch level below a patch). See BranchAwareRangeTest, which + * re-runs both sweeps over the committed fixture. + * + * @psalm-api + */ +class BranchAwareRange { + /** + * The patch that applies to $installedVersion, or null when the installed + * version is not affected. + * + * Returning the patch rather than a bool is deliberate: the caller needs + * the recommended safe version anyway, and deriving it separately is how + * the two answers drift apart. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + * @param list $patchedVersions + */ + public function resolvePatch(string $installedVersion, array $patchedVersions): ?string { + $patched = array_values(array_filter($patchedVersions, static fn (string $v): bool => trim($v) !== '')); + if ($patched === []) { + return null; + } + + $installed = $this->segments($installedVersion); + $major = $installed[0]; + $minor = $installed[1]; + + // 1. The installed version's own branch, if that branch was patched. + $ownBranch = array_values(array_filter( + $patched, + function (string $candidate) use ($major, $minor): bool { + $parts = $this->segments($candidate); + + return $parts[0] === $major && $parts[1] === $minor; + }, + )); + if ($ownBranch !== []) { + $earliest = $this->lowest($ownBranch); + + // At or above the patch for this branch means fixed. This is the + // branch of the logic that stops a patched 22.0.11 from being + // reported vulnerable because branch 23 has a later patch. + return $this->compare($earliest, $installedVersion) > 0 ? $earliest : null; + } + + // 2. This branch was never patched. The nearest higher patch on the + // same major is the upgrade target — this is what covers Mail 3.6.0, + // whose branch is absent but whose major has 3.7.25. + $sameMajor = array_values(array_filter( + $patched, + function (string $candidate) use ($major, $installedVersion): bool { + return $this->segments($candidate)[0] === $major + && $this->compare($candidate, $installedVersion) > 0; + }, + )); + if ($sameMajor !== []) { + return $this->lowest($sameMajor); + } + + // 3. The installed major has no fix at all. If the version predates + // EVERY published patch, the branch it sits on was abandoned + // without one and the only way out is forward — so it is affected, + // and the lowest patch is the nearest exit. + // + // This case is why the rule is not "never cross a major". Measured + // on the corpus: User OIDC 2.0.0 against patches 3.0.0/4.0.0/5.0.0 + // has no 2.x fix, and refusing to cross would report it SAFE. A + // security check must not fail in that direction, even though the + // recommendation it produces is a major upgrade. + $lowest = $this->lowest($patched); + if ($this->compare($installedVersion, $lowest) < 0) { + return $lowest; + } + + // 4. Newer than every patch for its major, and not below the earliest + // patch overall: the fix is already in. + return null; + } + + /** + * Numeric version segments, padded to four, so `3.7` and `3.7.0.0` + * compare equal. Non-numeric suffixes (`-beta1`) are ignored rather than + * ordered: an advisory that distinguishes prereleases is not something + * this data expresses. + * + * @return array{0: int, 1: int, 2: int, 3: int} + */ + private function segments(string $version): array { + preg_match_all('/\d+/', $version, $matches); + $parts = array_map('intval', array_slice($matches[0], 0, 4)); + $parts = array_pad($parts, 4, 0); + + /** @var array{0: int, 1: int, 2: int, 3: int} $parts */ + return $parts; + } + + /** + * -1, 0 or 1, comparing numerically segment by segment. version_compare is + * deliberately not used: it treats `31.0.12` and `31.0.12.0` as different + * and orders unknown suffixes in ways this corpus does not mean. + */ + private function compare(string $a, string $b): int { + return $this->segments($a) <=> $this->segments($b); + } + + /** + * @param non-empty-list $versions + */ + private function lowest(array $versions): string { + $lowest = $versions[0]; + foreach ($versions as $candidate) { + if ($this->compare($candidate, $lowest) < 0) { + $lowest = $candidate; + } + } + + return $lowest; + } +} diff --git a/tests/fixtures/nextcloud-advisories.json b/tests/fixtures/nextcloud-advisories.json new file mode 100644 index 00000000..9a85b431 --- /dev/null +++ b/tests/fixtures/nextcloud-advisories.json @@ -0,0 +1,1748 @@ +[ + { + "ghsa": "GHSA-2w7v-5299-3hw5", + "package": "Android Files", + "severity": "medium", + "range": ">= 33.0.0", + "patched": [ + "33.1.0" + ] + }, + { + "ghsa": "GHSA-h7gm-vgxr-9hcw", + "package": "Approval", + "severity": "low", + "range": "< 2.7.2", + "patched": [ + "2.7.2" + ] + }, + { + "ghsa": "GHSA-q26g-fmjq-x5g5", + "package": "Approval", + "severity": "low", + "range": ">= 1.0.0, >= 2.0.0", + "patched": [ + "1.3.1", + "2.5.0" + ] + }, + { + "ghsa": "GHSA-v8q8-w6c3-3gv9", + "package": "Approval", + "severity": "medium", + "range": "< 2.7.2", + "patched": [ + "2.7.2" + ] + }, + { + "ghsa": "GHSA-2r7q-vfmv-79qf", + "package": "Calendar", + "severity": "medium", + "range": ">= 4.3.0", + "patched": [ + "4.6.8", + "4.7.2" + ] + }, + { + "ghsa": "GHSA-7x2j-2674-fj95", + "package": "Calendar", + "severity": "low", + "range": ">= 4.0.0, >= 5.0.0, >= 6.0.0", + "patched": [ + "4.7.19", + "5.5.6", + "6.0.1" + ] + }, + { + "ghsa": "GHSA-f29c-ppmv-8mcv", + "package": "Calendar", + "severity": "medium", + "range": ">= 4.0.0, >= 5.0.0", + "patched": [ + "4.7.17", + "5.2.4" + ] + }, + { + "ghsa": "GHSA-fv3c-qvjr-5rv8", + "package": "Calendar", + "severity": "low", + "range": ">= 3.0.0", + "patched": [ + "4.5.3" + ] + }, + { + "ghsa": "GHSA-r697-74m9-gvf2", + "package": "Calendar", + "severity": "medium", + "range": ">= 6.2.0, >= 5.5.13", + "patched": [ + "6.2.3", + "5.5.17" + ] + }, + { + "ghsa": "GHSA-whm3-vv55-gf27", + "package": "Calendar", + "severity": "medium", + "range": ">= 6.0.0", + "patched": [ + "6.0.3" + ] + }, + { + "ghsa": "GHSA-8mpv-ggq8-hf3w", + "package": "Collectives", + "severity": "low", + "range": ">= 2.6.0, >= 3.5.0", + "patched": [ + "4.3.0" + ] + }, + { + "ghsa": "GHSA-9v78-cpfc-v6h2", + "package": "Contacts", + "severity": "low", + "range": ">= 5.0.0, >= 6.0.0, >= 7.0.0", + "patched": [ + "5.5.4", + "6.0.6", + "7.2.5" + ] + }, + { + "ghsa": "GHSA-mg7w-x9fm-9wwc", + "package": "Deck", + "severity": "low", + "range": ">= 1.9.0, >= 1.10.0", + "patched": [ + "1.9.5", + "1.11.2" + ] + }, + { + "ghsa": "GHSA-wwr8-hx9g-rjvv", + "package": "Deck", + "severity": "medium", + "range": ">= 1.14.0, >= 1.15.0", + "patched": [ + "1.14.6", + "1.15.2" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.6.0", + "patched": [ + "1.6.6" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.7.0", + "patched": [ + "1.7.5" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.8.0", + "patched": [ + "1.8.7" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.9.0", + "patched": [ + "1.9.6" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.11.0", + "patched": [ + "1.11.3" + ] + }, + { + "ghsa": "GHSA-x45g-vx69-r9m8", + "package": "Deck", + "severity": "medium", + "range": ">= 1.12.0", + "patched": [ + "1.12.1" + ] + }, + { + "ghsa": "GHSA-xjvq-xvr7-xpg6", + "package": "Deck", + "severity": "low", + "range": "< 1.12.7, < 1.14.4, < 1.15.1", + "patched": [ + "1.12.7", + "1.14.4", + "1.15.1" + ] + }, + { + "ghsa": "GHSA-h9xj-qh76-q3hw", + "package": "Desktop", + "severity": "low", + "range": ">= 3.0.0", + "patched": [ + "3.16.5" + ] + }, + { + "ghsa": "GHSA-hw3v-8vvq-5645", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.13.1", + "patched": [ + "3.13.4" + ] + }, + { + "ghsa": "GHSA-qm2f-959g-7p65", + "package": "Desktop", + "severity": "medium", + "range": "<3.15", + "patched": [ + "3.15" + ] + }, + { + "ghsa": "GHSA-r4qc-m9mj-452v", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.0.0", + "patched": [ + "3.14.2" + ] + }, + { + "ghsa": "GHSA-4mf7-v63m-99p7", + "package": "Desktop client", + "severity": "low", + "range": "<= 3.12.0", + "patched": [ + "3.12.0" + ] + }, + { + "ghsa": "GHSA-p3qw-7gwx-wg24", + "package": "End-to-End Encryption", + "severity": "low", + "range": ">= 1.15.0, >= 1.16.0, >= 1.17.0, >= 1.18.0", + "patched": [ + "1.15.4", + "1.16.3", + "1.17.1", + "1.18.1", + "2.0.0-rc.7" + ] + }, + { + "ghsa": "GHSA-q568-2933-gcjq", + "package": "Enterprise Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "26.0.13.13", + "27.1.11.13", + "28.0.14.4", + "29.0.13", + "30.0.7", + "31.0.1" + ] + }, + { + "ghsa": "GHSA-qqgg-hhfq-vhww", + "package": "Enterprise Server", + "severity": "medium", + "range": ">= 30.0.0, >= 29.0.0, >= 28.0.0", + "patched": [ + "30.0.2", + "29.0.9", + "28.0.12" + ] + }, + { + "ghsa": "GHSA-vhj3-mch4-67fq", + "package": "Files ZIP", + "severity": "medium", + "range": ">= 1.2.0", + "patched": [ + "1.2.1", + "1.4.1", + "1.5.0" + ] + }, + { + "ghsa": "GHSA-j8g7-88vv-rggv", + "package": "Files iOS", + "severity": "medium", + "range": "<= 4.9.1", + "patched": [ + "4.9.2" + ] + }, + { + "ghsa": "GHSA-g7vj-98x3-qvjf", + "package": "Flow", + "severity": "high", + "range": ">= 1.0.0, >= 1.1.0, >= 1.2.0", + "patched": [ + "1.3.0" + ] + }, + { + "ghsa": "GHSA-q4fw-6jf8-5vhh", + "package": "Forms", + "severity": "medium", + "range": ">=4.3.0", + "patched": [ + "5.2.7" + ] + }, + { + "ghsa": "GHSA-r4gh-f8x6-m55f", + "package": "Forms", + "severity": "medium", + "range": "< 5.2.6", + "patched": [ + "5.2.6" + ] + }, + { + "ghsa": "GHSA-2vrq-fhmf-c49m", + "package": "Groupfolders", + "severity": "low", + "range": "< 14.0.11,< 15.3.12, < 16.0.15,< 17.0.14, < 18.1.8, < 19.1.8, < 20.1.2", + "patched": [ + "14.0.11", + "15.3.12", + "16.0.15", + "17.0.14", + "18.1.8", + "19.1.8", + "20.1.2" + ] + }, + { + "ghsa": "GHSA-qqgg-hhfq-vhww", + "package": "Groupfolders", + "severity": "medium", + "range": ">= 18.0.0, >= 17.0.0, >= 16.0.0", + "patched": [ + "18.0.3", + "17.0.5", + "16.0.11" + ] + }, + { + "ghsa": "GHSA-v3qw-7vgv-2fxj", + "package": "Guests", + "severity": "medium", + "range": ">= 2.4.0, >= 2.5.0, >= 3.0.0", + "patched": [ + "2.4.1", + "2.5.1", + "3.0.1" + ] + }, + { + "ghsa": "GHSA-wr87-hx3w-29hh", + "package": "Guests", + "severity": "medium", + "range": ">= 2.4.0, >= 2.5.0, >= 3.0.0", + "patched": [ + "2.4.1", + "2.5.1", + "3.0.1" + ] + }, + { + "ghsa": "GHSA-4pp4-m8ph-2999", + "package": "Mail", + "severity": "low", + "range": ">= 1.13.0, >= 2.1.0, >= 3.1.0", + "patched": [ + "2.2.8", + "3.3.0" + ] + }, + { + "ghsa": "GHSA-pwpp-fvcr-w862", + "package": "Mail", + "severity": "low", + "range": ">=2.2.0, >= 3.6.0, >= 3.7.0", + "patched": [ + "2.2.10", + "3.6.2", + "3.7.2" + ] + }, + { + "ghsa": "GHSA-v394-8gpc-6fv5", + "package": "Mail", + "severity": "low", + "range": ">= 5.2.0", + "patched": [ + "5.5.3" + ] + }, + { + "ghsa": "GHSA-vmhx-hwph-q6mc", + "package": "Mail", + "severity": "high", + "range": ">= 1.9.0, >= 2.1.0, >= 3.1.0", + "patched": [ + "1.14.6", + "1.15.4", + "2.2.11", + "3.6.3", + "3.7.7", + "4.0.0" + ] + }, + { + "ghsa": "GHSA-vq3v-jv6f-6xp2", + "package": "Mail", + "severity": "medium", + "range": ">= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0", + "patched": [ + "3.7.25", + "5.5.16", + "5.6.20", + "5.7.13" + ] + }, + { + "ghsa": "GHSA-wfqv-cx85-7rjx", + "package": "Notes", + "severity": "medium", + "range": ">= 4.6.0", + "patched": [ + "4.9.3" + ] + }, + { + "ghsa": "GHSA-9chh-5prm-wp43", + "package": "Photos", + "severity": "low", + "range": ">= 25.0.1", + "patched": [ + "25.0.7" + ] + }, + { + "ghsa": "GHSA-9chh-5prm-wp43", + "package": "Photos", + "severity": "low", + "range": ">= 26.0.0", + "patched": [ + "26.0.2" + ] + }, + { + "ghsa": "GHSA-2448-44rp-c7hh", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-2448-44rp-c7hh", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-24wp-p865-7j4r", + "package": "Server", + "severity": "medium", + "range": ">=30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "30.0.17", + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-24wp-p865-7j4r", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "22.2.10.33", + "23.0.12.29", + "24.0.12.28", + "25.0.13.23", + "26.0.13.20", + "27.1.11.20", + "28.0.14.11", + "29.0.16.8", + "30.0.17", + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-285v-p9x9-cjhj", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-285v-p9x9-cjhj", + "package": "Server", + "severity": "medium", + "range": "< 21.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "21.0.9.20", + "22.2.10.35", + "23.0.12.31", + "24.0.12.30", + "25.0.13.25", + "26.0.13.22", + "27.1.11.22", + "28.0.14.13", + "29.0.16.10", + "30.0.17.5", + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-2q6f-gjgj-7hp4", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.10", + "29.0.7", + "30.0.0" + ] + }, + { + "ghsa": "GHSA-2q6f-gjgj-7hp4", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.10", + "29.0.7", + "30.0.0" + ] + }, + { + "ghsa": "GHSA-35fx-69q6-xpjr", + "package": "Server", + "severity": "medium", + "range": ">=32.0.0, >=33.0.0", + "patched": [ + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-35fx-69q6-xpjr", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >=28.0.0, >=29.0.0, >=30.0.0, >=31.0.0, >=32.0.0, >=33.0.0", + "patched": [ + "27.1.11.5", + "28.0.14.17", + "29.0.16.16", + "30.0.17.9", + "31.0.14.5", + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-35gc-jc6x-29cm", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0", + "patched": [ + "28.0.9", + "29.0.5" + ] + }, + { + "ghsa": "GHSA-35gc-jc6x-29cm", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "26.0.13.9", + "27.1.11.9", + "28.0.9", + "29.0.5" + ] + }, + { + "ghsa": "GHSA-35p6-4992-w5fr", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-35p6-4992-w5fr", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-3f8p-6qww-2prr", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-3f8p-6qww-2prr", + "package": "Server", + "severity": "medium", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "23.0.12.13", + "24.0.12.9", + "25.0.13.4", + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-42w6-r45m-9w9j", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-42w6-r45m-9w9j", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "25.0.13.14", + "26.0.13.10", + "27.1.11.10", + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-45pj-p7x7-4mhc", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-45pj-p7x7-4mhc", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0, >= 33.0.0", + "patched": [ + "26.0.13.26", + "27.1.11.25", + "28.0.14.17", + "29.0.16.16", + "30.0.17.9", + "31.0.14.5", + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-495w-cqv6-wr59", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-495w-cqv6-wr59", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "28.0.14.11", + "29.0.16.8", + "30.0.17.3", + "31.0.10" + ] + }, + { + "ghsa": "GHSA-4chh-6mhf-p4jj", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.2", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-4chh-6mhf-p4jj", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0, >= 33.0.0", + "patched": [ + "31.0.14.4", + "32.0.2", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-5j2p-q736-hw98", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-5j2p-q736-hw98", + "package": "Server", + "severity": "medium", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "23.0.12.13", + "24.0.12.9", + "25.0.13.4", + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-5m5g-hw8c-2236", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-5m5g-hw8c-2236", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "24.0.12.15", + "25.0.13.10", + "26.0.13.4", + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-5mq8-738w-5942", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "26.0.12", + "27.1.7", + "28.0.3" + ] + }, + { + "ghsa": "GHSA-5mq8-738w-5942", + "package": "Server", + "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "23.0.12.16", + "24.0.12.12", + "25.0.13.6", + "26.0.12", + "27.1.7", + "28.0.3" + ] + }, + { + "ghsa": "GHSA-8f69-f9jg-4x3v", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8f69-f9jg-4x3v", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.16", + "23.0.12.11", + "24.0.12.7", + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8jwv-c8c8-9fr3", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8jwv-c8c8-9fr3", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.16", + "23.0.12.11", + "24.0.12.7", + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-99gw-ww6p-f2rr", + "package": "Server", + "severity": "high", + "range": ">= 32.0.10, >= 33.0.4, >= 34.0.0", + "patched": [ + "32.0.12", + "33.0.6", + "34.0.1" + ] + }, + { + "ghsa": "GHSA-99gw-ww6p-f2rr", + "package": "Server", + "severity": "high", + "range": ">= 32.0.10, >= 33.0.4", + "patched": [ + "32.0.12", + "33.0.6" + ] + }, + { + "ghsa": "GHSA-9h3w-f3h4-qqrh", + "package": "Server", + "severity": "medium", + "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "29.0.15", + "30.0.9", + "31.0.3" + ] + }, + { + "ghsa": "GHSA-9h3w-f3h4-qqrh", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "26.0.13.15", + "27.1.11.15", + "28.0.14.6", + "29.0.15", + "30.0.9", + "31.0.3" + ] + }, + { + "ghsa": "GHSA-9v72-9xv5-3p7c", + "package": "Server", + "severity": "high", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "26.0.13", + "27.1.8", + "28.0.4" + ] + }, + { + "ghsa": "GHSA-9v72-9xv5-3p7c", + "package": "Server", + "severity": "high", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "21.0.9.17", + "22.2.10.22", + "23.0.12.17", + "24.0.12.13", + "25.0.13.8", + "26.0.13", + "27.1.8", + "28.0.4" + ] + }, + { + "ghsa": "GHSA-c7vq-m7f8-rx37", + "package": "Server", + "severity": "medium", + "range": "28.0.13, 29.0.10, 30.0.3", + "patched": [ + ">= 28.0.0", + ">= 29.0.0", + ">= 30.0.0" + ] + }, + { + "ghsa": "GHSA-c7vq-m7f8-rx37", + "package": "Server", + "severity": "medium", + "range": "28.0.13, 29.0.10, 30.0.3", + "patched": [ + ">= 28.0.0", + ">= 29.0.0", + ">= 30.0.0" + ] + }, + { + "ghsa": "GHSA-fvpc-8hq6-jgq2", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0", + "patched": [ + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-fvpc-8hq6-jgq2", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.11.8", + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-g8pr-g25r-58xj", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >= 28.0.0", + "patched": [ + "27.1.9", + "28.0.5", + "29.0.0" + ] + }, + { + "ghsa": "GHSA-g8pr-g25r-58xj", + "package": "Server", + "severity": "medium", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "21.0.9.18", + "22.2.10.23", + "23.0.12.18", + "24.0.12.14", + "25.0.13.9", + "26.0.13.3", + "27.1.9", + "28.0.5", + "29.0.0" + ] + }, + { + "ghsa": "GHSA-gxph-5m4j-pfmj", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.11", + "29.0.8", + "30.0.1" + ] + }, + { + "ghsa": "GHSA-gxph-5m4j-pfmj", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "25.0.13.13", + "26.0.13.9", + "27.1.11.9", + "28.0.11", + "29.0.8", + "30.0.1" + ] + }, + { + "ghsa": "GHSA-h4xv-cjpm-j595", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-h4xv-cjpm-j595", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-hq6c-r898-fgf2", + "package": "Server", + "severity": "medium", + "range": "31.0.0", + "patched": [ + "31.0.1" + ] + }, + { + "ghsa": "GHSA-hq6c-r898-fgf2", + "package": "Server", + "severity": "medium", + "range": "31.0.0", + "patched": [ + "31.0.1" + ] + }, + { + "ghsa": "GHSA-hrrv-mp25-26vv", + "package": "Server", + "severity": "high", + "range": ">= 33.0.0, >= 32.0.0", + "patched": [ + "33.0.3", + "32.0.9" + ] + }, + { + "ghsa": "GHSA-hrrv-mp25-26vv", + "package": "Server", + "severity": "high", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0, >= 27.0.0, >= 26.0.0, >= 25.0.0, >= 24.0.0, >= 23.0.0, >= 22.0.0, >= 21.0.0", + "patched": [ + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16", + "28.0.14.17", + "27.1.11.26", + "26.0.13.26", + "25.0.13.29", + "24.0.12.34", + "23.0.12.35", + "22.2.10.39", + "21.0.9.23" + ] + }, + { + "ghsa": "GHSA-j33j-qph5-4wch", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 31.0.0", + "patched": [ + "32.0.4", + "31.0.14" + ] + }, + { + "ghsa": "GHSA-j33j-qph5-4wch", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0", + "patched": [ + "32.0.4", + "31.0.14", + "30.0.17.7", + "29.0.17.12", + "28.0.14.15" + ] + }, + { + "ghsa": "GHSA-jgcj-v42r-9922", + "package": "Server", + "severity": "medium", + "range": ">= 33.0.0, >= 32.0.0", + "patched": [ + "33.0.3", + "32.0.9" + ] + }, + { + "ghsa": "GHSA-jgcj-v42r-9922", + "package": "Server", + "severity": "medium", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", + "patched": [ + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0", + "patched": [ + "26.0.13" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0", + "patched": [ + "27.1.8" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0", + "patched": [ + "28.0.4" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 23.0.0", + "patched": [ + "23.0.12.17" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0", + "patched": [ + "24.0.12.13" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0", + "patched": [ + "25.0.13.8" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0", + "patched": [ + "26.0.13" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0", + "patched": [ + "27.1.8" + ] + }, + { + "ghsa": "GHSA-jjm3-j9xh-5xmq", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0", + "patched": [ + "28.0.4" + ] + }, + { + "ghsa": "GHSA-mp6x-g55j-w9jw", + "package": "Server", + "severity": "medium", + "range": ">= 33.0.0, >= 32.0.0", + "patched": [ + "33.0.3", + "32.0.9" + ] + }, + { + "ghsa": "GHSA-mp6x-g55j-w9jw", + "package": "Server", + "severity": "medium", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", + "patched": [ + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16" + ] + }, + { + "ghsa": "GHSA-pxqf-cfxw-mqmj", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0", + "patched": [ + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-pxqf-cfxw-mqmj", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.11.8", + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-q568-2933-gcjq", + "package": "Server", + "severity": "low", + "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "29.0.13", + "30.0.7", + "31.0.1" + ] + }, + { + "ghsa": "GHSA-qcw2-p26m-9gc5", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-qcw2-p26m-9gc5", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-qqgg-hhfq-vhww", + "package": "Server", + "severity": "medium", + "range": ">= 30.0.0, >= 29.0.0", + "patched": [ + "30.0.2", + "29.0.9" + ] + }, + { + "ghsa": "GHSA-r3xh-x86g-hw4m", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-vrhf-532w-99rg", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-vrhf-532w-99rg", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-w7v5-mgxm-v6gm", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-w7v5-mgxm-v6gm", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-wppc-f5g8-vx36", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.11", + "27.1.6", + "28.0.0" + ] + }, + { + "ghsa": "GHSA-wppc-f5g8-vx36", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.11", + "27.1.6", + "28.0.0" + ] + }, + { + "ghsa": "GHSA-ww9m-f8j4-jj9x", + "package": "Server", + "severity": "medium", + "range": ">= 30.0.0, >= 31.0.0", + "patched": [ + "30.0.9", + "31.0.1" + ] + }, + { + "ghsa": "GHSA-ww9m-f8j4-jj9x", + "package": "Server", + "severity": "medium", + "range": ">= 30.0.0, >= 31.0.0", + "patched": [ + "30.0.9", + "31.0.1" + ] + }, + { + "ghsa": "GHSA-x9q3-c7f8-3rcg", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.11", + "29.0.8", + "30.0.1" + ] + }, + { + "ghsa": "GHSA-x9q3-c7f8-3rcg", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "25.0.13.13", + "26.0.13.9", + "27.1.11.9", + "28.0.11", + "29.0.8", + "30.0.1" + ] + }, + { + "ghsa": "GHSA-xpgv-grf9-gm7x", + "package": "Server", + "severity": "low", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.7", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-xpgv-grf9-gm7x", + "package": "Server", + "severity": "low", + "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0, >= 33.0.0", + "patched": [ + "29.0.16.14", + "30.0.17.8", + "31.0.14.3", + "32.0.7", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0", + "patched": [ + "26.0.13" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0", + "patched": [ + "27.1.8" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0", + "patched": [ + "28.0.4" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0", + "patched": [ + "25.0.13.7" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0", + "patched": [ + "26.0.13" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0", + "patched": [ + "27.1.8" + ] + }, + { + "ghsa": "GHSA-xwgx-f37p-xh8c", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0", + "patched": [ + "28.0.4" + ] + }, + { + "ghsa": "GHSA-2cwj-qp49-4xfw", + "package": "Tables", + "severity": "medium", + "range": ">= 0.6.0, >= 0.7.0", + "patched": [ + "0.8.9", + "0.9.6", + "1.0.1" + ] + }, + { + "ghsa": "GHSA-4qqp-9h2g-7qg7", + "package": "Tables", + "severity": "medium", + "range": ">= 0.6.0", + "patched": [ + "0.8.0" + ] + }, + { + "ghsa": "GHSA-5h2w-c7px-hp4j", + "package": "Tables", + "severity": "high", + "range": ">= 0.9.0, >= 1.0.0", + "patched": [ + "0.9.7", + "1.0.2" + ] + }, + { + "ghsa": "GHSA-p53h-6294-crjw", + "package": "Tables", + "severity": "medium", + "range": ">= 0.8.0, >= 0.9.0", + "patched": [ + "0.8.7", + "0.9.4" + ] + }, + { + "ghsa": "GHSA-rgvc-xr2w-qq45", + "package": "Tables", + "severity": "low", + "range": ">= 0.3.0", + "patched": [ + "0.8.1" + ] + }, + { + "ghsa": "GHSA-vvxm-6jjp-m9mp", + "package": "Tables", + "severity": "medium", + "range": ">=0.8.0,>=0.9.0,>=1.0.0", + "patched": [ + "1.0.4", + "2.0.0" + ] + }, + { + "ghsa": "GHSA-w787-vwqp-8wr7", + "package": "Tables", + "severity": "medium", + "range": ">= 0.4.0, >= 0.9.0", + "patched": [ + "0.8.6", + "0.9.3" + ] + }, + { + "ghsa": "GHSA-wpp5-4w35-pxq6", + "package": "Tables", + "severity": "medium", + "range": ">= 0.7.0, >= 0.8.0, >= 0.9.0", + "patched": [ + "0.7.6", + "0.8.8", + "0.9.5" + ] + }, + { + "ghsa": "GHSA-x43f-gmgh-vvjj", + "package": "Tables", + "severity": "high", + "range": ">=0.7.0,>=0.8.0,>=0.9.0,>=1.0.0", + "patched": [ + "0.7.7", + "0.8.10", + "0.9.8", + "1.0.4", + "2.0.0" + ] + }, + { + "ghsa": "GHSA-pr9f-vqgg-m2jh", + "package": "Talk", + "severity": "low", + "range": ">= 20.0.0, >= 21.0.0", + "patched": [ + "20.1.8", + "21.1.2" + ] + }, + { + "ghsa": "GHSA-x75r-65hm-cw35", + "package": "Talk", + "severity": "low", + "range": "< 21.1.10, < 22.0.11, < 23.0.3", + "patched": [ + "21.1.10", + "22.0.11", + "23.0.3" + ] + }, + { + "ghsa": "GHSA-wx2x-822r-rvmf", + "package": "Team Folders", + "severity": "medium", + "range": ">= 17.0.0, >= 18.0.0, >= 19.0.0, >= 20.0.0, >= 21.0.0", + "patched": [ + "17.0.15", + "18.1.12", + "19.1.16", + "20.1.11", + "21.0.4" + ] + }, + { + "ghsa": "GHSA-fr8x-mvjg-wf9q", + "package": "Twofactor WebAuthn", + "severity": "low", + "range": ">= 1.0.0, >= 2.0.0", + "patched": [ + "1.4.2", + "2.4.1" + ] + }, + { + "ghsa": "GHSA-784j-x2g5-4g7q", + "package": "User OIDC", + "severity": "low", + "range": ">= 6.0.0", + "patched": [ + "6.1.0" + ] + }, + { + "ghsa": "GHSA-79xf-ffj8-96fm", + "package": "User OIDC", + "severity": "medium", + "range": ">= 1.3.6, >= 5.0.3, >= 6.1.0, >= 6.3.0, >= 8.0.0", + "patched": [ + "8.4.0" + ] + }, + { + "ghsa": "GHSA-qqgv-fqwp-mjpp", + "package": "User OIDC", + "severity": "high", + "range": ">= 0.3.0, >= 1.0.0, >= 1.2.0, >= 1.3.0, >= 5.0.0, >= 6.0.0", + "patched": [ + "3.1.0", + "4.1.0", + "5.1.0", + "6.4.0", + "8.3.0" + ] + }, + { + "ghsa": "GHSA-vw7g-959g-vj6q", + "package": "User OIDC", + "severity": "medium", + "range": "<= 1.3.6", + "patched": [ + "3.0.0", + "4.0.0", + "5.0.0" + ] + }, + { + "ghsa": "GHSA-622q-xhfr-xmv7", + "package": "User Saml", + "severity": "low", + "range": ">= 5.0.0, >= 5.1.0, >= 5.2.0, >= 6.0.0", + "patched": [ + "5.1.5", + "5.2.5", + "6.0.1" + ] + }, + { + "ghsa": "GHSA-8wjr-5cg8-4w73", + "package": "user_oidc", + "severity": "low", + "range": ">= 6.1.0, >= 6.3.0", + "patched": [ + "8.2.2" + ] + }, + { + "ghsa": "GHSA-vw5h-29xf-g55g", + "package": "user_oidc", + "severity": "medium", + "range": "< 1.3.5", + "patched": [ + "1.3.5", + "2.0.0", + "3.0.0", + "4.0.0", + "5.0.0" + ] + } +] \ No newline at end of file diff --git a/tests/unit/Service/Advisory/BranchAwareRangeTest.php b/tests/unit/Service/Advisory/BranchAwareRangeTest.php new file mode 100644 index 00000000..01a470ca --- /dev/null +++ b/tests/unit/Service/Advisory/BranchAwareRangeTest.php @@ -0,0 +1,222 @@ +range = new BranchAwareRange(); + } + + /** + * The real advisory corpus, captured from `nextcloud/security-advisories` + * on 2026-08-21: 161 vulnerability entries across 27 packages. + * + * @return list}> + */ + private function corpus(): array { + $raw = file_get_contents(__DIR__ . '/../../../fixtures/nextcloud-advisories.json'); + self::assertIsString($raw, 'the advisory fixture must be readable'); + /** @var list}> $decoded */ + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + // A fixture that silently emptied would make every sweep below vacuous + // — the classic "the check passed because it checked nothing". + self::assertGreaterThan(100, count($decoded), 'the fixture must actually contain the corpus'); + + return $decoded; + } + + // ── The two properties, swept over the whole real corpus ────────────── + + /** + * An instance sitting exactly ON a published patch must never be reported + * as affected. This is the property that pure-OR evaluation violates: + * Talk's `< 21.1.10, < 22.0.11, < 23.0.3` would flag a correct 22.0.11. + */ + public function testNoInstanceOnAPatchedVersionIsEverReportedAffected(): void { + $probes = 0; + $falsePositives = []; + + foreach ($this->corpus() as $entry) { + foreach ($entry['patched'] as $patched) { + $probes++; + $verdict = $this->range->resolvePatch($patched, $entry['patched']); + if ($verdict !== null) { + $falsePositives[] = sprintf( + '%s [%s] installed=%s patched=%s -> recommended %s', + $entry['ghsa'], + $entry['package'], + $patched, + implode(',', $entry['patched']), + $verdict, + ); + } + } + } + + self::assertGreaterThan(400, $probes, 'the sweep must actually probe the corpus'); + self::assertSame([], $falsePositives, "patched versions reported as still vulnerable:\n" . implode("\n", $falsePositives)); + } + + /** + * An instance one patch level BELOW a published patch must always be + * reported affected. This is the property that AND evaluation violates: + * Mail's four lower bounds collapse to `>= 4.3.0`, clearing 3.6.0. + */ + public function testEveryVersionJustBelowAPatchIsReportedAffected(): void { + $probes = 0; + $misses = []; + + foreach ($this->corpus() as $entry) { + foreach ($entry['patched'] as $patched) { + $below = $this->oneBelow($patched); + if ($below === null) { + continue; + } + // Skip when the constructed version is ITSELF a published + // patch. With patches 3.0.0/4.0.0/5.0.0, "one below 4.0.0" is + // 3.0.0 — a fixed version — so the two properties in this + // class would contradict each other on it. The probe is what + // is wrong there, not the verdict. + if ($this->isItselfAPatch($below, $entry['patched'])) { + continue; + } + $probes++; + if ($this->range->resolvePatch($below, $entry['patched']) === null) { + $misses[] = sprintf( + '%s [%s] installed=%s patched=%s -> reported SAFE', + $entry['ghsa'], + $entry['package'], + $below, + implode(',', $entry['patched']), + ); + } + } + } + + self::assertGreaterThan(350, $probes, 'the sweep must actually probe the corpus'); + self::assertSame([], $misses, "vulnerable versions reported as safe:\n" . implode("\n", $misses)); + } + + // ── The specific shapes those two properties exist to protect ───────── + + /** + * Mail, the 66.5% shape. Under the previous AND semantics the four lower + * bounds collapsed to `>= 4.3.0` and 3.6.0 was cleared. + */ + public function testAffectedOnABranchWhoseOwnPatchIsNotListed(): void { + $patched = ['3.7.25', '5.5.16', '5.6.20', '5.7.13']; + + self::assertSame('3.7.25', $this->range->resolvePatch('3.6.0', $patched), 'branch 3.6 has no patch, so the nearest patch on major 3 applies'); + self::assertSame('3.7.25', $this->range->resolvePatch('3.5.0', $patched)); + } + + /** + * Talk, the multi-upper shape. Under OR semantics 22.0.11 would be flagged + * because branch 23 has a later patch. + */ + public function testAPatchedBranchIsNotDraggedForwardByALaterBranch(): void { + $patched = ['21.1.10', '22.0.11', '23.0.3']; + + self::assertNull($this->range->resolvePatch('22.0.11', $patched), 'on its branch patch: fixed'); + self::assertNull($this->range->resolvePatch('22.0.99', $patched), 'above its branch patch: fixed'); + self::assertSame('22.0.11', $this->range->resolvePatch('22.0.10', $patched), 'below its branch patch: affected'); + } + + public function testNewerThanEveryPatchOnItsMajorIsNotAffected(): void { + self::assertNull($this->range->resolvePatch('5.8.0', ['3.7.25', '5.5.16', '5.6.20', '5.7.13'])); + } + + /** + * A branch abandoned without a fix must still be reported affected, even + * though the only exit is a major upgrade. + * + * This test originally asserted the OPPOSITE — that a fix on a higher + * major is a migration and should not be recommended. Sweeping the real + * corpus disproved it: User OIDC 2.0.0 against patches 3.0.0/4.0.0/5.0.0 + * has no 2.x fix, so "never cross a major" reports a vulnerable instance + * as safe. Forms is the same shape — `>= 4.3.0` fixed only in 5.2.7. + */ + public function testABranchAbandonedWithoutAFixIsStillAffected(): void { + self::assertSame('5.2.7', $this->range->resolvePatch('4.9.0', ['5.2.7']), 'major 4 has no fix; 5.2.7 is the only exit'); + self::assertSame('3.0.0', $this->range->resolvePatch('2.0.0', ['3.0.0', '4.0.0', '5.0.0'])); + } + + /** + * The corpus contains records whose `patched_versions` field carries range + * OPERATORS rather than bare versions (`>= 28.0.0, >= 29.0.0`). Parsing + * must survive that rather than treating it as no patch at all. + */ + public function testToleratesOperatorsInsideThePatchedVersionsField(): void { + $patched = ['>= 28.0.0', '>= 29.0.0', '>= 30.0.0']; + + self::assertSame('>= 28.0.0', $this->range->resolvePatch('27.0.0', $patched), 'below every patch: affected'); + self::assertNull($this->range->resolvePatch('31.0.0', $patched), 'above every patch on no listed branch: fixed'); + } + + public function testSingleUpperBoundShape(): void { + self::assertSame('2.7.2', $this->range->resolvePatch('2.7.1', ['2.7.2'])); + self::assertNull($this->range->resolvePatch('2.7.2', ['2.7.2'])); + } + + public function testFourSegmentServerVersionsCompareNumerically(): void { + // Server advisories carry four-segment patches such as 29.0.16.10. + self::assertSame('29.0.16.10', $this->range->resolvePatch('29.0.16.9', ['29.0.16.10'])); + self::assertNull($this->range->resolvePatch('29.0.16.10', ['29.0.16.10'])); + // version_compare() would order 29.0.16 BELOW 29.0.16.0; segment + // comparison treats them as equal, which is what the data means. + self::assertNull($this->range->resolvePatch('29.0.16', ['29.0.16'])); + } + + public function testNoPatchedVersionsMeansNoVerdict(): void { + self::assertNull($this->range->resolvePatch('1.0.0', [])); + self::assertNull($this->range->resolvePatch('1.0.0', [' '])); + } + + /** + * Numeric equality against any published patch, ignoring operators and + * segment padding, so `3.0.0` matches `>= 3.0.0` and `3.0.0.0`. + * + * @param list $patched + */ + private function isItselfAPatch(string $version, array $patched): bool { + $normalise = static function (string $v): array { + preg_match_all('/\d+/', $v, $m); + + return array_pad(array_map('intval', array_slice($m[0], 0, 4)), 4, 0); + }; + $target = $normalise($version); + foreach ($patched as $candidate) { + if ($normalise($candidate) === $target) { + return true; + } + } + + return false; + } + + /** + * @return string|null one patch level below, or null when there is no + * lower neighbour to construct + */ + private function oneBelow(string $version): ?string { + preg_match_all('/\d+/', $version, $matches); + $parts = array_map('intval', $matches[0]); + for ($i = count($parts) - 1; $i >= 0; $i--) { + if ($parts[$i] > 0) { + $parts[$i]--; + + return implode('.', $parts); + } + } + + return null; + } +} From d4eb20c1bf944bd235e094cd68a29c907a55a622 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 09:19:07 +0200 Subject: [PATCH 2/6] test(advisories): use the FULL advisory corpus, not the first page of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed endpoint ignores `?page=` — page 1 and page 2 return identical bodies — and paginates by an opaque cursor in the Link header instead. The original fixture was built with a page-number loop, so it captured only the first 100 advisories and I described it as the corpus. Following the cursor reaches 277 advisories: 389 vulnerability entries with patched versions across 53 distinct packages, against 161 entries and 27 packages before. The validation sweeps now run over 2.4x the data. Both properties still hold unchanged: no instance sitting on a published patch is reported affected, and every instance one patch level below a patch is reported affected. --- tests/fixtures/nextcloud-advisories.json | 3392 ++++++++++++++++++---- 1 file changed, 2895 insertions(+), 497 deletions(-) diff --git a/tests/fixtures/nextcloud-advisories.json b/tests/fixtures/nextcloud-advisories.json index 9a85b431..43b438cc 100644 --- a/tests/fixtures/nextcloud-advisories.json +++ b/tests/fixtures/nextcloud-advisories.json @@ -1,4 +1,67 @@ [ + { + "ghsa": "GHSA-32j4-9xf3-h2mg", + "package": "Android", + "severity": "low", + "range": "< 3.19.1", + "patched": [ + "3.19.1" + ] + }, + { + "ghsa": "GHSA-5cj3-v98r-2wmq", + "package": "Android", + "severity": "low", + "range": "< 3.19.0", + "patched": [ + "3.19.0" + ] + }, + { + "ghsa": "GHSA-8875-wxww-3rr8", + "package": "Android", + "severity": "medium", + "range": ">= 3.13.0", + "patched": [ + "3.25.0" + ] + }, + { + "ghsa": "GHSA-c3rf-94h6-vj8v", + "package": "Android", + "severity": "low", + "range": ">= 3.7.0", + "patched": [ + "3.24.1" + ] + }, + { + "ghsa": "GHSA-vw2w-gpcv-v39f", + "package": "Android", + "severity": "low", + "range": "< 3.21.0", + "patched": [ + "3.21.0" + ] + }, + { + "ghsa": "GHSA-wrwg-jwpg-r3c4", + "package": "Android", + "severity": "low", + "range": "< 3.17.1", + "patched": [ + "3.17.1" + ] + }, + { + "ghsa": "GHSA-xcj9-3jch-qr2r", + "package": "Android", + "severity": "low", + "range": "< 3.19.0", + "patched": [ + "3.19.0" + ] + }, { "ghsa": "GHSA-2w7v-5299-3hw5", "package": "Android Files", @@ -8,6 +71,15 @@ "33.1.0" ] }, + { + "ghsa": "GHSA-497c-c8hx-6qcf", + "package": "Android Talk Client", + "severity": "low", + "range": "< 12.3.0", + "patched": [ + "12.3.0" + ] + }, { "ghsa": "GHSA-h7gm-vgxr-9hcw", "package": "Approval", @@ -36,6 +108,16 @@ "2.7.2" ] }, + { + "ghsa": "GHSA-2792-2734-hr7j", + "package": "Calendar", + "severity": "low", + "range": "\u2264 v3.5.4, \u2264 v4.2.2", + "patched": [ + "v3.5.5", + "v4.2.3" + ] + }, { "ghsa": "GHSA-2r7q-vfmv-79qf", "package": "Calendar", @@ -57,6 +139,15 @@ "6.0.1" ] }, + { + "ghsa": "GHSA-8xv5-4855-24qf", + "package": "Calendar", + "severity": "medium", + "range": "<3.2.2", + "patched": [ + "3.2.2" + ] + }, { "ghsa": "GHSA-f29c-ppmv-8mcv", "package": "Calendar", @@ -86,6 +177,15 @@ "5.5.17" ] }, + { + "ghsa": "GHSA-r936-8gwm-w452", + "package": "Calendar", + "severity": "medium", + "range": ">= 1.0.0", + "patched": [ + "4.4.4" + ] + }, { "ghsa": "GHSA-whm3-vv55-gf27", "package": "Calendar", @@ -115,6 +215,78 @@ "7.2.5" ] }, + { + "ghsa": "GHSA-hxr6-cx85-gcjx", + "package": "Contacts", + "severity": "low", + "range": "\u2265 v5.0.0, \u2265 v4.1.0", + "patched": [ + "v5.0.3", + "v4.2.4" + ] + }, + { + "ghsa": "GHSA-j6cx-mxqf-f9vc", + "package": "Contacts", + "severity": "low", + "range": "< 4.0.3", + "patched": [ + "4.0.3" + ] + }, + { + "ghsa": "GHSA-2vw5-pfg6-3wm6", + "package": "Deck", + "severity": "low", + "range": "1.6.5, < 1.7.3, < 1.8.2", + "patched": [ + "1.6.5", + "1.7.3", + "1.8.2" + ] + }, + { + "ghsa": "GHSA-2x96-38qg-3m72", + "package": "Deck", + "severity": "high", + "range": "< 1.2.9, < 1.4.5, < 1.5.3", + "patched": [ + "1.2.9", + "1.4.5", + "1.5.3" + ] + }, + { + "ghsa": "GHSA-8fjp-w9gp-j5hq", + "package": "Deck", + "severity": "medium", + "range": "1.8.0, 1.8.1", + "patched": [ + "1.8.2" + ] + }, + { + "ghsa": "GHSA-93j5-wx4c-6g88", + "package": "Deck", + "severity": "low", + "range": "< 1.6.5, < 1.7.3, < 1.8.2", + "patched": [ + "1.6.5", + "1.7.3", + "1.8.2" + ] + }, + { + "ghsa": "GHSA-hx9w-xfrg-2qvp", + "package": "Deck", + "severity": "low", + "range": "< v1.2.11, < v1.4.6, < v1.5.4", + "patched": [ + "v1.2.11", + "v1.4.6", + "v1.5.4" + ] + }, { "ghsa": "GHSA-mg7w-x9fm-9wwc", "package": "Deck", @@ -125,6 +297,17 @@ "1.11.2" ] }, + { + "ghsa": "GHSA-vqhf-673w-7r3j", + "package": "Deck", + "severity": "medium", + "range": "< 1.4.8, < 1.5.6, < 1.6.1", + "patched": [ + "1.4.8", + "1.5.6", + "1.6.1" + ] + }, { "ghsa": "GHSA-wwr8-hx9g-rjvv", "package": "Deck", @@ -200,6 +383,87 @@ "1.15.1" ] }, + { + "ghsa": "GHSA-3w86-rm38-8w63", + "package": "Desktop", + "severity": "medium", + "range": "3.6.0", + "patched": [ + "3.6.1" + ] + }, + { + "ghsa": "GHSA-4gfv-xqpx-42qj", + "package": "Desktop", + "severity": "medium", + "range": "3.6.1", + "patched": [ + "3.6.2" + ] + }, + { + "ghsa": "GHSA-4p33-rw27-j5fc", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.0.0", + "patched": [ + "3.6.5" + ] + }, + { + "ghsa": "GHSA-64qc-vf6v-8xgg", + "package": "Desktop", + "severity": "low", + "range": "< 3.6.3", + "patched": [ + "3.6.3" + ] + }, + { + "ghsa": "GHSA-82xx-98xv-4jxv", + "package": "Desktop", + "severity": "low", + "range": "< 3.6.1", + "patched": [ + "3.6.1" + ] + }, + { + "ghsa": "GHSA-8875-wxww-3rr8", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.0.0", + "patched": [ + "3.8.0" + ] + }, + { + "ghsa": "GHSA-92p9-x79h-2mj8", + "package": "Desktop", + "severity": "low", + "range": "< 3.6.1", + "patched": [ + "3.6.1" + ] + }, + { + "ghsa": "GHSA-c3xh-q694-6rc5", + "package": "Desktop", + "severity": "low", + "range": "< 3.6.1", + "patched": [ + "3.6.1" + ] + }, + { + "ghsa": "GHSA-h82x-98q3-7534", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.0.0", + "patched": [ + "3.7.0" + ] + }, { "ghsa": "GHSA-h9xj-qh76-q3hw", "package": "Desktop", @@ -218,6 +482,24 @@ "3.13.4" ] }, + { + "ghsa": "GHSA-jh3g-wpwv-cqgr", + "package": "Desktop", + "severity": "medium", + "range": ">= 3.0.0", + "patched": [ + "3.6.5" + ] + }, + { + "ghsa": "GHSA-q9f6-4r6r-h74p", + "package": "Desktop", + "severity": "low", + "range": "< 3.6.1", + "patched": [ + "3.6.1" + ] + }, { "ghsa": "GHSA-qm2f-959g-7p65", "package": "Desktop", @@ -236,6 +518,24 @@ "3.14.2" ] }, + { + "ghsa": "GHSA-6q2w-v879-q24v", + "package": "Desktop Client", + "severity": "low", + "range": "3.0.3 up to 3.2.4", + "patched": [ + "3.3.0" + ] + }, + { + "ghsa": "GHSA-f5fr-5gcv-6cc5", + "package": "Desktop Client", + "severity": "medium", + "range": "< 3.3.0", + "patched": [ + "3.3.0" + ] + }, { "ghsa": "GHSA-4mf7-v63m-99p7", "package": "Desktop client", @@ -258,6 +558,15 @@ "2.0.0-rc.7" ] }, + { + "ghsa": "GHSA-x7c7-v5r3-mg37", + "package": "End-to-End Encryption", + "severity": "medium", + "range": ">= 1.12.0", + "patched": [ + "1.12.4" + ] + }, { "ghsa": "GHSA-q568-2933-gcjq", "package": "Enterprise Server", @@ -283,6 +592,17 @@ "28.0.12" ] }, + { + "ghsa": "GHSA-4m73-g7v7-v62w", + "package": "Files Access Control", + "severity": "low", + "range": "< 1.12.2, < 1.13.1, < 1.14.1", + "patched": [ + "1.12.2", + "1.13.1", + "1.14.1" + ] + }, { "ghsa": "GHSA-vhj3-mch4-67fq", "package": "Files ZIP", @@ -294,6 +614,20 @@ "1.5.0" ] }, + { + "ghsa": "GHSA-3m2f-v8x7-9w99", + "package": "Files automated tagging", + "severity": "medium", + "range": ">= 1.11.0, >= 1.12.0, >= 1.13.0, >= 1.14.0, >= 1.15.0, >= 1.16.0", + "patched": [ + "1.11.1", + "1.12.1", + "1.13.1", + "1.14.2", + "1.15.3", + "1.16.1" + ] + }, { "ghsa": "GHSA-j8g7-88vv-rggv", "package": "Files iOS", @@ -330,6 +664,18 @@ "5.2.6" ] }, + { + "ghsa": "GHSA-vj5q-f63m-wp77", + "package": "Global Site Selector", + "severity": "critical", + "range": ">= 1.1.0, >= 2.0.0, >= 2.1.0, >= 2.2.0, >= 2.3.0, >= 2.4.0", + "patched": [ + "1.4.1", + "2.1.2", + "2.3.4", + "2.4.5" + ] + }, { "ghsa": "GHSA-2vrq-fhmf-c49m", "package": "Groupfolders", @@ -378,6 +724,16 @@ "3.0.1" ] }, + { + "ghsa": "GHSA-24pm-rjfv-23mh", + "package": "Mail", + "severity": "medium", + "range": "<1.12.7, <1.13.6", + "patched": [ + "1.12.8", + "1.13.6" + ] + }, { "ghsa": "GHSA-4pp4-m8ph-2999", "package": "Mail", @@ -389,957 +745,2625 @@ ] }, { - "ghsa": "GHSA-pwpp-fvcr-w862", + "ghsa": "GHSA-63m3-w68h-3wjg", "package": "Mail", "severity": "low", - "range": ">=2.2.0, >= 3.6.0, >= 3.7.0", + "range": "< 1.12.1", "patched": [ - "2.2.10", - "3.6.2", - "3.7.2" + "1.12.1" ] }, { - "ghsa": "GHSA-v394-8gpc-6fv5", + "ghsa": "GHSA-6q9v-wm8r-rcv5", "package": "Mail", "severity": "low", - "range": ">= 5.2.0", + "range": "< 1.10.4", "patched": [ - "5.5.3" + "1.10.4" ] }, { - "ghsa": "GHSA-vmhx-hwph-q6mc", + "ghsa": "GHSA-8gcx-r739-9pf6", "package": "Mail", - "severity": "high", - "range": ">= 1.9.0, >= 2.1.0, >= 3.1.0", + "severity": "low", + "range": "< 1.15.0, < 2.2.2", "patched": [ - "1.14.6", - "1.15.4", - "2.2.11", - "3.6.3", - "3.7.7", - "4.0.0" + "1.15.0", + "2.2.2" ] }, { - "ghsa": "GHSA-vq3v-jv6f-6xp2", + "ghsa": "GHSA-8gph-9895-w564", "package": "Mail", - "severity": "medium", - "range": ">= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0", + "severity": "low", + "range": ">= 2.3.0, >= 1.13.0, >= 1.12.0", "patched": [ - "3.7.25", - "5.5.16", - "5.6.20", - "5.7.13" + "3.0.2", + "2.2.5", + "1.15.3" ] }, { - "ghsa": "GHSA-wfqv-cx85-7rjx", - "package": "Notes", + "ghsa": "GHSA-8j9x-fmww-qr37", + "package": "Mail", "severity": "medium", - "range": ">= 4.6.0", + "range": ">= 2.0.0, >= 3.0.0", "patched": [ - "4.9.3" + "2.2.8", + "3.3.0" ] }, { - "ghsa": "GHSA-9chh-5prm-wp43", - "package": "Photos", + "ghsa": "GHSA-g86r-x755-93f4", + "package": "Mail", "severity": "low", - "range": ">= 25.0.1", + "range": "< 2.2.2", "patched": [ - "25.0.7" + "2.2.2" ] }, { - "ghsa": "GHSA-9chh-5prm-wp43", + "ghsa": "GHSA-m45f-r5gh-h6cx", + "package": "Mail", + "severity": "medium", + "range": "<2.2.1, < 1.14.5, <1.12.9, <1.11.8", + "patched": [ + "2.2.1", + "1.14.5", + "1.12.9", + "1.11.8" + ] + }, + { + "ghsa": "GHSA-pwpp-fvcr-w862", + "package": "Mail", + "severity": "low", + "range": ">=2.2.0, >= 3.6.0, >= 3.7.0", + "patched": [ + "2.2.10", + "3.6.2", + "3.7.2" + ] + }, + { + "ghsa": "GHSA-v394-8gpc-6fv5", + "package": "Mail", + "severity": "low", + "range": ">= 5.2.0", + "patched": [ + "5.5.3" + ] + }, + { + "ghsa": "GHSA-vmhx-hwph-q6mc", + "package": "Mail", + "severity": "high", + "range": ">= 1.9.0, >= 2.1.0, >= 3.1.0", + "patched": [ + "1.14.6", + "1.15.4", + "2.2.11", + "3.6.3", + "3.7.7", + "4.0.0" + ] + }, + { + "ghsa": "GHSA-vq3v-jv6f-6xp2", + "package": "Mail", + "severity": "medium", + "range": ">= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0", + "patched": [ + "3.7.25", + "5.5.16", + "5.6.20", + "5.7.13" + ] + }, + { + "ghsa": "GHSA-xhv7-5mhv-299j", + "package": "Mail", + "severity": "medium", + "range": "< 1.12.2", + "patched": [ + "1.12.2" + ] + }, + { + "ghsa": "GHSA-22v9-q3r6-x7cj", + "package": "Nextcloud Android Client", + "severity": "low", + "range": "< 3.16.0", + "patched": [ + "3.16.0" + ] + }, + { + "ghsa": "GHSA-25m9-cf6c-qf2c", + "package": "Nextcloud Android Client", + "severity": "low", + "range": "< 3.16.1", + "patched": [ + "3.16.1" + ] + }, + { + "ghsa": "GHSA-5v33-r9cm-7736", + "package": "Nextcloud Android Client", + "severity": "medium", + "range": "< 3.16.1", + "patched": [ + "3.16.1" + ] + }, + { + "ghsa": "GHSA-g5gf-rmhm-wpxw", + "package": "Nextcloud Android Client", + "severity": "low", + "range": "< 3.16.1", + "patched": [ + "3.16.1" + ] + }, + { + "ghsa": "GHSA-h2gm-m374-99vc", + "package": "Nextcloud Android Client", + "severity": "low", + "range": "< 3.15.1", + "patched": [ + "3.15.1" + ] + }, + { + "ghsa": "GHSA-56j9-3rj4-wvgm", + "package": "Nextcloud Circles", + "severity": "medium", + "range": "< 0.19.15, < 0.20.11, < 0.21.4", + "patched": [ + "0.19.15", + "0.20.11", + "0.21.4" + ] + }, + { + "ghsa": "GHSA-hgpq-28gj-jrj9", + "package": "Nextcloud Circles", + "severity": "low", + "range": "< 0.21.3, < 0.20.10, < 0.19.14", + "patched": [ + "0.21.3", + "0.20.10", + "0.19.14" + ] + }, + { + "ghsa": "GHSA-4mxp-j277-82hr", + "package": "Nextcloud Deck", + "severity": "medium", + "range": "< 1.5.1, < 1.4.4, < 1.2.9", + "patched": [ + "1.5.1", + "1.4.4", + "1.2.9" + ] + }, + { + "ghsa": "GHSA-h8f6-wg82-6p7r", + "package": "Nextcloud Deck", + "severity": "low", + "range": "< 1.2.7, < 1.4.2", + "patched": [ + "1.2.7", + "1.4.2" + ] + }, + { + "ghsa": "GHSA-qpgp-vf4p-wcw5", + "package": "Nextcloud Desktop Client", + "severity": "medium", + "range": "< 3.1.3", + "patched": [ + "3.1.3" + ] + }, + { + "ghsa": "GHSA-3829-45wm-ww36", + "package": "Nextcloud End-to-End Encryption", + "severity": "low", + "range": "< 1.5.3, < 1.6.3, < 1.7.1", + "patched": [ + "1.5.3", + "1.6.3", + "1.7.1" + ] + }, + { + "ghsa": "GHSA-jmgp-77jq-fjp3", + "package": "Nextcloud Mail", + "severity": "low", + "range": "< 1.9.5", + "patched": [ + "1.9.5" + ] + }, + { + "ghsa": "GHSA-mxx2-6rg9-v2vc", + "package": "Nextcloud Mail", + "severity": "high", + "range": "< 1.4.3, < 1.8.2", + "patched": [ + "1.4.3", + "1.8.2" + ] + }, + { + "ghsa": "GHSA-xxp4-44xc-8crh", + "package": "Nextcloud Mail", + "severity": "low", + "range": "< 1.9.6, < 1.10.0", + "patched": [ + "1.9.6", + "1.10.0" + ] + }, + { + "ghsa": "GHSA-24x8-h6m2-9jf2", + "package": "Nextcloud Richdocuments", + "severity": "low", + "range": "< 3.8.3, < 4.2.0", + "patched": [ + "3.8.3", + "4.2.0" + ] + }, + { + "ghsa": "GHSA-gvvr-h36p-8mjx", + "package": "Nextcloud Richdocuments", + "severity": "low", + "range": "< 3.8.4, < 4.2.1", + "patched": [ + "3.8.4", + "4.2.1" + ] + }, + { + "ghsa": "GHSA-2967-6mrp-gg3p", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-375p-cxxq-gc9p", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-396j-vqpr-qg45", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-3hjp-26x8-mhf6", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-48m7-7r2r-838r", + "package": "Nextcloud Server", + "severity": "high", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-48rx-3gmf-g74j", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-6f6v-h9x9-jj4v", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-6hf5-c2c4-2526", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-6qr9-c846-j8mg", + "package": "Nextcloud Server", + "severity": "high", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-crvj-vmf7-xrvr", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-fjv7-283f-5m54", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-fx62-q47f-f665", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-fxpq-wq7c-vppf", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-gcf3-3wmc-88jr", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 20.0.12, < 21.0.4, < 22.1.0", + "patched": [ + "20.0.12", + "21.0.4", + "22.1.0" + ] + }, + { + "ghsa": "GHSA-grph-cm44-p3jv", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-gv5w-8q25-785v", + "package": "Nextcloud Server", + "severity": "high", + "range": "< 20.0.12, < 21.0.4, < 22.1.0", + "patched": [ + "20.0.12", + "21.0.4", + "22.1.0" + ] + }, + { + "ghsa": "GHSA-j875-vr2q-h6x6", + "package": "Nextcloud Server", + "severity": "medium", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-jf9h-v24c-22g5", + "package": "Nextcloud Server", + "severity": "high", + "range": "< 19.0.11, < 20.0.10, < 21.0.2", + "patched": [ + "19.0.11", + "20.0.10", + "21.0.2" + ] + }, + { + "ghsa": "GHSA-m682-v4g9-wrq7", + "package": "Nextcloud Server", + "severity": "critical", + "range": "< 20.0.12, < 21.0.4, < 22.1.0", + "patched": [ + "20.0.12", + "21.0.4", + "22.1.0" + ] + }, + { + "ghsa": "GHSA-mcpf-v65v-359h", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 20.0.12, < 21.0.4, < 22.1.0", + "patched": [ + "20.0.12", + "21.0.4", + "22.1.0" + ] + }, + { + "ghsa": "GHSA-x4w3-jhcr-57pq", + "package": "Nextcloud Server", + "severity": "low", + "range": "< 19.0.13, < 20.0.11, < 21.0.3", + "patched": [ + "19.0.13", + "20.0.11", + "21.0.3" + ] + }, + { + "ghsa": "GHSA-p6h7-84v4-827r", + "package": "Nextcloud Talk", + "severity": "low", + "range": "< 9.0.10, < 10.0.8, < 11.2.2", + "patched": [ + "9.0.10", + "10.0.8", + "11.2.2" + ] + }, + { + "ghsa": "GHSA-xv6f-344w-895c", + "package": "Nextcloud Talk", + "severity": "medium", + "range": "< 11.2.2, < 11.3.0", + "patched": [ + "11.2.2", + "11.3.0" + ] + }, + { + "ghsa": "GHSA-m7w4-cvjr-76mh", + "package": "Nextcloud iOS Client", + "severity": "low", + "range": "< 3.4.2", + "patched": [ + "3.4.2" + ] + }, + { + "ghsa": "GHSA-6g88-37x7-4vw6", + "package": "Notes", + "severity": "low", + "range": ">= 4.4.0", + "patched": [ + "4.8.0" + ] + }, + { + "ghsa": "GHSA-wfqv-cx85-7rjx", + "package": "Notes", + "severity": "medium", + "range": ">= 4.6.0", + "patched": [ + "4.9.3" + ] + }, + { + "ghsa": "GHSA-64xc-r58v-53gj", + "package": "Office (Richdocuments)", + "severity": "medium", + "range": "< 7.0.2, < 6.3.2, < 5.0.10, <4.2.9, < 3.8.7", + "patched": [ + "7.0.2", + "6.3.2", + "5.0.10", + "4.2.9", + "3.8.7" + ] + }, + { + "ghsa": "GHSA-92g2-h5jv-jjmg", + "package": "Office (Richdocuments)", + "severity": "low", + "range": "7.0.0, 6.0.0-6.3.0", + "patched": [ + "7.0.1", + "6.3.1" + ] + }, + { + "ghsa": "GHSA-56wm-r6jm-3v9h", + "package": "OfficeOnline", + "severity": "low", + "range": "< 1.1.1", + "patched": [ + "1.1.1" + ] + }, + { + "ghsa": "GHSA-c7mw-9q4r-8qwr", + "package": "Password Policy", + "severity": "low", + "range": "< 22.2.10, < 23.0.7, < 24.0.3", + "patched": [ + "22.2.10", + "23.0.7", + "24.0.3" + ] + }, + { + "ghsa": "GHSA-9chh-5prm-wp43", "package": "Photos", "severity": "low", - "range": ">= 26.0.0", + "range": ">= 25.0.1", + "patched": [ + "25.0.7" + ] + }, + { + "ghsa": "GHSA-9chh-5prm-wp43", + "package": "Photos", + "severity": "low", + "range": ">= 26.0.0", + "patched": [ + "26.0.2" + ] + }, + { + "ghsa": "GHSA-pxhh-954f-8w7w", + "package": "Richdocuments", + "severity": "high", + "range": "< 3.8.4, < 4.2.1", + "patched": [ + "3.8.4", + "4.2.1" + ] + }, + { + "ghsa": "GHSA-rjcc-4cgj-6v93", + "package": "Richdocuments", + "severity": "low", + "range": "< 3.8.6, < 4.2.3", + "patched": [ + "3.8.6", + "4.2.3" + ] + }, + { + "ghsa": "GHSA-2448-44rp-c7hh", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-2448-44rp-c7hh", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-24wp-p865-7j4r", + "package": "Server", + "severity": "medium", + "range": ">=30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "30.0.17", + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-24wp-p865-7j4r", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "22.2.10.33", + "23.0.12.29", + "24.0.12.28", + "25.0.13.23", + "26.0.13.20", + "27.1.11.20", + "28.0.14.11", + "29.0.16.8", + "30.0.17", + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-264h-3v4w-6xh2", + "package": "Server", + "severity": "medium", + "range": "< 22.2.8, < 23.0.5, < 24.0.1", + "patched": [ + "22.2.8", + "23.0.5", + "24.0.1" + ] + }, + { + "ghsa": "GHSA-264h-3v4w-6xh2", + "package": "Server", + "severity": "medium", + "range": "<19.0.13.7, <20.0.14.6, <21.0.9.5, <22.2.8, <23.0.5", + "patched": [ + "19.0.13.7", + "20.0.14.6", + "21.0.9.5", + "22.2.8", + "23.0.5" + ] + }, + { + "ghsa": "GHSA-273v-9h7x-p68v", + "package": "Server", + "severity": "medium", + "range": "<25.0.2, < 24.0.8, < 23.0.12", + "patched": [ + "25.0.2", + "24.0.8", + "23.0.12" + ] + }, + { + "ghsa": "GHSA-285v-p9x9-cjhj", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-285v-p9x9-cjhj", + "package": "Server", + "severity": "medium", + "range": "< 21.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "patched": [ + "21.0.9.20", + "22.2.10.35", + "23.0.12.31", + "24.0.12.30", + "25.0.13.25", + "26.0.13.22", + "27.1.11.22", + "28.0.14.13", + "29.0.16.10", + "30.0.17.5", + "31.0.12", + "32.0.3" + ] + }, + { + "ghsa": "GHSA-2hrc-5fgp-c9c9", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0", + "patched": [ + "25.0.9", + "26.0.4" + ] + }, + { + "ghsa": "GHSA-2hrc-5fgp-c9c9", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", + "patched": [ + "22.2.10.14", + "23.0.12.9", + "24.0.12.5", + "25.0.9", + "26.0.4" + ] + }, + { + "ghsa": "GHSA-2q6f-gjgj-7hp4", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.10", + "29.0.7", + "30.0.0" + ] + }, + { + "ghsa": "GHSA-2q6f-gjgj-7hp4", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.10", + "29.0.7", + "30.0.0" + ] + }, + { + "ghsa": "GHSA-2vwh-5v93-3vcq", + "package": "Server", + "severity": "low", + "range": "< 22.2.9, < 23.0.6, < 24.0.2", + "patched": [ + "22.2.9", + "23.0.6", + "24.0.2" + ] + }, + { + "ghsa": "GHSA-35fx-69q6-xpjr", + "package": "Server", + "severity": "medium", + "range": ">=32.0.0, >=33.0.0", + "patched": [ + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-35fx-69q6-xpjr", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >=28.0.0, >=29.0.0, >=30.0.0, >=31.0.0, >=32.0.0, >=33.0.0", + "patched": [ + "27.1.11.5", + "28.0.14.17", + "29.0.16.16", + "30.0.17.9", + "31.0.14.5", + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-35gc-jc6x-29cm", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0", + "patched": [ + "28.0.9", + "29.0.5" + ] + }, + { + "ghsa": "GHSA-35gc-jc6x-29cm", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "26.0.13.9", + "27.1.11.9", + "28.0.9", + "29.0.5" + ] + }, + { + "ghsa": "GHSA-35p6-4992-w5fr", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-35p6-4992-w5fr", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-36g6-wjx2-333x", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-36g6-wjx2-333x", + "package": "Server", + "severity": "medium", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "21.0.9.10", + "22.2.10.10", + "23.0.12.5", + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-3f8p-6qww-2prr", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-3f8p-6qww-2prr", + "package": "Server", + "severity": "medium", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "23.0.12.13", + "24.0.12.9", + "25.0.13.4", + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-3m2f-v8x7-9w99", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.11", + "25.0.5" + ] + }, + { + "ghsa": "GHSA-3m2f-v8x7-9w99", + "package": "Server", + "severity": "medium", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "21.0.9.11", + "22.2.10.11", + "23.0.12.6", + "24.0.11", + "25.0.5" + ] + }, + { + "ghsa": "GHSA-42w6-r45m-9w9j", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-42w6-r45m-9w9j", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "25.0.13.14", + "26.0.13.10", + "27.1.11.10", + "28.0.12", + "29.0.9", + "30.0.2" + ] + }, + { + "ghsa": "GHSA-45pj-p7x7-4mhc", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-45pj-p7x7-4mhc", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0, >= 33.0.0", + "patched": [ + "26.0.13.26", + "27.1.11.25", + "28.0.14.17", + "29.0.16.16", + "30.0.17.9", + "31.0.14.5", + "32.0.9", + "33.0.3" + ] + }, + { + "ghsa": "GHSA-492h-596q-xr2f", + "package": "Server", + "severity": "low", + "range": "< 25.0.1, < 24.0.8, < 23.0.12", + "patched": [ + "25.0.1", + "24.0.8", + "23.0.12" + ] + }, + { + "ghsa": "GHSA-495w-cqv6-wr59", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0", + "patched": [ + "31.0.10", + "32.0.1" + ] + }, + { + "ghsa": "GHSA-495w-cqv6-wr59", + "package": "Server", + "severity": "medium", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "28.0.14.11", + "29.0.16.8", + "30.0.17.3", + "31.0.10" + ] + }, + { + "ghsa": "GHSA-4chh-6mhf-p4jj", + "package": "Server", + "severity": "medium", + "range": ">= 32.0.0, >= 33.0.0", + "patched": [ + "32.0.2", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-4chh-6mhf-p4jj", + "package": "Server", + "severity": "medium", + "range": ">= 31.0.0, >= 32.0.0, >= 33.0.0", + "patched": [ + "31.0.14.4", + "32.0.2", + "33.0.1" + ] + }, + { + "ghsa": "GHSA-4gm7-j7wg-m4fx", + "package": "Server", + "severity": "low", + "range": "< 23.0.11, < 24.0.7, < 25.0.0", + "patched": [ + "23.0.11", + "24.0.7", + "25.0.0" + ] + }, + { + "ghsa": "GHSA-4gm7-j7wg-m4fx", + "package": "Server", + "severity": "low", + "range": "< 22.2.11, < 23.0.11, < 24.0.7, < 25.0.0", + "patched": [ + "22.2.11", + "23.0.11", + "24.0.7", + "25.0.0" + ] + }, + { + "ghsa": "GHSA-539w-xvpg-wj29", + "package": "Server", + "severity": "high", + "range": "< 21.0.7, < 22.2.3, < 23.0.0", + "patched": [ + "21.0.7", + "22.2.3", + "23.0.0" + ] + }, + { + "ghsa": "GHSA-53q2-cm29-7j83", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0", + "patched": [ + "25.0.3" + ] + }, + { + "ghsa": "GHSA-53q2-cm29-7j83", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0", + "patched": [ + "25.0.3" + ] + }, + { + "ghsa": "GHSA-5j2p-q736-hw98", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0", + "patched": [ + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-5j2p-q736-hw98", + "package": "Server", + "severity": "medium", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "23.0.12.13", + "24.0.12.9", + "25.0.13.4", + "26.0.9", + "27.1.4" + ] + }, + { + "ghsa": "GHSA-5m5g-hw8c-2236", + "package": "Server", + "severity": "medium", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-5m5g-hw8c-2236", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "24.0.12.15", + "25.0.13.10", + "26.0.13.4", + "27.1.10", + "28.0.6", + "29.0.1" + ] + }, + { + "ghsa": "GHSA-5mq8-738w-5942", + "package": "Server", + "severity": "low", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "26.0.12", + "27.1.7", + "28.0.3" + ] + }, + { + "ghsa": "GHSA-5mq8-738w-5942", + "package": "Server", + "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "23.0.12.16", + "24.0.12.12", + "25.0.13.6", + "26.0.12", + "27.1.7", + "28.0.3" + ] + }, + { + "ghsa": "GHSA-5vw6-6prg-gvw6", + "package": "Server", + "severity": "low", + "range": "< 21.0.8 , < 22.2.4, < 23.0.1", + "patched": [ + "21.0.8", + "22.2.4", + "23.0.1" + ] + }, + { + "ghsa": "GHSA-5w64-6c42-rgcv", + "package": "Server", + "severity": "low", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-5w64-6c42-rgcv", + "package": "Server", + "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "23.0.14", + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-637g-xp2c-qh5h", + "package": "Server", + "severity": "high", + "range": ">= 25.0.0, >= 26.0.0", + "patched": [ + "25.0.7", + "26.0.2" + ] + }, + { + "ghsa": "GHSA-637g-xp2c-qh5h", + "package": "Server", + "severity": "high", + "range": ">= 19.0.0 >= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", + "patched": [ + "19.0.13.9", + "20.0.14.14", + "21.0.9.12", + "22.2.10.12", + "23.0.12.7", + "24.0.12.2", + "25.0.7", + "26.0.2" + ] + }, + { + "ghsa": "GHSA-6w9f-jgjx-4vj6", + "package": "Server", + "severity": "low", + "range": "< 22.2.10, < 23.0.7, < 24.0.3", + "patched": [ + "22.2.10", + "23.0.7", + "24.0.3" + ] + }, + { + "ghsa": "GHSA-6w9f-jgjx-4vj6", + "package": "Server", + "severity": "low", + "range": "< 22.2.10, < 23.0.7, < 24.0.3", + "patched": [ + "22.2.10", + "23.0.7", + "24.0.3" + ] + }, + { + "ghsa": "GHSA-7cwm-qph5-4h5w", + "package": "Server", + "severity": "medium", + "range": "< 22.2.7, < 23.0.4", + "patched": [ + "22.2.7", + "23.0.4" + ] + }, + { + "ghsa": "GHSA-7hvh-rc6f-px23", + "package": "Server", + "severity": "high", + "range": "< 20.0.13, < 21.0.5 , < 22.2.0", + "patched": [ + "20.0.13", + "21.0.5", + "22.2.0" + ] + }, + { + "ghsa": "GHSA-7w2p-rp9m-9xp9", + "package": "Server", + "severity": "low", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-7w2p-rp9m-9xp9", + "package": "Server", + "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "23.0.14", + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-7w6h-5qgw-4j94", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.4, >= 25.0.0", + "patched": [ + "24.0.7", + "25.0.1" + ] + }, + { + "ghsa": "GHSA-7w6h-5qgw-4j94", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.4, >= 25.0.0", + "patched": [ + "24.0.7", + "25.0.1" + ] + }, + { + "ghsa": "GHSA-8f3p-rcm5-mrg3", + "package": "Server", + "severity": "low", + "range": "< 23.0.9, < 24.0.5", + "patched": [ + "23.0.9", + "24.0.5" + ] + }, + { + "ghsa": "GHSA-8f3p-rcm5-mrg3", + "package": "Server", + "severity": "low", + "range": "< 23.0.9, < 24.0.5", + "patched": [ + "23.0.9", + "24.0.5" + ] + }, + { + "ghsa": "GHSA-8f69-f9jg-4x3v", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8f69-f9jg-4x3v", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.16", + "23.0.12.11", + "24.0.12.7", + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8jwv-c8c8-9fr3", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8jwv-c8c8-9fr3", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.16", + "23.0.12.11", + "24.0.12.7", + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-8v5c-f752-fgpv", + "package": "Server", + "severity": "low", + "range": ">= 24.0.0", + "patched": [ + "24.0.9" + ] + }, + { + "ghsa": "GHSA-8v5c-f752-fgpv", + "package": "Server", + "severity": "low", + "range": ">= 24.0.0", + "patched": [ + "24.0.9" + ] + }, + { + "ghsa": "GHSA-92g2-h5jv-jjmg", + "package": "Server", + "severity": "low", + "range": "25.0.0, 24.0.4 - 24.0.7", + "patched": [ + "25.0.1", + "24.0.8" + ] + }, + { + "ghsa": "GHSA-99gw-ww6p-f2rr", + "package": "Server", + "severity": "high", + "range": ">= 32.0.10, >= 33.0.4, >= 34.0.0", + "patched": [ + "32.0.12", + "33.0.6", + "34.0.1" + ] + }, + { + "ghsa": "GHSA-99gw-ww6p-f2rr", + "package": "Server", + "severity": "high", + "range": ">= 32.0.10, >= 33.0.4", + "patched": [ + "32.0.12", + "33.0.6" + ] + }, + { + "ghsa": "GHSA-9h3w-f3h4-qqrh", + "package": "Server", + "severity": "medium", + "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "29.0.15", + "30.0.9", + "31.0.3" + ] + }, + { + "ghsa": "GHSA-9h3w-f3h4-qqrh", + "package": "Server", + "severity": "medium", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "patched": [ + "26.0.13.15", + "27.1.11.15", + "28.0.14.6", + "29.0.15", + "30.0.9", + "31.0.3" + ] + }, + { + "ghsa": "GHSA-9mh6-cph8-772c", + "package": "Server", + "severity": "low", + "range": "< 24.0.7, < 25.0.1", + "patched": [ + "24.0.7", + "25.0.1" + ] + }, + { + "ghsa": "GHSA-9mh6-cph8-772c", + "package": "Server", + "severity": "low", + "range": "< 24.0.7, < 25.0.1", + "patched": [ + "24.0.7", + "25.0.1" + ] + }, + { + "ghsa": "GHSA-9qvg-7fwg-722x", + "package": "Server", + "severity": "low", + "range": "< 22.2.7, < 23.0.4", + "patched": [ + "22.2.7", + "23.0.4", + "24.0.0" + ] + }, + { + "ghsa": "GHSA-9v72-9xv5-3p7c", + "package": "Server", + "severity": "high", + "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "26.0.13", + "27.1.8", + "28.0.4" + ] + }, + { + "ghsa": "GHSA-9v72-9xv5-3p7c", + "package": "Server", + "severity": "high", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "patched": [ + "21.0.9.17", + "22.2.10.22", + "23.0.12.17", + "24.0.12.13", + "25.0.13.8", + "26.0.13", + "27.1.8", + "28.0.4" + ] + }, + { + "ghsa": "GHSA-9wmj-gp8v-477j", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0", + "patched": [ + "25.0.3" + ] + }, + { + "ghsa": "GHSA-c7vq-m7f8-rx37", + "package": "Server", + "severity": "medium", + "range": "28.0.13, 29.0.10, 30.0.3", + "patched": [ + ">= 28.0.0", + ">= 29.0.0", + ">= 30.0.0" + ] + }, + { + "ghsa": "GHSA-c7vq-m7f8-rx37", + "package": "Server", + "severity": "medium", + "range": "28.0.13, 29.0.10, 30.0.3", + "patched": [ + ">= 28.0.0", + ">= 29.0.0", + ">= 30.0.0" + ] + }, + { + "ghsa": "GHSA-ch7f-px7m-hg25", + "package": "Server", + "severity": "low", + "range": ">= 24.0.0, >= 25.0.0", "patched": [ - "26.0.2" + "24.0.10", + "25.0.4" ] }, { - "ghsa": "GHSA-2448-44rp-c7hh", + "ghsa": "GHSA-ch7f-px7m-hg25", "package": "Server", "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "23.0.14", + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-cq8w-v4fh-4rjq", + "package": "Server", + "severity": "medium", "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "25.0.8", + "26.0.3", + "27.0.1" ] }, { - "ghsa": "GHSA-2448-44rp-c7hh", + "ghsa": "GHSA-cq8w-v4fh-4rjq", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.13", + "23.0.12.8", + "24.0.12.4", + "25.0.8", + "26.0.3", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-f4h6-pjhm-ph2h", + "package": "Server", + "severity": "low", + "range": "< 23.0.10, < 24.0.6", + "patched": [ + "23.0.10", + "24.0.6" + ] + }, + { + "ghsa": "GHSA-f4h6-pjhm-ph2h", "package": "Server", "severity": "low", + "range": "< 23.0.10, < 24.0.6", + "patched": [ + "23.0.10", + "24.0.6" + ] + }, + { + "ghsa": "GHSA-f962-hw26-g267", + "package": "Server", + "severity": "high", "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "25.0.13", + "26.0.8", + "27.1.3" ] }, { - "ghsa": "GHSA-24wp-p865-7j4r", + "ghsa": "GHSA-f962-hw26-g267", "package": "Server", - "severity": "medium", - "range": ">=30.0.0, >= 31.0.0, >= 32.0.0", + "severity": "high", + "range": ">= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "30.0.17", - "31.0.10", - "32.0.1" + "20.0.14.16", + "21.0.9.13", + "22.2.10.15", + "23.0.12.12", + "24.0.12.8", + "25.0.13", + "26.0.8", + "27.1.3" ] }, { - "ghsa": "GHSA-24wp-p865-7j4r", + "ghsa": "GHSA-fj39-4qx4-m3f2", + "package": "Server", + "severity": "high", + "range": "< 20.0.13, < 21.0.5 , < 22.2.0", + "patched": [ + "20.0.13", + "21.0.5", + "22.2.0" + ] + }, + { + "ghsa": "GHSA-fvpc-8hq6-jgq2", + "package": "Server", + "severity": "low", + "range": ">= 28.0.0, >= 29.0.0", + "patched": [ + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-fvpc-8hq6-jgq2", + "package": "Server", + "severity": "low", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "patched": [ + "27.1.11.8", + "28.0.10", + "29.0.7" + ] + }, + { + "ghsa": "GHSA-g36v-67gv-h757", "package": "Server", "severity": "medium", - "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "range": "< 20.0.13, < 21.0.5 , < 22.2.0", "patched": [ - "22.2.10.33", - "23.0.12.29", - "24.0.12.28", - "25.0.13.23", - "26.0.13.20", - "27.1.11.20", - "28.0.14.11", - "29.0.16.8", - "30.0.17", - "31.0.10", - "32.0.1" + "20.0.13", + "21.0.5", + "22.2.0" ] }, { - "ghsa": "GHSA-285v-p9x9-cjhj", + "ghsa": "GHSA-g722-cm3h-8wrx", + "package": "Server", + "severity": "low", + "range": "< 20.0.14, < 21.0.6 , < 22.2.1", + "patched": [ + "20.0.14", + "21.0.6", + "22.2.1" + ] + }, + { + "ghsa": "GHSA-g8pr-g25r-58xj", "package": "Server", "severity": "medium", - "range": ">= 31.0.0, >= 32.0.0", + "range": ">= 27.0.0, >= 28.0.0", "patched": [ - "31.0.12", - "32.0.3" + "27.1.9", + "28.0.5", + "29.0.0" ] }, { - "ghsa": "GHSA-285v-p9x9-cjhj", + "ghsa": "GHSA-g8pr-g25r-58xj", "package": "Server", "severity": "medium", - "range": "< 21.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", "patched": [ - "21.0.9.20", - "22.2.10.35", - "23.0.12.31", - "24.0.12.30", - "25.0.13.25", - "26.0.13.22", - "27.1.11.22", - "28.0.14.13", - "29.0.16.10", - "30.0.17.5", - "31.0.12", - "32.0.3" + "21.0.9.18", + "22.2.10.23", + "23.0.12.18", + "24.0.12.14", + "25.0.13.9", + "26.0.13.3", + "27.1.9", + "28.0.5", + "29.0.0" ] }, { - "ghsa": "GHSA-2q6f-gjgj-7hp4", + "ghsa": "GHSA-g97r-8ffm-hfpj", "package": "Server", "severity": "low", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "28.0.10", - "29.0.7", - "30.0.0" + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-2q6f-gjgj-7hp4", + "ghsa": "GHSA-g97r-8ffm-hfpj", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.9", + "26.0.4", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-gxph-5m4j-pfmj", "package": "Server", "severity": "low", "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", "patched": [ - "28.0.10", - "29.0.7", - "30.0.0" + "28.0.11", + "29.0.8", + "30.0.1" ] }, { - "ghsa": "GHSA-35fx-69q6-xpjr", + "ghsa": "GHSA-gxph-5m4j-pfmj", + "package": "Server", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "patched": [ + "25.0.13.13", + "26.0.13.9", + "27.1.11.9", + "28.0.11", + "29.0.8", + "30.0.1" + ] + }, + { + "ghsa": "GHSA-h353-vvwv-j2r4", "package": "Server", "severity": "medium", - "range": ">=32.0.0, >=33.0.0", + "range": ">= 26.0.0", "patched": [ - "32.0.9", - "33.0.3" + "26.0.2" ] }, { - "ghsa": "GHSA-35fx-69q6-xpjr", + "ghsa": "GHSA-h353-vvwv-j2r4", "package": "Server", "severity": "medium", - "range": ">= 27.0.0, >=28.0.0, >=29.0.0, >=30.0.0, >=31.0.0, >=32.0.0, >=33.0.0", + "range": ">= 26.0.0", "patched": [ - "27.1.11.5", - "28.0.14.17", - "29.0.16.16", - "30.0.17.9", - "31.0.14.5", - "32.0.9", - "33.0.3" + "26.0.2" ] }, { - "ghsa": "GHSA-35gc-jc6x-29cm", + "ghsa": "GHSA-h3c9-cmh8-7qpj", + "package": "Server", + "severity": "critical", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-h3c9-cmh8-7qpj", + "package": "Server", + "severity": "critical", + "range": ">= 18.0.0, >= 19.0.0, >= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0", + "patched": [ + "20.0.14.12", + "21.0.9.10", + "22.2.10.10", + "23.0.12.5", + "24.0.10", + "25.0.4" + ] + }, + { + "ghsa": "GHSA-h4xv-cjpm-j595", "package": "Server", "severity": "low", - "range": ">= 28.0.0, >= 29.0.0", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", "patched": [ - "28.0.9", - "29.0.5" + "27.1.10", + "28.0.6", + "29.0.1" ] }, { - "ghsa": "GHSA-35gc-jc6x-29cm", + "ghsa": "GHSA-h4xv-cjpm-j595", "package": "Server", "severity": "low", - "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", "patched": [ - "26.0.13.9", - "27.1.11.9", - "28.0.9", - "29.0.5" + "27.1.10", + "28.0.6", + "29.0.1" ] }, { - "ghsa": "GHSA-35p6-4992-w5fr", + "ghsa": "GHSA-h7f7-535f-7q87", + "package": "Server", + "severity": "high", + "range": ">= 25.0.0, >= 26.0.0", + "patched": [ + "25.0.7", + "26.0.2" + ] + }, + { + "ghsa": "GHSA-h7f7-535f-7q87", + "package": "Server", + "severity": "high", + "range": ">= 16.0.0, >= 17.0.0, >= 18.0.0, >= 19.0.0 >= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", + "patched": [ + "19.0.13.9", + "20.0.14.14", + "21.0.9.12", + "22.2.10.12", + "23.0.12.7", + "24.0.12.2", + "25.0.7", + "26.0.2" + ] + }, + { + "ghsa": "GHSA-hhgv-jcg9-p4m9", "package": "Server", "severity": "medium", "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "25.0.8", + "26.0.3", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-hhgv-jcg9-p4m9", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.8", + "26.0.3", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-hhq4-4pr8-wm27", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.9", + "25.0.3" + ] + }, + { + "ghsa": "GHSA-hhq4-4pr8-wm27", + "package": "Server", + "severity": "medium", + "range": ">= 24.0.0, >= 25.0.0", + "patched": [ + "24.0.9", + "25.0.3" + ] + }, + { + "ghsa": "GHSA-hq6c-r898-fgf2", + "package": "Server", + "severity": "medium", + "range": "31.0.0", + "patched": [ + "31.0.1" + ] + }, + { + "ghsa": "GHSA-hq6c-r898-fgf2", + "package": "Server", + "severity": "medium", + "range": "31.0.0", + "patched": [ + "31.0.1" ] }, { - "ghsa": "GHSA-35p6-4992-w5fr", + "ghsa": "GHSA-hrrv-mp25-26vv", "package": "Server", - "severity": "medium", - "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "severity": "high", + "range": ">= 33.0.0, >= 32.0.0", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "33.0.3", + "32.0.9" ] }, { - "ghsa": "GHSA-3f8p-6qww-2prr", + "ghsa": "GHSA-hrrv-mp25-26vv", "package": "Server", - "severity": "medium", - "range": ">= 26.0.0, >= 27.0.0", + "severity": "high", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0, >= 27.0.0, >= 26.0.0, >= 25.0.0, >= 24.0.0, >= 23.0.0, >= 22.0.0, >= 21.0.0", "patched": [ - "26.0.9", - "27.1.4" + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16", + "28.0.14.17", + "27.1.11.26", + "26.0.13.26", + "25.0.13.29", + "24.0.12.34", + "23.0.12.35", + "22.2.10.39", + "21.0.9.23" ] }, { - "ghsa": "GHSA-3f8p-6qww-2prr", + "ghsa": "GHSA-j33j-qph5-4wch", "package": "Server", "severity": "medium", - "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "range": ">= 32.0.0, >= 31.0.0", "patched": [ - "23.0.12.13", - "24.0.12.9", - "25.0.13.4", - "26.0.9", - "27.1.4" + "32.0.4", + "31.0.14" ] }, { - "ghsa": "GHSA-42w6-r45m-9w9j", + "ghsa": "GHSA-j33j-qph5-4wch", "package": "Server", "severity": "medium", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "range": ">= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0", "patched": [ - "28.0.12", - "29.0.9", - "30.0.2" + "32.0.4", + "31.0.14", + "30.0.17.7", + "29.0.17.12", + "28.0.14.15" ] }, { - "ghsa": "GHSA-42w6-r45m-9w9j", + "ghsa": "GHSA-j4qm-5q5x-54m5", "package": "Server", - "severity": "medium", - "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "severity": "high", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "25.0.13.14", - "26.0.13.10", - "27.1.11.10", - "28.0.12", - "29.0.9", - "30.0.2" + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-45pj-p7x7-4mhc", + "ghsa": "GHSA-j4qm-5q5x-54m5", "package": "Server", - "severity": "medium", - "range": ">= 32.0.0, >= 33.0.0", + "severity": "high", + "range": ">= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "32.0.9", - "33.0.3" + "20.0.14.15", + "21.0.9.13", + "22.2.10.14", + "23.0.12.9", + "24.0.12.5", + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-45pj-p7x7-4mhc", + "ghsa": "GHSA-jf3h-xf4q-mh89", "package": "Server", - "severity": "medium", - "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0, >= 32.0.0, >= 33.0.0", + "severity": "low", + "range": "< 21.0.8 , < 22.2.4, < 23.0.1", "patched": [ - "26.0.13.26", - "27.1.11.25", - "28.0.14.17", - "29.0.16.16", - "30.0.17.9", - "31.0.14.5", - "32.0.9", - "33.0.3" + "21.0.8", + "22.2.4", + "23.0.1" ] }, { - "ghsa": "GHSA-495w-cqv6-wr59", + "ghsa": "GHSA-jgcj-v42r-9922", "package": "Server", "severity": "medium", - "range": ">= 31.0.0, >= 32.0.0", + "range": ">= 33.0.0, >= 32.0.0", "patched": [ - "31.0.10", - "32.0.1" + "33.0.3", + "32.0.9" ] }, { - "ghsa": "GHSA-495w-cqv6-wr59", + "ghsa": "GHSA-jgcj-v42r-9922", "package": "Server", "severity": "medium", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", "patched": [ - "28.0.14.11", - "29.0.16.8", - "30.0.17.3", - "31.0.10" + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16" ] }, { - "ghsa": "GHSA-4chh-6mhf-p4jj", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 32.0.0, >= 33.0.0", + "range": ">= 26.0.0", "patched": [ - "32.0.2", - "33.0.1" + "26.0.13" ] }, { - "ghsa": "GHSA-4chh-6mhf-p4jj", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 31.0.0, >= 32.0.0, >= 33.0.0", + "range": ">= 27.0.0", "patched": [ - "31.0.14.4", - "32.0.2", - "33.0.1" + "27.1.8" ] }, { - "ghsa": "GHSA-5j2p-q736-hw98", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 26.0.0, >= 27.0.0", + "range": ">= 28.0.0", "patched": [ - "26.0.9", - "27.1.4" + "28.0.4" ] }, { - "ghsa": "GHSA-5j2p-q736-hw98", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "range": ">= 23.0.0", "patched": [ - "23.0.12.13", - "24.0.12.9", - "25.0.13.4", - "26.0.9", - "27.1.4" + "23.0.12.17" ] }, { - "ghsa": "GHSA-5m5g-hw8c-2236", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "range": ">= 24.0.0", "patched": [ - "27.1.10", - "28.0.6", - "29.0.1" + "24.0.12.13" ] }, { - "ghsa": "GHSA-5m5g-hw8c-2236", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0", + "range": ">= 25.0.0", "patched": [ - "24.0.12.15", - "25.0.13.10", - "26.0.13.4", - "27.1.10", - "28.0.6", - "29.0.1" + "25.0.13.8" ] }, { - "ghsa": "GHSA-5mq8-738w-5942", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", - "severity": "low", - "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "severity": "medium", + "range": ">= 26.0.0", "patched": [ - "26.0.12", - "27.1.7", - "28.0.3" + "26.0.13" ] }, { - "ghsa": "GHSA-5mq8-738w-5942", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", - "severity": "low", - "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "severity": "medium", + "range": ">= 27.0.0", "patched": [ - "23.0.12.16", - "24.0.12.12", - "25.0.13.6", - "26.0.12", - "27.1.7", - "28.0.3" + "27.1.8" ] }, { - "ghsa": "GHSA-8f69-f9jg-4x3v", + "ghsa": "GHSA-jjm3-j9xh-5xmq", "package": "Server", "severity": "medium", - "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "range": ">= 28.0.0", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "28.0.4" ] }, { - "ghsa": "GHSA-8f69-f9jg-4x3v", + "ghsa": "GHSA-jp9c-vpr3-m5rf", "package": "Server", - "severity": "medium", - "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "severity": "high", + "range": "< 20.0.13, < 21.0.5 , < 22.2.0", "patched": [ - "22.2.10.16", - "23.0.12.11", - "24.0.12.7", - "25.0.11", - "26.0.6", - "27.1.0" + "20.0.13", + "21.0.5", + "22.2.0" ] }, { - "ghsa": "GHSA-8jwv-c8c8-9fr3", + "ghsa": "GHSA-m4wp-r357-4q94", "package": "Server", "severity": "medium", - "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "range": "< 20.0.14, < 21.0.6 , < 22.2.1", "patched": [ - "25.0.11", - "26.0.6", - "27.1.0" + "20.0.14", + "21.0.6", + "22.2.1" ] }, { - "ghsa": "GHSA-8jwv-c8c8-9fr3", + "ghsa": "GHSA-m92j-xxc8-hq3v", "package": "Server", - "severity": "medium", - "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "severity": "low", + "range": "< 23.0.10, < 24.0.5", "patched": [ - "22.2.10.16", - "23.0.12.11", - "24.0.12.7", - "25.0.11", - "26.0.6", - "27.1.0" + "23.0.10", + "24.0.5" ] }, { - "ghsa": "GHSA-99gw-ww6p-f2rr", + "ghsa": "GHSA-m92j-xxc8-hq3v", "package": "Server", - "severity": "high", - "range": ">= 32.0.10, >= 33.0.4, >= 34.0.0", + "severity": "low", + "range": "< 23.0.10, < 24.0.5", "patched": [ - "32.0.12", - "33.0.6", - "34.0.1" + "23.0.10", + "24.0.5" ] }, { - "ghsa": "GHSA-99gw-ww6p-f2rr", + "ghsa": "GHSA-mjf5-p765-qmr6", "package": "Server", "severity": "high", - "range": ">= 32.0.10, >= 33.0.4", + "range": ">= 25.0.0, >= 26.0.0", "patched": [ - "32.0.12", - "33.0.6" + "25.0.7", + "26.0.2" ] }, { - "ghsa": "GHSA-9h3w-f3h4-qqrh", + "ghsa": "GHSA-mjf5-p765-qmr6", "package": "Server", - "severity": "medium", - "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", + "severity": "high", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", "patched": [ - "29.0.15", - "30.0.9", - "31.0.3" + "21.0.9.12", + "22.2.10.12", + "23.0.12.7", + "24.0.12.2", + "25.0.7", + "26.0.2" ] }, { - "ghsa": "GHSA-9h3w-f3h4-qqrh", + "ghsa": "GHSA-mp6x-g55j-w9jw", "package": "Server", "severity": "medium", - "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0, >= 31.0.0", + "range": ">= 33.0.0, >= 32.0.0", "patched": [ - "26.0.13.15", - "27.1.11.15", - "28.0.14.6", - "29.0.15", - "30.0.9", - "31.0.3" + "33.0.3", + "32.0.9" ] }, { - "ghsa": "GHSA-9v72-9xv5-3p7c", + "ghsa": "GHSA-mp6x-g55j-w9jw", "package": "Server", - "severity": "high", - "range": ">= 26.0.0, >= 27.0.0, >= 28.0.0", + "severity": "medium", + "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", "patched": [ - "26.0.13", - "27.1.8", - "28.0.4" + "33.0.3", + "32.0.9", + "31.0.14.5", + "30.0.17.9", + "29.0.16.16" ] }, { - "ghsa": "GHSA-9v72-9xv5-3p7c", + "ghsa": "GHSA-mqrx-grp7-244m", "package": "Server", - "severity": "high", - "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "severity": "medium", + "range": "< 24.0.8, < 23.0.12", "patched": [ - "21.0.9.17", - "22.2.10.22", - "23.0.12.17", - "24.0.12.13", - "25.0.13.8", - "26.0.13", - "27.1.8", - "28.0.4" + "24.0.8", + "23.0.12" ] }, { - "ghsa": "GHSA-c7vq-m7f8-rx37", + "ghsa": "GHSA-mr7q-xf62-fw54", "package": "Server", - "severity": "medium", - "range": "28.0.13, 29.0.10, 30.0.3", + "severity": "high", + "range": ">= 24.0.0, >= 25.0.0", "patched": [ - ">= 28.0.0", - ">= 29.0.0", - ">= 30.0.0" + "24.0.11", + "25.0.5", + "26.0.0" ] }, { - "ghsa": "GHSA-c7vq-m7f8-rx37", + "ghsa": "GHSA-mr7q-xf62-fw54", "package": "Server", - "severity": "medium", - "range": "28.0.13, 29.0.10, 30.0.3", + "severity": "high", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0", "patched": [ - ">= 28.0.0", - ">= 29.0.0", - ">= 30.0.0" + "23.0.12.6", + "24.0.12", + "25.0.5", + "26.0.0" ] }, { - "ghsa": "GHSA-fvpc-8hq6-jgq2", + "ghsa": "GHSA-p7g9-x25m-4h87", "package": "Server", "severity": "low", - "range": ">= 28.0.0, >= 29.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "28.0.10", - "29.0.7" + "25.0.13", + "26.0.8", + "27.1.3" ] }, { - "ghsa": "GHSA-fvpc-8hq6-jgq2", + "ghsa": "GHSA-p7g9-x25m-4h87", "package": "Server", "severity": "low", - "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "27.1.11.8", - "28.0.10", - "29.0.7" + "25.0.13", + "26.0.8", + "27.1.3" ] }, { - "ghsa": "GHSA-g8pr-g25r-58xj", + "ghsa": "GHSA-pwjv-h37v-c4fx", "package": "Server", - "severity": "medium", - "range": ">= 27.0.0, >= 28.0.0", + "severity": "low", + "range": "< 22.2.6, < 23.0.3", "patched": [ - "27.1.9", - "28.0.5", - "29.0.0" + "22.2.6", + "23.0.3" ] }, { - "ghsa": "GHSA-g8pr-g25r-58xj", + "ghsa": "GHSA-pxqf-cfxw-mqmj", "package": "Server", "severity": "medium", - "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0", + "range": ">= 28.0.0, >= 29.0.0", "patched": [ - "21.0.9.18", - "22.2.10.23", - "23.0.12.18", - "24.0.12.14", - "25.0.13.9", - "26.0.13.3", - "27.1.9", - "28.0.5", - "29.0.0" + "28.0.10", + "29.0.7" ] }, { - "ghsa": "GHSA-gxph-5m4j-pfmj", + "ghsa": "GHSA-pxqf-cfxw-mqmj", "package": "Server", - "severity": "low", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "severity": "medium", + "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", "patched": [ - "28.0.11", - "29.0.8", - "30.0.1" + "27.1.11.8", + "28.0.10", + "29.0.7" ] }, { - "ghsa": "GHSA-gxph-5m4j-pfmj", + "ghsa": "GHSA-q568-2933-gcjq", "package": "Server", "severity": "low", - "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0, >= 28.0.0, >= 29.0.0, >= 30.0.0", + "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", "patched": [ - "25.0.13.13", - "26.0.13.9", - "27.1.11.9", - "28.0.11", - "29.0.8", - "30.0.1" + "29.0.13", + "30.0.7", + "31.0.1" ] }, { - "ghsa": "GHSA-h4xv-cjpm-j595", + "ghsa": "GHSA-q8c4-chpj-6v38", "package": "Server", - "severity": "low", - "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "severity": "high", + "range": ">= 25.0.2, >= 26.0.0", "patched": [ - "27.1.10", - "28.0.6", - "29.0.1" + "25.0.6", + "26.0.1" ] }, { - "ghsa": "GHSA-h4xv-cjpm-j595", + "ghsa": "GHSA-q8c4-chpj-6v38", "package": "Server", - "severity": "low", - "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "severity": "high", + "range": ">= 25.0.2, >= 26.0.0", "patched": [ - "27.1.10", - "28.0.6", - "29.0.1" + "25.0.6", + "26.0.1" ] }, { - "ghsa": "GHSA-hq6c-r898-fgf2", + "ghsa": "GHSA-qcw2-p26m-9gc5", "package": "Server", "severity": "medium", - "range": "31.0.0", + "range": ">= 31.0.0, >= 32.0.0", "patched": [ - "31.0.1" + "31.0.12", + "32.0.3" ] }, { - "ghsa": "GHSA-hq6c-r898-fgf2", + "ghsa": "GHSA-qcw2-p26m-9gc5", "package": "Server", "severity": "medium", - "range": "31.0.0", + "range": ">= 31.0.0, >= 32.0.0", "patched": [ - "31.0.1" + "31.0.12", + "32.0.3" ] }, { - "ghsa": "GHSA-hrrv-mp25-26vv", + "ghsa": "GHSA-qhgm-w4gx-gvgp", "package": "Server", - "severity": "high", - "range": ">= 33.0.0, >= 32.0.0", + "severity": "low", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "33.0.3", - "32.0.9" + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-hrrv-mp25-26vv", + "ghsa": "GHSA-qhgm-w4gx-gvgp", "package": "Server", - "severity": "high", - "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0, >= 27.0.0, >= 26.0.0, >= 25.0.0, >= 24.0.0, >= 23.0.0, >= 22.0.0, >= 21.0.0", + "severity": "low", + "range": ">= 24.0.4, >= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "33.0.3", - "32.0.9", - "31.0.14.5", - "30.0.17.9", - "29.0.16.16", - "28.0.14.17", - "27.1.11.26", - "26.0.13.26", - "25.0.13.29", - "24.0.12.34", - "23.0.12.35", - "22.2.10.39", - "21.0.9.23" + "24.0.12.5", + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-j33j-qph5-4wch", + "ghsa": "GHSA-qpf5-jj85-36h5", "package": "Server", "severity": "medium", - "range": ">= 32.0.0, >= 31.0.0", + "range": "< 23.0.9, < 24.0.5", "patched": [ - "32.0.4", - "31.0.14" + "23.0.9", + "24.0.5" ] }, { - "ghsa": "GHSA-j33j-qph5-4wch", + "ghsa": "GHSA-qpf5-jj85-36h5", "package": "Server", "severity": "medium", - "range": ">= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0, >= 28.0.0", + "range": "< 22.2.10.5, < 23.0.9, < 24.0.5", "patched": [ - "32.0.4", - "31.0.14", - "30.0.17.7", - "29.0.17.12", - "28.0.14.15" + "22.2.10.5", + "23.0.9", + "24.0.5" ] }, { - "ghsa": "GHSA-jgcj-v42r-9922", + "ghsa": "GHSA-qphh-6xh7-vffg", "package": "Server", - "severity": "medium", - "range": ">= 33.0.0, >= 32.0.0", + "severity": "high", + "range": ">= 25.0.0, >= 26.0.0", "patched": [ - "33.0.3", - "32.0.9" + "25.0.7", + "26.0.2" ] }, { - "ghsa": "GHSA-jgcj-v42r-9922", + "ghsa": "GHSA-qphh-6xh7-vffg", "package": "Server", - "severity": "medium", - "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", + "severity": "high", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", "patched": [ - "33.0.3", - "32.0.9", - "31.0.14.5", - "30.0.17.9", - "29.0.16.16" + "21.0.9.12", + "22.2.10.12", + "23.0.12.7", + "24.0.12.2", + "25.0.7", + "26.0.2" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-qqgg-hhfq-vhww", "package": "Server", "severity": "medium", - "range": ">= 26.0.0", + "range": ">= 30.0.0, >= 29.0.0", "patched": [ - "26.0.13" + "30.0.2", + "29.0.9" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-r3xh-x86g-hw4m", "package": "Server", "severity": "medium", - "range": ">= 27.0.0", + "range": ">= 32.0.0, >= 33.0.0", "patched": [ - "27.1.8" + "32.0.9", + "33.0.3" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-r5wf-xj97-3w7w", "package": "Server", - "severity": "medium", - "range": ">= 28.0.0", + "severity": "low", + "range": ">= 24.0.0, >= 25.0.0", "patched": [ - "28.0.4" + "24.0.11", + "25.0.5" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-r5wf-xj97-3w7w", "package": "Server", - "severity": "medium", - "range": ">= 23.0.0", + "severity": "low", + "range": ">= 23.0.0, >= 24.0.0, >= 25.0.0", "patched": [ - "23.0.12.17" + "23.0.12.6", + "24.0.11", + "25.0.5" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-rmf9-w497-8cq8", "package": "Server", - "severity": "medium", - "range": ">= 24.0.0", + "severity": "low", + "range": "< 23.0.8, < 24.0.4", "patched": [ - "24.0.12.13" + "23.0.8", + "24.0.4" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-rmf9-w497-8cq8", "package": "Server", - "severity": "medium", - "range": ">= 25.0.0", + "severity": "low", + "range": "< 22.2.10.4, < 23.0.8, < 24.0.4", "patched": [ - "25.0.13.8" + "22.2.10.4", + "23.0.8", + "24.0.4" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-v243-x6jc-42mp", "package": "Server", "severity": "medium", - "range": ">= 26.0.0", + "range": ">= 24.0.0, >= 25.0.0", "patched": [ - "26.0.13" + "24.0.10", + "25.0.4" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-v243-x6jc-42mp", "package": "Server", "severity": "medium", - "range": ">= 27.0.0", + "range": ">= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0", "patched": [ - "27.1.8" + "21.0.9.10", + "22.2.10.10", + "23.0.12.5", + "24.0.10", + "25.0.4" ] }, { - "ghsa": "GHSA-jjm3-j9xh-5xmq", + "ghsa": "GHSA-vqgm-f748-g76v", "package": "Server", - "severity": "medium", - "range": ">= 28.0.0", + "severity": "low", + "range": "< 23.0.7, < 24.0.3", "patched": [ - "28.0.4" + "23.0.7", + "24.0.3" ] }, { - "ghsa": "GHSA-mp6x-g55j-w9jw", + "ghsa": "GHSA-vqgm-f748-g76v", "package": "Server", - "severity": "medium", - "range": ">= 33.0.0, >= 32.0.0", + "severity": "low", + "range": "< 22.2.11, < 23.0.7, < 24.0.3", "patched": [ - "33.0.3", - "32.0.9" + "22.2.11", + "23.0.7", + "24.0.3" ] }, { - "ghsa": "GHSA-mp6x-g55j-w9jw", + "ghsa": "GHSA-vrhf-532w-99rg", "package": "Server", "severity": "medium", - "range": ">= 33.0.0, >= 32.0.0, >= 31.0.0, >= 30.0.0, >= 29.0.0", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", "patched": [ - "33.0.3", - "32.0.9", - "31.0.14.5", - "30.0.17.9", - "29.0.16.16" + "28.0.12", + "29.0.9", + "30.0.2" ] }, { - "ghsa": "GHSA-pxqf-cfxw-mqmj", + "ghsa": "GHSA-vrhf-532w-99rg", "package": "Server", "severity": "medium", - "range": ">= 28.0.0, >= 29.0.0", + "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", "patched": [ - "28.0.10", - "29.0.7" + "28.0.12", + "29.0.9", + "30.0.2" ] }, { - "ghsa": "GHSA-pxqf-cfxw-mqmj", + "ghsa": "GHSA-vv27-g2hq-v48h", "package": "Server", "severity": "medium", - "range": ">= 27.0.0, >= 28.0.0, >= 29.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "27.1.11.8", - "28.0.10", - "29.0.7" + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-q568-2933-gcjq", + "ghsa": "GHSA-vv27-g2hq-v48h", "package": "Server", - "severity": "low", - "range": ">= 29.0.0, >= 30.0.0, >= 31.0.0", - "patched": [ - "29.0.13", - "30.0.7", - "31.0.1" + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.14", + "23.0.12.9", + "24.0.12.5", + "25.0.9", + "26.0.4", + "27.0.1" ] }, { - "ghsa": "GHSA-qcw2-p26m-9gc5", + "ghsa": "GHSA-vxcm-g5v4-637f", "package": "Server", - "severity": "medium", - "range": ">= 31.0.0, >= 32.0.0", + "severity": "low", + "range": "< 20.0.13, < 21.0.5 , < 22.2.0", "patched": [ - "31.0.12", - "32.0.3" + "20.0.13", + "21.0.5", + "22.2.0" ] }, { - "ghsa": "GHSA-qcw2-p26m-9gc5", + "ghsa": "GHSA-w3h6-p64h-q9jp", "package": "Server", "severity": "medium", - "range": ">= 31.0.0, >= 32.0.0", + "range": "< 20.0.14.4, < 21.0.8, < 22.2.4, < 23.0.1", "patched": [ - "31.0.12", - "32.0.3" + "20.0.14.4", + "21.0.8", + "22.2.4", + "23.0.1" ] }, { - "ghsa": "GHSA-qqgg-hhfq-vhww", + "ghsa": "GHSA-w47p-f66h-h2vj", "package": "Server", "severity": "medium", - "range": ">= 30.0.0, >= 29.0.0", + "range": ">= 24.0.4, >= 25.0.0", "patched": [ - "30.0.2", - "29.0.9" + "24.0.10", + "25.0.4" ] }, { - "ghsa": "GHSA-r3xh-x86g-hw4m", + "ghsa": "GHSA-w47p-f66h-h2vj", "package": "Server", "severity": "medium", - "range": ">= 32.0.0, >= 33.0.0", + "range": ">= 24.0.4, >= 25.0.0", "patched": [ - "32.0.9", - "33.0.3" + "24.0.10", + "25.0.4" ] }, { - "ghsa": "GHSA-vrhf-532w-99rg", + "ghsa": "GHSA-w7v5-mgxm-v6gm", "package": "Server", - "severity": "medium", + "severity": "low", "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", "patched": [ "28.0.12", @@ -1348,9 +3372,9 @@ ] }, { - "ghsa": "GHSA-vrhf-532w-99rg", + "ghsa": "GHSA-w7v5-mgxm-v6gm", "package": "Server", - "severity": "medium", + "severity": "low", "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", "patched": [ "28.0.12", @@ -1359,25 +3383,25 @@ ] }, { - "ghsa": "GHSA-w7v5-mgxm-v6gm", + "ghsa": "GHSA-wgpw-qqq2-gwv6", "package": "Server", "severity": "low", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "28.0.12", - "29.0.9", - "30.0.2" + "25.0.13", + "26.0.8", + "27.1.3" ] }, { - "ghsa": "GHSA-w7v5-mgxm-v6gm", + "ghsa": "GHSA-wgpw-qqq2-gwv6", "package": "Server", "severity": "low", - "range": ">= 28.0.0, >= 29.0.0, >= 30.0.0", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", "patched": [ - "28.0.12", - "29.0.9", - "30.0.2" + "25.0.13", + "26.0.8", + "27.1.3" ] }, { @@ -1422,6 +3446,27 @@ "31.0.1" ] }, + { + "ghsa": "GHSA-wxx7-w5p4-7x4c", + "package": "Server", + "severity": "medium", + "range": "< 23.0.10, < 24.0.6", + "patched": [ + "23.0.10", + "24.0.6" + ] + }, + { + "ghsa": "GHSA-wxx7-w5p4-7x4c", + "package": "Server", + "severity": "medium", + "range": "< 22.2.10, < 23.0.10, < 24.0.6", + "patched": [ + "22.2.10", + "23.0.10", + "24.0.6" + ] + }, { "ghsa": "GHSA-x9q3-c7f8-3rcg", "package": "Server", @@ -1447,6 +3492,31 @@ "30.0.1" ] }, + { + "ghsa": "GHSA-xmhp-7vr4-hp63", + "package": "Server", + "severity": "medium", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, + { + "ghsa": "GHSA-xmhp-7vr4-hp63", + "package": "Server", + "severity": "medium", + "range": ">= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "22.2.10.16", + "23.0.12.11", + "24.0.12.7", + "25.0.11", + "26.0.6", + "27.1.0" + ] + }, { "ghsa": "GHSA-xpgv-grf9-gm7x", "package": "Server", @@ -1533,6 +3603,88 @@ "28.0.4" ] }, + { + "ghsa": "GHSA-xwxx-2752-w3xm", + "package": "Server", + "severity": "high", + "range": ">= 25.0.0, >= 26.0.0, >= 27.0.0", + "patched": [ + "25.0.9", + "26.0.4", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-xwxx-2752-w3xm", + "package": "Server", + "severity": "high", + "range": ">= 19.0.0 >= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0, >= 26.0.0", + "patched": [ + "19.0.13.10", + "20.0.14.15", + "21.0.9.13", + "22.2.10.14", + "23.0.12.9", + "24.0.12.5", + "25.0.9", + "26.0.4", + "27.0.1" + ] + }, + { + "ghsa": "GHSA-273v-9h7x-p68v", + "package": "Server (Enterprise)", + "severity": "medium", + "range": "<25.0.2, < 24.0.8, < 23.0.12, < 22.2.10, < 21.0.9, < 20.0.14", + "patched": [ + "25.0.2", + "24.0.8", + "23.0.12", + "22.2.10.10", + "21.0.9.10", + "20.0.14.12" + ] + }, + { + "ghsa": "GHSA-492h-596q-xr2f", + "package": "Server (Enterprise)", + "severity": "low", + "range": "< 25.0.1, < 24.0.8, < 23.0.12", + "patched": [ + "25.0.1", + "24.0.8", + "23.0.12" + ] + }, + { + "ghsa": "GHSA-92g2-h5jv-jjmg", + "package": "Server (Enterprise)", + "severity": "low", + "range": "25.0.0, 24.0.4 - 24.0.7", + "patched": [ + "25.0.1", + "24.0.8" + ] + }, + { + "ghsa": "GHSA-mqrx-grp7-244m", + "package": "Server (Enterprise)", + "severity": "medium", + "range": "< 24.0.8, < 23.0.12", + "patched": [ + "24.0.8", + "23.0.12" + ] + }, + { + "ghsa": "GHSA-mhpq-rrg8-9gfr", + "package": "Suspicious Login", + "severity": "low", + "range": "< 1.13.9", + "patched": [ + "1.13.9" + ] + }, { "ghsa": "GHSA-2cwj-qp49-4xfw", "package": "Tables", @@ -1626,6 +3778,76 @@ "2.0.0" ] }, + { + "ghsa": "GHSA-3m6r-479j-4chf", + "package": "Talk", + "severity": "low", + "range": ">= 14.0.0, >= 15.0.0", + "patched": [ + "14.0.9", + "15.0.4" + ] + }, + { + "ghsa": "GHSA-4fxr-mrw2-cq92", + "package": "Talk", + "severity": "low", + "range": "< 12.1.2", + "patched": [ + "12.1.2" + ] + }, + { + "ghsa": "GHSA-7rf8-pqmj-rpqv", + "package": "Talk", + "severity": "medium", + "range": ">= 15.0.0, >= 16.0.0, >= 17.0.0", + "patched": [ + "15.0.8", + "16.0.6", + "17.1.1" + ] + }, + { + "ghsa": "GHSA-c9hr-cq65-9mjw", + "package": "Talk", + "severity": "low", + "range": ">= 15.0.0", + "patched": [ + "15.0.5" + ] + }, + { + "ghsa": "GHSA-j45w-7mpq-264c", + "package": "Talk", + "severity": "medium", + "range": "< 11.3.4, < 12.2.2, <13.0.0", + "patched": [ + "11.3.4", + "12.2.2", + "13.0.0" + ] + }, + { + "ghsa": "GHSA-j53p-r755-v4jf", + "package": "Talk", + "severity": "low", + "range": ">= 15.0.0", + "patched": [ + "15.0.3" + ] + }, + { + "ghsa": "GHSA-pf36-jvpv-4hwq", + "package": "Talk", + "severity": "low", + "range": "< 12.2.7, < 13.0.7, < 14.0.3", + "patched": [ + "12.2.7", + "13.0.7", + "14.0.3" + ] + }, { "ghsa": "GHSA-pr9f-vqgg-m2jh", "package": "Talk", @@ -1636,6 +3858,38 @@ "21.1.2" ] }, + { + "ghsa": "GHSA-vxpr-hcqq-7fw7", + "package": "Talk", + "severity": "low", + "range": "< 13.0.5", + "patched": [ + "13.0.5", + "v14.0.0" + ] + }, + { + "ghsa": "GHSA-wq3g-2x46-q2gv", + "package": "Talk", + "severity": "medium", + "range": "< 13.0.8, < 14.0.4", + "patched": [ + "13.0.8", + "14.0.4" + ] + }, + { + "ghsa": "GHSA-wx6w-xpg9-6fv4", + "package": "Talk", + "severity": "medium", + "range": "< 12.2.8, < 13.0.10, < 14.0.6, < 15.0.0", + "patched": [ + "12.2.8", + "13.0.10", + "14.0.6", + "15.0.0" + ] + }, { "ghsa": "GHSA-x75r-65hm-cw35", "package": "Talk", @@ -1647,6 +3901,46 @@ "23.0.3" ] }, + { + "ghsa": "GHSA-xhxq-f4vg-jw5g", + "package": "Talk", + "severity": "low", + "range": "< 10.0.7, < 10.1.4, < 11.1.2, < 11.2.0, < 12.0.0", + "patched": [ + "10.0.7", + "10.1.4", + "11.1.2", + "11.2.0", + "12.0.0" + ] + }, + { + "ghsa": "GHSA-36f7-93f3-mcfj", + "package": "Talk Android", + "severity": "high", + "range": "< 17.0.0", + "patched": [ + "17.0.0" + ] + }, + { + "ghsa": "GHSA-564v-3rfc-352m", + "package": "Talk Android", + "severity": "low", + "range": "< 14.1.0", + "patched": [ + "14.1.0" + ] + }, + { + "ghsa": "GHSA-wvr4-gc4c-6vmx", + "package": "Talk Android", + "severity": "low", + "range": "< 15.0.2", + "patched": [ + "15.0.2" + ] + }, { "ghsa": "GHSA-wx2x-822r-rvmf", "package": "Team Folders", @@ -1660,6 +3954,17 @@ "21.0.4" ] }, + { + "ghsa": "GHSA-26c8-35cm-xq9m", + "package": "Text", + "severity": "high", + "range": "< 20.0.14, < 21.0.6 , < 22.2.1", + "patched": [ + "20.0.14", + "21.0.6", + "22.2.1" + ] + }, { "ghsa": "GHSA-fr8x-mvjg-wf9q", "package": "Twofactor WebAuthn", @@ -1723,6 +4028,81 @@ "6.0.1" ] }, + { + "ghsa": "GHSA-8875-wxww-3rr8", + "package": "iOS", + "severity": "medium", + "range": ">= 3.0.5", + "patched": [ + "4.8.0" + ] + }, + { + "ghsa": "GHSA-wjgg-2v4p-2gq6", + "package": "iOS", + "severity": "medium", + "range": "< 4.7.0", + "patched": [ + "4.7.0" + ] + }, + { + "ghsa": "GHSA-94hr-7g4v-f53r", + "package": "richdocuments", + "severity": "low", + "range": "< 5.0.4, < 4.2.6", + "patched": [ + "6.0.0", + "5.0.4", + "4.2.6" + ] + }, + { + "ghsa": "GHSA-95j6-p5cj-5hh5", + "package": "richdocuments", + "severity": "medium", + "range": ">= 7.0.0, >= 6.0.0", + "patched": [ + "7.0.2", + "6.3.2" + ] + }, + { + "ghsa": "GHSA-2vff-cq8h-chhg", + "package": "user_oidc", + "severity": "low", + "range": "< 1.2.1", + "patched": [ + "1.2.1" + ] + }, + { + "ghsa": "GHSA-3f92-5c8p-f6gq", + "package": "user_oidc", + "severity": "low", + "range": ">= 1.0.0", + "patched": [ + "1.3.3" + ] + }, + { + "ghsa": "GHSA-52hv-xw32-wf7f", + "package": "user_oidc", + "severity": "medium", + "range": ">= 1.0.0", + "patched": [ + "1.3.0" + ] + }, + { + "ghsa": "GHSA-5fpw-795h-rg57", + "package": "user_oidc", + "severity": "low", + "range": "< 1.2.1", + "patched": [ + "1.2.1" + ] + }, { "ghsa": "GHSA-8wjr-5cg8-4w73", "package": "user_oidc", @@ -1744,5 +4124,23 @@ "4.0.0", "5.0.0" ] + }, + { + "ghsa": "GHSA-x8mc-84wj-rf34", + "package": "user_oidc", + "severity": "medium", + "range": "<= 1.3.1", + "patched": [ + "1.3.2" + ] + }, + { + "ghsa": "GHSA-xx3h-v363-q36j", + "package": "user_oidc", + "severity": "medium", + "range": ">= 1.0.0", + "patched": [ + "1.3.3" + ] } ] \ No newline at end of file From 4ac74c6744be0281d5fd6eaa8cc351a3b1121e3c Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 09:27:56 +0200 Subject: [PATCH 3/6] feat(advisories): read the advisories Nextcloud actually publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of three following up #160/#166, and the one that gives the feature real data. Builds on #168 (branch-aware evaluation). THE GAP. The App Store publishes no advisory information at all — measured, garm3.nextcloud.com/api/v1/apps.json returns 755 entries and 31.7 MB with no `securityAdvisories` field and nothing advisory-shaped. So correlation asked 87 of 88 apps a question their source cannot answer and recorded the silence as `error => null`, i.e. indistinguishable from "checked, clean" (#166). The real data is published centrally as GHSA records on nextcloud/security-advisories: 277 advisories, 389 vulnerability entries, 53 distinct packages. WHAT LANDS. NextcloudAdvisoryFeed reads that feed ONCE per sweep and indexes it by target. It follows the Link-header CURSOR rather than `?page=`, because that endpoint ignores the page parameter — pages 1 and 2 return identical bodies — so a page-number loop silently truncates the feed to its first 100 records. That is the same ignored-parameter trap as the App Store's `?filter=`, in a second API. A partial read keeps what it got AND reports the error, because discarding it would turn a feed that failed on page three into "no advisories". AdvisoryPackageMap resolves published package names to app ids. This is not cosmetic: the feed says `Talk` for `spreed`, `Team Folders` for `groupfolders`, and carries BOTH `User OIDC` and `user_oidc` for the same app. Matching normalised ids AND display names resolves 19 of 27 packages from the catalogue alone; indexing installed apps as well is what catches bundled apps like Photos and Flow, which are absent from the App Store catalogue entirely. A name that resolves to nothing is dropped, never guessed — a wrong match attaches a real advisory to the wrong app and leaves the affected one looking clean. AdvisoryService now merges feed advisories into each app's correlation, and routes any record carrying `patchedVersions` through BranchAwareRange. An app whose source has no advisory capability is no longer a dead end: the feed may still cover it, and for App Store apps it is the only thing that does. The server gets its own row, keyed distinctly so nothing mistakes it for an app. It is 95 of the 277 advisories — the largest single subject in the feed. Desktop and mobile client advisories are filtered out: an administrator cannot act on those from here. ServerVersionProvider exists so that row is TESTABLE. OCP\ServerVersion is readonly (PHPUnit cannot double it) and its constructor requires the server's own version.php, so depending on it directly would leave the server path — the largest slice of the feed — with no test at all. NOT TOUCHED, deliberately: three psalm errors in lib/Service/Installer. They appear locally and NOT in CI, because CI resolves OCP types against a real Nextcloud tree while a local run uses the app's stubs. "Fixing" them from local output — by pruning the baseline entries psalm calls unused — would have turned a green CI job red. 545 unit tests, 1099 assertions, no failure outside tests/unit/Command (19 errors there are a missing symfony/console in the local vendor copy). psalm clean on every file this change adds or touches; gate-16 count=0. --- lib/Service/Advisory/AdvisoryPackageMap.php | 184 ++++++++++++ lib/Service/Advisory/AdvisoryService.php | 135 ++++++++- .../Advisory/NextcloudAdvisoryFeed.php | 267 ++++++++++++++++++ .../Advisory/ServerVersionProvider.php | 43 +++ .../Advisory/AdvisoryPackageMapTest.php | 142 ++++++++++ .../Service/Advisory/AdvisoryServiceTest.php | 178 +++++++++++- .../Advisory/NextcloudAdvisoryFeedTest.php | 198 +++++++++++++ 7 files changed, 1130 insertions(+), 17 deletions(-) create mode 100644 lib/Service/Advisory/AdvisoryPackageMap.php create mode 100644 lib/Service/Advisory/NextcloudAdvisoryFeed.php create mode 100644 lib/Service/Advisory/ServerVersionProvider.php create mode 100644 tests/unit/Service/Advisory/AdvisoryPackageMapTest.php create mode 100644 tests/unit/Service/Advisory/NextcloudAdvisoryFeedTest.php diff --git a/lib/Service/Advisory/AdvisoryPackageMap.php b/lib/Service/Advisory/AdvisoryPackageMap.php new file mode 100644 index 00000000..0e7fe42a --- /dev/null +++ b/lib/Service/Advisory/AdvisoryPackageMap.php @@ -0,0 +1,184 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +use OCP\App\IAppManager; +use Psr\Log\LoggerInterface; + +/** + * Resolves the package name in a Nextcloud security advisory to something this + * instance can act on: an installed app id, the server itself, or nothing. + * + * WHY THIS IS NOT A ONE-LINER. The published feed names packages the way a + * human would, not the way the instance does. Measured over the 27 distinct + * packages in the live feed (2026-08-21): + * + * Talk -> spreed (id bears no resemblance) + * Team Folders -> groupfolders (the app was renamed) + * User OIDC -> user_oidc + * user_oidc -> user_oidc (the SAME app, both spellings + * appear in the same feed) + * Twofactor WebAuthn -> twofactor_webauthn + * End-to-End Encryption -> end_to_end_encryption + * + * Normalising to lowercase alphanumerics collapses all of those, and matching + * against BOTH the app id and the app's display name is what makes `Talk` find + * `spreed` — the id never matches, the name does. + * + * INSTALLED APPS ARE INDEXED AS WELL AS THE STORE CATALOGUE, and that is not + * redundant: apps bundled with the server are absent from the App Store + * catalogue entirely. Measured, a catalogue-only index resolved 19 of 27 + * packages and missed `Photos` and `Flow` for exactly that reason. + * + * A name that resolves to nothing is DROPPED AND COUNTED, never guessed at. A + * wrong match here either raises a false alarm on an app that is fine or, + * worse, attaches a real advisory to the wrong app and leaves the affected one + * looking clean. + * + * @psalm-api + */ +class AdvisoryPackageMap { + /** + * Packages that describe the server rather than an app. These correlate + * against the server version, not an app version. + * + * @var list + */ + private const SERVER_PACKAGES = ['server', 'enterpriseserver']; + + /** + * Packages this instance cannot act on: the desktop and mobile clients. + * They appear in the same feed, and surfacing them in an app list would be + * noise an administrator cannot resolve from here. + * + * @var list + */ + private const CLIENT_PACKAGES = ['desktop', 'desktopclient', 'androidfiles', 'filesios', 'iosfiles', 'androidnextcloud']; + + /** Sentinel returned for advisories that describe the server itself. */ + public const SERVER = ':server'; + + /** @var array|null normalised name => app id */ + private ?array $index = null; + + public function __construct( + private IAppManager $appManager, + private LoggerInterface $logger, + ) { + } + + /** + * Resolves a package name to an installed app id, {@see self::SERVER}, or + * null when the instance has nothing to correlate it against. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function resolve(string $packageName): ?string { + $key = $this->normalise($packageName); + if ($key === '') { + return null; + } + + if (in_array($key, self::SERVER_PACKAGES, true)) { + return self::SERVER; + } + + // Checked BEFORE the index, because an instance that happens to have an + // app whose name normalises to `desktop` should still not be told about + // the desktop client's advisories. + if (in_array($key, self::CLIENT_PACKAGES, true)) { + return null; + } + + return $this->index()[$key] ?? null; + } + + /** + * Builds the lookup once per request: every enabled app indexed under both + * its id and its display name. + * + * @return array + */ + private function index(): array { + if ($this->index !== null) { + return $this->index; + } + + $index = []; + foreach ($this->appManager->getEnabledApps() as $appId) { + $index[$this->normalise($appId)] = $appId; + + try { + $info = $this->appManager->getAppInfo($appId); + } catch (\Throwable $error) { + $this->logger->debug('AdvisoryPackageMap: could not read app info', [ + 'app' => $appId, + 'message' => $error->getMessage(), + ]); + continue; + } + if (!is_array($info)) { + continue; + } + + foreach ($this->displayNames($info) as $name) { + $key = $this->normalise($name); + // An id match is more trustworthy than a name match, so never + // let a name overwrite one. + if ($key !== '' && !isset($index[$key])) { + $index[$key] = $appId; + } + } + } + + $this->index = $index; + + return $index; + } + + /** + * An app's `name` may be a plain string or a per-language map; both shapes + * occur in real info.xml files. + * + * @param array $info + * @return list + */ + private function displayNames(array $info): array { + /** @var mixed $name */ + $name = $info['name'] ?? null; + if (is_string($name)) { + return [$name]; + } + if (!is_array($name)) { + return []; + } + + $names = []; + /** @var mixed $value */ + foreach ($name as $value) { + if (is_string($value)) { + $names[] = $value; + } + } + + return $names; + } + + /** + * Lowercase alphanumerics only. This is what makes `User OIDC`, + * `user_oidc` and `USER-OIDC` the same key. + */ + private function normalise(string $value): string { + return (string)preg_replace('/[^a-z0-9]/', '', strtolower($value)); + } +} diff --git a/lib/Service/Advisory/AdvisoryService.php b/lib/Service/Advisory/AdvisoryService.php index 55e3a995..0a654fa8 100644 --- a/lib/Service/Advisory/AdvisoryService.php +++ b/lib/Service/Advisory/AdvisoryService.php @@ -68,10 +68,23 @@ class AdvisoryService { public const STATE_AVAILABLE = 'advisory-available'; public const STATE_VULNERABLE = 'pinned-to-vulnerable'; + /** + * Key used for the Nextcloud server's own advisory row. + * + * The server is not an app, but 95 of the 277 published advisories are + * about it — by far the largest single subject in the feed — and an + * administrator running a vulnerable server wants to know. It is keyed + * distinctly so no caller mistakes it for an installed app. + */ + public const SERVER_KEY = AdvisoryPackageMap::SERVER; + public function __construct( private SourceRegistry $sourceRegistry, private SourceBindingStore $bindingStore, private IAppManager $appManager, + private NextcloudAdvisoryFeed $advisoryFeed, + private BranchAwareRange $branchRange, + private ServerVersionProvider $serverVersion, private LoggerInterface $logger, ) { } @@ -83,24 +96,38 @@ public function __construct( * answer advisories, so callers can treat the map uniformly. * * @spec openspec/specs/security-advisory-correlation/spec.md + * @param list, firstPatchedVersion: ?string, patchedVersions?: list}> $feedAdvisories + * Advisories the central feed already resolved to this app. Passed in + * rather than fetched here because the feed is read ONCE per sweep. * @return array{appId: string, installedVersion: ?string, state: string, advisories: list, recommendedVersion: ?string, error: ?string} */ - public function correlate(string $appId): array { + public function correlate(string $appId, array $feedAdvisories = []): array { $installedVersion = $this->installedVersion($appId); $binding = $this->bindingStore->get($appId) ?? SourceBinding::appStore(); $source = $this->sourceRegistry->get($binding); - if (!$source instanceof AdvisorySourceInterface) { - return $this->emptyResult($appId, $installedVersion); - } - if ($installedVersion === null) { return $this->emptyResult($appId, null); } + // An app whose source cannot answer advisories is NOT a dead end any + // more: the centrally-published feed may still cover it, and for App + // Store apps it is the only thing that does. + if (!$source instanceof AdvisorySourceInterface) { + if ($feedAdvisories === []) { + return $this->emptyResult($appId, $installedVersion); + } + + $evaluated = $this->evaluate($installedVersion, $feedAdvisories, []); + $evaluated['appId'] = $appId; + $evaluated['error'] = null; + + return $evaluated; + } + $advisoryResult = $source->listAdvisories($appId, $binding); - $advisories = $advisoryResult['advisories']; + $advisories = [...$feedAdvisories, ...$advisoryResult['advisories']]; $error = $advisoryResult['error']; $available = []; @@ -133,6 +160,17 @@ public function correlateAll(?float $budgetSeconds = null): array { $results = []; $deadline = microtime(true) + ($budgetSeconds ?? self::CORRELATE_ALL_BUDGET_SECONDS); + // ONE fetch for the whole sweep. The advisories that actually exist are + // published centrally, not per app, so asking each app's own source was + // asking 87 of 88 apps a question their source cannot answer (#166). + $feed = $this->advisoryFeed->fetchAll(); + $feedAdvisories = $feed['advisories']; + if ($feed['error'] !== null) { + $this->logger->warning('AdvisoryService: the Nextcloud advisory feed could not be read in full', [ + 'message' => $feed['error'], + ]); + } + foreach ($this->appManager->getEnabledApps() as $appId) { // BUDGET, BECAUSE THIS ENDPOINT COULD NOT PREVIOUSLY RETURN AT ALL. // @@ -164,7 +202,7 @@ public function correlateAll(?float $budgetSeconds = null): array { } try { - $results[$appId] = $this->correlate($appId); + $results[$appId] = $this->correlate($appId, $feedAdvisories[$appId] ?? []); } catch (\Throwable $error) { $this->logger->warning('AdvisoryService: correlation failed for app', [ 'app' => $appId, @@ -174,6 +212,18 @@ public function correlateAll(?float $budgetSeconds = null): array { } } + // The server's own row. It is not an app, but 95 of the 277 published + // advisories are about it — the largest single subject in the feed — + // and an administrator on a vulnerable server has to be told. + $serverAdvisories = $feedAdvisories[self::SERVER_KEY] ?? []; + if ($serverAdvisories !== []) { + $installed = $this->serverVersion->current(); + $evaluated = $this->evaluate($installed, $serverAdvisories, []); + $evaluated['appId'] = self::SERVER_KEY; + $evaluated['error'] = null; + $results[self::SERVER_KEY] = $evaluated; + } + return $results; } @@ -184,7 +234,11 @@ public function correlateAll(?float $budgetSeconds = null): array { * core of the feature. * * @spec openspec/specs/security-advisory-correlation/spec.md - * @param list, firstPatchedVersion: ?string}> $advisories + * @param list, firstPatchedVersion: ?string, patchedVersions?: list}> $advisories + * `patchedVersions` is present on records from the central Nextcloud + * feed and absent on the older per-source shape. Its presence is what + * selects branch-aware evaluation over clause evaluation, so it is part + * of the contract rather than an optional extra. * @param list $availableVersions * @return array{appId: string, installedVersion: ?string, state: string, advisories: list, recommendedVersion: ?string, error: ?string} */ @@ -200,10 +254,35 @@ public function evaluate(string $installedVersion, array $advisories, array $ava ]; } - $active = array_values(array_filter( - $advisories, - fn (array $advisory): bool => $this->isAffected($installedVersion, $advisory['affected']), - )); + // Two evaluation paths, chosen by what the record carries. + // + // A record with `patchedVersions` came from the Nextcloud feed, which + // describes several maintenance branches per advisory. Its comma- + // separated range CANNOT be read as a boolean: measured over the + // published corpus, 66.5% of entries are multiple lower bounds with no + // upper bound, which ANDed collapse to the highest and clear a + // genuinely vulnerable instance. BranchAwareRange resolves those from + // the patch list instead. + // + // A record without it came from a forge source in the older shape, and + // keeps the original clause semantics. + $active = []; + $branchPatches = []; + foreach ($advisories as $advisory) { + $patchedVersions = $advisory['patchedVersions'] ?? []; + if ($patchedVersions !== []) { + $patch = $this->branchRange->resolvePatch($installedVersion, $patchedVersions); + if ($patch !== null) { + $active[] = $advisory; + $branchPatches[] = $patch; + } + continue; + } + + if ($this->isAffected($installedVersion, $advisory['affected'])) { + $active[] = $advisory; + } + } if ($active === []) { // The app has advisories, but none affect the installed version: @@ -218,21 +297,47 @@ public function evaluate(string $installedVersion, array $advisories, array $ava ]; } + // The advisory's own patch beats a guess from the version list: it is + // the version the publisher states resolves the issue on THIS branch, + // whereas nearestResolving() infers one from whatever the source + // happens to offer. + $recommended = $branchPatches !== [] + ? $this->lowestVersion($branchPatches) + : $this->nearestResolving($installedVersion, $active, $availableVersions); + return [ 'appId' => '', 'installedVersion' => $installedVersion, 'state' => self::STATE_VULNERABLE, 'advisories' => $this->summarise($active), - 'recommendedVersion' => $this->nearestResolving($installedVersion, $active, $availableVersions), + 'recommendedVersion' => $recommended, 'error' => null, ]; } + /** + * The lowest of several branch patches — when more than one advisory + * affects the installed version, the nearest upgrade that starts resolving + * them is the one to name. + * + * @param non-empty-list $versions + */ + private function lowestVersion(array $versions): string { + $lowest = $versions[0]; + foreach ($versions as $candidate) { + if (version_compare($candidate, $lowest, '<')) { + $lowest = $candidate; + } + } + + return $lowest; + } + /** * Reduces advisory records to the id/severity/summary triple surfaced to * the admin (drops the internal affected-range / patch fields). * - * @param list, firstPatchedVersion?: ?string}> $advisories + * @param list, firstPatchedVersion?: ?string, patchedVersions?: list}> $advisories * @return list */ private function summarise(array $advisories): array { @@ -317,7 +422,7 @@ private function satisfiesClause(string $version, string $clause): bool { * source; otherwise scans the available versions ascending. Returns null * when the source offers no resolving version (stuck-on-vulnerable). * - * @param list, firstPatchedVersion: ?string}> $active + * @param list, firstPatchedVersion: ?string, patchedVersions?: list}> $active * @param list $availableVersions */ private function nearestResolving(string $installedVersion, array $active, array $availableVersions): ?string { diff --git a/lib/Service/Advisory/NextcloudAdvisoryFeed.php b/lib/Service/Advisory/NextcloudAdvisoryFeed.php new file mode 100644 index 00000000..bbe090b8 --- /dev/null +++ b/lib/Service/Advisory/NextcloudAdvisoryFeed.php @@ -0,0 +1,267 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Reads Nextcloud's published security advisories and groups them by the thing + * this instance can act on — an app id, or the server. + * + * WHY A CENTRAL FEED RATHER THAN A PER-APP SOURCE. The App Store publishes no + * advisory data at all: measured 2026-08-21, `garm3.nextcloud.com/api/v1/apps.json` + * returned 755 entries and 31.7 MB containing no `securityAdvisories` field + * and nothing advisory-shaped. So the existing per-app correlation asked 87 of + * 88 apps a question their source could never answer, and recorded the silence + * as "no advisories" (issue #166). + * + * The real data lives in ONE place — the GHSA records published on + * `nextcloud/security-advisories` — so it is fetched once per sweep and + * indexed, rather than re-asked per app. + * + * @psalm-api + */ +class NextcloudAdvisoryFeed { + /** + * The advisory feed. Overridable via `advisory.feed_base` app config so an + * e2e run can point at a fixture, mirroring how `appstore.api_base` works. + */ + private const DEFAULT_FEED_URL = 'https://api.github.com/repos/nextcloud/security-advisories/security-advisories'; + + /** + * Pages to follow before giving up. + * + * The endpoint IGNORES `?page=` — measured, page 1 and page 2 return byte + * -identical bodies — and paginates by an opaque cursor in the `Link` + * header instead. A page-number loop would therefore re-read page one until + * it hit this cap, so the cursor is not an optimisation: without it the + * feed is silently truncated to its first 100 records. + * + * @var int + */ + private const MAX_PAGES = 20; + + private const PER_PAGE = 100; + private const FETCH_TIMEOUT_SECONDS = 30; + + public function __construct( + private IClientService $clientService, + private IAppConfig $config, + private AdvisoryPackageMap $packageMap, + private LoggerInterface $logger, + ) { + } + + /** + * Every advisory the feed publishes, keyed by app id (or + * {@see AdvisoryPackageMap::SERVER}). Packages this instance cannot act on + * — uninstalled apps, desktop and mobile clients — are dropped. + * + * Errors are reported, never thrown: a sweep that cannot reach the feed + * must degrade to "could not check", not abort the whole correlation. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + * @return array{advisories: array, firstPatchedVersion: ?string, patchedVersions: list}>>, error: ?string} + */ + public function fetchAll(): array { + $client = $this->clientService->newClient(); + $url = $this->feedUrl() . '?per_page=' . self::PER_PAGE; + + $byTarget = []; + $seen = []; + $pages = 0; + + while ($url !== null && $pages < self::MAX_PAGES) { + $pages++; + try { + $response = $client->get($url, [ + 'timeout' => self::FETCH_TIMEOUT_SECONDS, + 'headers' => ['Accept' => 'application/vnd.github+json'], + ]); + } catch (Throwable $error) { + return $this->partial($byTarget, 'Could not read the Nextcloud advisory feed: ' . $error->getMessage()); + } + + if ($response->getStatusCode() !== 200) { + return $this->partial($byTarget, 'The Nextcloud advisory feed returned HTTP ' . $response->getStatusCode() . '.'); + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + return $this->partial($byTarget, 'The Nextcloud advisory feed returned invalid JSON: ' . $error->getMessage()); + } + if (!is_array($decoded)) { + return $this->partial($byTarget, 'The Nextcloud advisory feed returned an unexpected payload shape.'); + } + + $fresh = 0; + /** @var mixed $record */ + foreach ($decoded as $record) { + if (!is_array($record)) { + continue; + } + /** @var mixed $ghsa */ + $ghsa = $record['ghsa_id'] ?? null; + if (!is_string($ghsa) || $ghsa === '' || isset($seen[$ghsa])) { + continue; + } + $seen[$ghsa] = true; + $fresh++; + foreach ($this->targetsFor($record, $ghsa) as $target => $advisory) { + $byTarget[$target][] = $advisory; + } + } + + $next = $this->nextCursorUrl($response->getHeader('Link')); + // A page that produced nothing new means the cursor is not moving. + // Stopping is what keeps a server-side pagination change from + // spinning until MAX_PAGES. + $url = ($fresh > 0) ? $next : null; + } + + return ['advisories' => $byTarget, 'error' => null]; + } + + /** + * Advisory records for one GHSA entry, keyed by the target it applies to. + * + * A single advisory routinely names several packages, and may name the + * same target twice (Server appears twice in some records); both are + * flattened to one entry per target here. + * + * @param array $record + * @return array, firstPatchedVersion: ?string, patchedVersions: list}> + */ + private function targetsFor(array $record, string $ghsa): array { + /** @var mixed $vulnerabilities */ + $vulnerabilities = $record['vulnerabilities'] ?? null; + if (!is_array($vulnerabilities)) { + return []; + } + + $severity = is_string($record['severity'] ?? null) ? (string)$record['severity'] : 'unknown'; + $summary = is_string($record['summary'] ?? null) ? (string)$record['summary'] : ''; + + $targets = []; + /** @var mixed $vulnerability */ + foreach ($vulnerabilities as $vulnerability) { + if (!is_array($vulnerability)) { + continue; + } + $package = $vulnerability['package'] ?? null; + if (!is_array($package) || !is_string($package['name'] ?? null)) { + continue; + } + + $target = $this->packageMap->resolve((string)$package['name']); + if ($target === null) { + continue; + } + + $patched = $this->splitList(is_string($vulnerability['patched_versions'] ?? null) ? (string)$vulnerability['patched_versions'] : ''); + $affected = $this->splitList(is_string($vulnerability['vulnerable_version_range'] ?? null) ? (string)$vulnerability['vulnerable_version_range'] : ''); + + if (isset($targets[$target])) { + // Same advisory, same target, listed twice: merge the version + // information rather than letting the second entry replace the + // first and silently drop a branch. + $targets[$target]['patchedVersions'] = array_values(array_unique([...$targets[$target]['patchedVersions'], ...$patched])); + $targets[$target]['affected'] = array_values(array_unique([...$targets[$target]['affected'], ...$affected])); + $targets[$target]['firstPatchedVersion'] = $targets[$target]['patchedVersions'][0] ?? null; + continue; + } + + $targets[$target] = [ + 'id' => $ghsa, + 'severity' => $severity, + 'summary' => $summary, + 'affected' => $affected, + // Kept for the existing contract; `patchedVersions` is what + // BranchAwareRange actually evaluates, because one advisory + // carries a separate patch per maintenance branch. + 'firstPatchedVersion' => $patched[0] ?? null, + 'patchedVersions' => $patched, + ]; + } + + return $targets; + } + + /** + * Splits a comma-separated version list, dropping empties. + * + * No interpretation happens here on purpose: whether the commas mean AND + * or OR is a question the corpus answers differently per record, and + * BranchAwareRange resolves it from the patched versions instead. + * + * @return list + */ + private function splitList(string $raw): array { + if (trim($raw) === '') { + return []; + } + + return array_values(array_filter( + array_map('trim', explode(',', $raw)), + static fn (string $part): bool => $part !== '', + )); + } + + /** + * The `rel="next"` URL from a GitHub `Link` header, or null when this is + * the last page. + */ + private function nextCursorUrl(string $linkHeader): ?string { + if (trim($linkHeader) === '') { + return null; + } + + foreach (explode(',', $linkHeader) as $part) { + if (!str_contains($part, 'rel="next"')) { + continue; + } + if (preg_match('/<([^>]+)>/', $part, $matches) === 1) { + return $matches[1]; + } + } + + return null; + } + + private function feedUrl(): string { + $override = trim($this->config->getValueString('app_versions', 'advisory.feed_base', '')); + + return $override !== '' ? rtrim($override, '/') : self::DEFAULT_FEED_URL; + } + + /** + * Returns whatever was collected before the failure, WITH the error. + * + * Discarding a partial read would turn a feed that failed on page three + * into "no advisories", which is the exact absence-reads-as-reassurance + * failure this whole feature keeps hitting. + * + * @param array, firstPatchedVersion: ?string, patchedVersions: list}>> $collected + * @return array{advisories: array, firstPatchedVersion: ?string, patchedVersions: list}>>, error: string} + */ + private function partial(array $collected, string $error): array { + $this->logger->warning('NextcloudAdvisoryFeed: ' . $error, ['collected' => count($collected)]); + + return ['advisories' => $collected, 'error' => $error]; + } +} diff --git a/lib/Service/Advisory/ServerVersionProvider.php b/lib/Service/Advisory/ServerVersionProvider.php new file mode 100644 index 00000000..3d476bba --- /dev/null +++ b/lib/Service/Advisory/ServerVersionProvider.php @@ -0,0 +1,43 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +use OCP\ServerVersion; + +/** + * Supplies the running server's version string. + * + * This exists only to make the server's advisory row testable. `OCP\ServerVersion` + * is declared `readonly`, so PHPUnit cannot double it, and its constructor + * `require`s the server's own `version.php` — which is absent from a unit-test + * autoload tree. Depending on it directly would mean the server correlation + * path, the one covering 95 of the 277 published advisories, could not be + * tested at all. + * + * @psalm-api + */ +class ServerVersionProvider { + public function __construct( + private ServerVersion $serverVersion, + ) { + } + + /** + * The running server version, e.g. `31.0.2`. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function current(): string { + return $this->serverVersion->getVersionString(); + } +} diff --git a/tests/unit/Service/Advisory/AdvisoryPackageMapTest.php b/tests/unit/Service/Advisory/AdvisoryPackageMapTest.php new file mode 100644 index 00000000..7800d517 --- /dev/null +++ b/tests/unit/Service/Advisory/AdvisoryPackageMapTest.php @@ -0,0 +1,142 @@ +> $apps app id => display name + */ + private function map(array $apps): AdvisoryPackageMap { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getEnabledApps')->willReturn(array_keys($apps)); + $appManager->method('getAppInfo')->willReturnCallback( + static fn (string $appId) => isset($apps[$appId]) ? ['name' => $apps[$appId]] : null, + ); + + return new AdvisoryPackageMap($appManager, $this->createMock(LoggerInterface::class)); + } + + /** + * The cases that make this class necessary. Every one is taken from the + * live feed; none of them resolve by comparing the package name to the app + * id directly. + * + * @dataProvider realWorldNames + */ + public function testResolvesTheNamesTheFeedActuallyPublishes(string $package, string $expected): void { + $map = $this->map([ + 'spreed' => 'Talk', + 'groupfolders' => 'Team Folders', + 'user_oidc' => 'OpenID Connect user backend', + 'twofactor_webauthn' => 'WebAuthn', + 'end_to_end_encryption' => 'End-to-End Encryption', + 'photos' => 'Photos', + 'workflowengine' => 'Flow', + 'tables' => 'Tables', + ]); + + self::assertSame($expected, $map->resolve($package)); + } + + /** + * @return array + */ + public static function realWorldNames(): array { + return [ + // The id bears no resemblance to the published name. + 'Talk -> spreed' => ['Talk', 'spreed'], + // The app was renamed; only the current display name matches. + 'Team Folders -> groupfolders' => ['Team Folders', 'groupfolders'], + // Both spellings appear in the SAME feed for the same app. + 'User OIDC -> user_oidc' => ['User OIDC', 'user_oidc'], + 'user_oidc -> user_oidc' => ['user_oidc', 'user_oidc'], + // Punctuation and case differences only. + 'Twofactor WebAuthn -> id' => ['Twofactor WebAuthn', 'twofactor_webauthn'], + 'End-to-End Encryption -> id' => ['End-to-End Encryption', 'end_to_end_encryption'], + // Bundled apps: absent from the App Store catalogue entirely, which + // is why the installed-app list has to be part of the index. + 'Photos -> photos' => ['Photos', 'photos'], + 'Flow -> workflowengine' => ['Flow', 'workflowengine'], + ]; + } + + public function testServerPackagesResolveToTheServerSentinel(): void { + $map = $this->map(['files' => 'Files']); + + self::assertSame(AdvisoryPackageMap::SERVER, $map->resolve('Server')); + self::assertSame(AdvisoryPackageMap::SERVER, $map->resolve('Enterprise Server')); + } + + /** + * Desktop and mobile clients share the feed. An administrator cannot + * resolve those from this instance, so surfacing them would be noise. + */ + public function testClientPackagesAreDropped(): void { + $map = $this->map(['files' => 'Files']); + + self::assertNull($map->resolve('Desktop')); + self::assertNull($map->resolve('Desktop client')); + self::assertNull($map->resolve('Android Files')); + self::assertNull($map->resolve('Files iOS')); + } + + /** + * A client name must stay filtered even if some installed app happens to + * normalise to it — the filter is checked before the index for exactly + * this reason. + */ + public function testAClientNameIsFilteredEvenWhenAnAppWouldMatchIt(): void { + $map = $this->map(['some_app' => 'Desktop']); + + self::assertNull($map->resolve('Desktop')); + } + + /** + * An unknown package is dropped, never guessed. A wrong match attaches a + * real advisory to the wrong app and leaves the affected one looking clean. + */ + public function testUnknownPackagesResolveToNull(): void { + $map = $this->map(['files' => 'Files']); + + self::assertNull($map->resolve('Some App Nobody Installed')); + self::assertNull($map->resolve('')); + self::assertNull($map->resolve(' ')); + } + + public function testAnAppThatIsNotInstalledDoesNotResolve(): void { + // Talk's advisories must not attach to anything on an instance without it. + $map = $this->map(['files' => 'Files']); + + self::assertNull($map->resolve('Talk')); + } + + /** + * An id match must win over a name match, because ids are unique and + * display names are not. + */ + public function testAnIdMatchIsNotOverwrittenByAnotherAppsName(): void { + $map = $this->map([ + 'tables' => 'Tables', + 'other_app' => 'tables', + ]); + + self::assertSame('tables', $map->resolve('tables')); + } + + /** + * info.xml `name` is sometimes a per-language map rather than a string. + */ + public function testHandlesATranslatedNameMap(): void { + $map = $this->map(['deck' => ['en' => 'Deck', 'de' => 'Deck-Board']]); + + self::assertSame('deck', $map->resolve('Deck')); + self::assertSame('deck', $map->resolve('Deck-Board')); + } +} diff --git a/tests/unit/Service/Advisory/AdvisoryServiceTest.php b/tests/unit/Service/Advisory/AdvisoryServiceTest.php index 0b5e0386..90b6702b 100644 --- a/tests/unit/Service/Advisory/AdvisoryServiceTest.php +++ b/tests/unit/Service/Advisory/AdvisoryServiceTest.php @@ -13,11 +13,14 @@ namespace OCA\AppVersions\Tests\Unit\Service\Advisory; use OCA\AppVersions\Service\Advisory\AdvisoryService; +use OCA\AppVersions\Service\Advisory\BranchAwareRange; +use OCA\AppVersions\Service\Advisory\NextcloudAdvisoryFeed; use OCA\AppVersions\Service\Advisory\AdvisorySourceInterface; use OCA\AppVersions\Service\Source\SourceBinding; use OCA\AppVersions\Service\Source\SourceBindingStore; use OCA\AppVersions\Service\Source\SourceInterface; use OCA\AppVersions\Service\Source\SourceRegistry; +use OCA\AppVersions\Service\Advisory\ServerVersionProvider; use OCP\App\IAppManager; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -28,10 +31,83 @@ private function service(): AdvisoryService { $this->createMock(SourceRegistry::class), $this->createMock(SourceBindingStore::class), $this->createMock(IAppManager::class), + $this->quietFeed(), + new BranchAwareRange(), + $this->createMock(ServerVersionProvider::class), $this->createMock(LoggerInterface::class), ); } + /** + * A feed that answers with nothing. These tests exercise the pure + * evaluation path, so the central feed must not contribute advisories of + * its own — otherwise a change to the feed would move assertions about + * clause semantics. + */ + private function quietFeed(): NextcloudAdvisoryFeed { + $feed = $this->createMock(NextcloudAdvisoryFeed::class); + $feed->method('fetchAll')->willReturn(['advisories' => [], 'error' => null]); + + return $feed; + } + + /** + * A service whose central feed answers with the given map, and whose + * enabled-app list and versions are fixed. + * + * @param array>> $feedAdvisories + * @param array $installedVersions app id => version + */ + private function serviceWithFeed(array $feedAdvisories, array $installedVersions, string $serverVersion = '31.0.2'): AdvisoryService { + $feed = $this->createMock(NextcloudAdvisoryFeed::class); + $feed->method('fetchAll')->willReturn(['advisories' => $feedAdvisories, 'error' => null]); + + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getEnabledApps')->willReturn(array_keys($installedVersions)); + $appManager->method('getAppVersion')->willReturnCallback( + static fn (string $appId): string => $installedVersions[$appId] ?? '', + ); + + // A source with no advisory capability — the App Store case, which is + // 87 of 88 apps on a real instance. + $registry = $this->createMock(SourceRegistry::class); + $registry->method('get')->willReturn($this->createMock(SourceInterface::class)); + + $bindingStore = $this->createMock(SourceBindingStore::class); + $bindingStore->method('get')->willReturn(SourceBinding::appStore()); + + $provider = $this->createMock(ServerVersionProvider::class); + $provider->method('current')->willReturn($serverVersion); + + return new AdvisoryService( + $registry, + $bindingStore, + $appManager, + $feed, + new BranchAwareRange(), + $provider, + $this->createMock(LoggerInterface::class), + ); + } + + /** + * A feed-shaped advisory: carries `patchedVersions`, which is what routes + * it through branch-aware evaluation. + * + * @param list $patched + * @return array{id: string, severity: string, summary: string, affected: list, firstPatchedVersion: ?string, patchedVersions: list} + */ + private function feedAdvisory(string $id, array $patched, string $range = ''): array { + return [ + 'id' => $id, + 'severity' => 'high', + 'summary' => 'Feed advisory ' . $id, + 'affected' => $range === '' ? [] : [$range], + 'firstPatchedVersion' => $patched[0] ?? null, + 'patchedVersions' => $patched, + ]; + } + /** * @param list, firstPatchedVersion: ?string}> $advisories * @return array{id: string, severity: string, summary: string, affected: list, firstPatchedVersion: ?string} @@ -150,7 +226,7 @@ public function listAdvisories(string $appId, SourceBinding $binding): array { $appManager = $this->createMock(IAppManager::class); $appManager->method('getAppVersion')->willReturn('1.0.5'); - $service = new AdvisoryService($registry, $bindingStore, $appManager, $this->createMock(LoggerInterface::class)); + $service = new AdvisoryService($registry, $bindingStore, $appManager, $this->quietFeed(), new BranchAwareRange(), $this->createMock(ServerVersionProvider::class), $this->createMock(LoggerInterface::class)); $result = $service->correlate('someapp'); $this->assertSame('someapp', $result['appId']); @@ -175,10 +251,108 @@ public function testCorrelateReturnsNoneWhenSourceCannotAnswerAdvisories(): void $appManager = $this->createMock(IAppManager::class); $appManager->method('getAppVersion')->willReturn('1.0.0'); - $service = new AdvisoryService($registry, $bindingStore, $appManager, $this->createMock(LoggerInterface::class)); + $service = new AdvisoryService($registry, $bindingStore, $appManager, $this->quietFeed(), new BranchAwareRange(), $this->createMock(ServerVersionProvider::class), $this->createMock(LoggerInterface::class)); $result = $service->correlate('plainapp'); $this->assertSame(AdvisoryService::STATE_NONE, $result['state']); $this->assertNull($result['error']); } + + // ── The central feed: what #166 exists to fix ──────────────────────── + + /** + * THE HEADLINE CASE. An App Store app's own source publishes no advisory + * data at all, so before this the app was recorded as "no advisories" with + * no error. The centrally-published feed is the only thing that covers it. + */ + public function testAnAppStoreAppIsCorrelatedFromTheCentralFeed(): void { + $service = $this->serviceWithFeed( + ['mail' => [$this->feedAdvisory('GHSA-aaaa', ['3.7.25', '5.5.16'])]], + ['mail' => '3.6.0'], + ); + + $results = $service->correlateAll(60.0); + + $this->assertSame(AdvisoryService::STATE_VULNERABLE, $results['mail']['state']); + $this->assertSame('3.7.25', $results['mail']['recommendedVersion']); + $this->assertNull($results['mail']['error']); + } + + /** + * The branch-aware rule reaching the real evaluation path. Under the old + * AND semantics these four lower bounds collapse to `>= 4.3.0` and 3.6.0 + * is cleared — a false negative on two thirds of real advisories. + */ + public function testMultipleLowerBoundsDoNotClearAVulnerableInstance(): void { + $service = $this->serviceWithFeed( + ['mail' => [$this->feedAdvisory('GHSA-bbbb', ['3.7.25', '5.5.16', '5.6.20', '5.7.13'], '>= 3.5.0, >= 3.7.0, >= 4.1.0, >= 4.3.0')]], + ['mail' => '3.6.0'], + ); + + $results = $service->correlateAll(60.0); + + $this->assertSame(AdvisoryService::STATE_VULNERABLE, $results['mail']['state']); + } + + /** + * And the other direction: an instance already on its branch's patch must + * not be dragged forward by a later branch's patch. + */ + public function testAnInstanceOnItsBranchPatchIsNotReportedVulnerable(): void { + $service = $this->serviceWithFeed( + ['spreed' => [$this->feedAdvisory('GHSA-cccc', ['21.1.10', '22.0.11', '23.0.3'])]], + ['spreed' => '22.0.11'], + ); + + $results = $service->correlateAll(60.0); + + $this->assertSame(AdvisoryService::STATE_AVAILABLE, $results['spreed']['state'], 'patched, but the app has a security history'); + $this->assertNull($results['spreed']['recommendedVersion']); + } + + /** + * The server is not an app, but it is the largest single subject in the + * feed — 95 of 277 advisories — so it gets its own row. + */ + public function testTheServerGetsItsOwnCorrelatedRow(): void { + $service = $this->serviceWithFeed( + [AdvisoryService::SERVER_KEY => [$this->feedAdvisory('GHSA-dddd', ['31.0.12'])]], + ['mail' => '5.7.13'], + '31.0.5', + ); + + $results = $service->correlateAll(60.0); + + $this->assertArrayHasKey(AdvisoryService::SERVER_KEY, $results); + $this->assertSame('31.0.5', $results[AdvisoryService::SERVER_KEY]['installedVersion']); + $this->assertSame(AdvisoryService::STATE_VULNERABLE, $results[AdvisoryService::SERVER_KEY]['state']); + $this->assertSame('31.0.12', $results[AdvisoryService::SERVER_KEY]['recommendedVersion']); + } + + /** + * No server advisories means no server row — an empty row would read as + * "the server was checked and is fine" on an instance where the feed was + * never reached. + */ + public function testNoServerRowWhenTheFeedCarriesNoServerAdvisories(): void { + $service = $this->serviceWithFeed(['mail' => []], ['mail' => '1.0.0']); + + $this->assertArrayNotHasKey(AdvisoryService::SERVER_KEY, $service->correlateAll(60.0)); + } + + /** + * The recommended version comes from the ADVISORY's patch list, not from + * whatever versions the source happens to offer. The publisher's stated + * fix is authoritative; an inferred one is a guess. + */ + public function testTheRecommendationComesFromTheAdvisoryNotTheVersionList(): void { + $service = $this->serviceWithFeed( + ['tables' => [$this->feedAdvisory('GHSA-eeee', ['0.9.5'])]], + ['tables' => '0.9.0'], + ); + + $results = $service->correlateAll(60.0); + + $this->assertSame('0.9.5', $results['tables']['recommendedVersion']); + } } diff --git a/tests/unit/Service/Advisory/NextcloudAdvisoryFeedTest.php b/tests/unit/Service/Advisory/NextcloudAdvisoryFeedTest.php new file mode 100644 index 00000000..fda28105 --- /dev/null +++ b/tests/unit/Service/Advisory/NextcloudAdvisoryFeedTest.php @@ -0,0 +1,198 @@ + $pages + * @param list $installedApps + */ + private function feed(array $pages, array $installedApps = ['mail', 'spreed', 'tables']): NextcloudAdvisoryFeed { + $responses = []; + foreach ($pages as $page) { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($page['status'] ?? 200); + $response->method('getBody')->willReturn($page['body']); + $response->method('getHeader')->willReturnCallback( + static fn (string $key): string => strtolower($key) === 'link' ? ($page['link'] ?? '') : '', + ); + $responses[] = $response; + } + + $client = $this->createMock(IClient::class); + $client->method('get')->willReturnOnConsecutiveCalls(...$responses); + $clientService = $this->createMock(IClientService::class); + $clientService->method('newClient')->willReturn($client); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturn(''); + + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getEnabledApps')->willReturn($installedApps); + $appManager->method('getAppInfo')->willReturnCallback( + static fn (string $id) => ['name' => ['mail' => 'Mail', 'spreed' => 'Talk', 'tables' => 'Tables'][$id] ?? $id], + ); + + $logger = $this->createMock(LoggerInterface::class); + + return new NextcloudAdvisoryFeed( + $clientService, + $config, + new AdvisoryPackageMap($appManager, $logger), + $logger, + ); + } + + /** + * @param list $vulns + */ + private function advisory(string $ghsa, array $vulns, string $severity = 'high', string $summary = 'Something'): array { + return [ + 'ghsa_id' => $ghsa, + 'severity' => $severity, + 'summary' => $summary, + 'vulnerabilities' => array_map(static fn (array $v): array => [ + 'package' => ['ecosystem' => 'nextcloud', 'name' => $v['package']], + 'vulnerable_version_range' => $v['range'] ?? '', + 'patched_versions' => $v['patched'] ?? '', + ], $vulns), + ]; + } + + public function testGroupsAdvisoriesByResolvedTarget(): void { + $body = json_encode([ + $this->advisory('GHSA-aaaa', [['package' => 'Mail', 'range' => '>= 3.5.0', 'patched' => '3.7.25, 5.5.16']]), + $this->advisory('GHSA-bbbb', [['package' => 'Talk', 'patched' => '21.1.10']]), + ], JSON_THROW_ON_ERROR); + + $result = $this->feed([['body' => $body]])->fetchAll(); + + self::assertNull($result['error']); + self::assertSame(['mail', 'spreed'], array_keys($result['advisories'])); + self::assertSame('GHSA-aaaa', $result['advisories']['mail'][0]['id']); + self::assertSame(['3.7.25', '5.5.16'], $result['advisories']['mail'][0]['patchedVersions']); + self::assertSame('3.7.25', $result['advisories']['mail'][0]['firstPatchedVersion']); + } + + /** + * The whole reason this class does cursor pagination: the endpoint IGNORES + * `?page=`, so a page-number loop reads the first 100 records over and + * over and the feed is silently truncated. + */ + public function testFollowsTheCursorInTheLinkHeader(): void { + $page1 = json_encode([$this->advisory('GHSA-aaaa', [['package' => 'Mail', 'patched' => '1.0.1']])], JSON_THROW_ON_ERROR); + $page2 = json_encode([$this->advisory('GHSA-bbbb', [['package' => 'Tables', 'patched' => '2.0.1']])], JSON_THROW_ON_ERROR); + + $result = $this->feed([ + ['body' => $page1, 'link' => '; rel="next"'], + ['body' => $page2], + ])->fetchAll(); + + self::assertNull($result['error']); + self::assertArrayHasKey('mail', $result['advisories'], 'page one must be kept'); + self::assertArrayHasKey('tables', $result['advisories'], 'page two must be followed via the cursor'); + } + + /** + * A server that keeps returning the same page must not spin to MAX_PAGES. + */ + public function testStopsWhenAPageAddsNothingNew(): void { + $same = json_encode([$this->advisory('GHSA-aaaa', [['package' => 'Mail', 'patched' => '1.0.1']])], JSON_THROW_ON_ERROR); + $link = '; rel="next"'; + + // Only two responses are provisioned. If the loop did not stop, the + // third get() would return null and the test would error rather than + // pass — which is the point. + $result = $this->feed([ + ['body' => $same, 'link' => $link], + ['body' => $same, 'link' => $link], + ])->fetchAll(); + + self::assertNull($result['error']); + self::assertCount(1, $result['advisories']['mail'], 'the duplicate advisory must not be counted twice'); + } + + public function testDropsPackagesTheInstanceCannotActOn(): void { + $body = json_encode([ + $this->advisory('GHSA-aaaa', [['package' => 'Desktop', 'patched' => '1.0.1']]), + $this->advisory('GHSA-bbbb', [['package' => 'Some Uninstalled App', 'patched' => '2.0.1']]), + ], JSON_THROW_ON_ERROR); + + $result = $this->feed([['body' => $body]])->fetchAll(); + + self::assertSame([], $result['advisories'], 'a client advisory and an uninstalled app must both be dropped'); + } + + public function testServerAdvisoriesAreKeyedByTheServerSentinel(): void { + $body = json_encode([ + $this->advisory('GHSA-cccc', [['package' => 'Server', 'patched' => '31.0.1']]), + ], JSON_THROW_ON_ERROR); + + $result = $this->feed([['body' => $body]])->fetchAll(); + + self::assertArrayHasKey(AdvisoryPackageMap::SERVER, $result['advisories']); + } + + /** + * Some records list the same package twice. Merging keeps every branch; + * letting the second entry win would silently drop patches. + */ + public function testMergesRepeatedPackagesWithinOneAdvisory(): void { + $body = json_encode([ + $this->advisory('GHSA-dddd', [ + ['package' => 'Server', 'range' => '< 21.0.0', 'patched' => '21.0.9'], + ['package' => 'Server', 'range' => '>= 22.0.0', 'patched' => '22.2.10'], + ]), + ], JSON_THROW_ON_ERROR); + + $result = $this->feed([['body' => $body]])->fetchAll(); + + $entry = $result['advisories'][AdvisoryPackageMap::SERVER][0]; + self::assertSame(['21.0.9', '22.2.10'], $entry['patchedVersions']); + self::assertSame(['< 21.0.0', '>= 22.0.0'], $entry['affected']); + } + + // ── Failure modes: an unreachable feed must never read as "nothing found" ── + + public function testReportsAnHttpFailureRatherThanReturningSilence(): void { + $result = $this->feed([['body' => '', 'status' => 503]])->fetchAll(); + + self::assertNotNull($result['error']); + self::assertStringContainsString('503', $result['error']); + } + + public function testReportsInvalidJson(): void { + $result = $this->feed([['body' => '{not json']])->fetchAll(); + + self::assertNotNull($result['error']); + self::assertStringContainsString('invalid JSON', $result['error']); + } + + /** + * A failure on page two must keep page one AND report the error. Throwing + * the partial away would turn a half-read feed into "no advisories". + */ + public function testKeepsWhatItReadBeforeAFailureAndStillReportsIt(): void { + $page1 = json_encode([$this->advisory('GHSA-aaaa', [['package' => 'Mail', 'patched' => '1.0.1']])], JSON_THROW_ON_ERROR); + + $result = $this->feed([ + ['body' => $page1, 'link' => '; rel="next"'], + ['body' => '', 'status' => 500], + ])->fetchAll(); + + self::assertNotNull($result['error'], 'the failure must be reported'); + self::assertArrayHasKey('mail', $result['advisories'], 'page one must survive the page-two failure'); + } +} From 3a7eedbd021b8e488449ae84b8cafd1f64d6dcca Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 09:44:55 +0200 Subject: [PATCH 4/6] feat(advisories): configurable check interval and a weekly digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the advisory work behind #160/#166. Two decisions taken by the maintainer: 6-hour default with 1-24 configurable, and urgent notifications immediately plus a weekly digest for everything else. INTERVAL. AdvisorySettingsStore holds it, AdvisoryRefreshJob reads it at construction (TimedJob fixes its interval there), and GET/PUT /api/advisory/settings expose it alongside the supported bounds — a client that hardcodes the range drifts from the server the first time it changes. The store CLAMPS out-of-range values; the endpoint REJECTS them. That is deliberate rather than inconsistent. A UI that asks for 48 hours and is answered "200 OK" while the server stored 24 has been lied to, so the API says no. But a value that arrives another way — `occ config:app:set`, or a future release narrowing the range — must still produce a working schedule: refusing to run because a stored number is out of bounds would silently stop security checks, which is worse than checking at a neighbouring frequency. DIGEST. AdvisoryDigestNotifier summarises the informational advisories — apps with a security history whose installed version is already safe — once a week. Urgent advisories keep their own immediate path and are excluded here, so nobody is told twice. Three behaviours worth naming, each of which is a way this could have gone quietly wrong: - a week with nothing informational sends NOTHING and does NOT advance the clock, so the first week with something to report sends immediately rather than waiting out a window consumed by silence; - a dispatch that reached nobody does not advance the clock either, so one transient failure does not suppress a second week as well; - the digest rate-limits itself, so the job may call it on every sweep — up to 24 times a day — and it still sends once a week. It defaults ON: the urgent path fires regardless, and the digest is what carries everything else. Defaulting it off would hide that material behind a setting nobody knows exists. UI in the Settings panel, with the bounds read from the server. The frontend sends '1'/'0' rather than a JSON boolean, because PHP casts a JSON `false` to '' and the server would read that as "unspecified" — the same trap already documented on the auto-update kill switch. Verification: 564 unit tests, 1125 assertions, no failure outside tests/unit/Command (19 errors there are a missing symfony/console in the local vendor copy). psalm clean on every file added or touched; gate-16 count=0; openapi regenerated (22 routes); frontend builds and the new strings were confirmed present in the built bundle. Three psalm errors in AdvisoryNotifier.php are NOT touched: they appear locally and not in CI, which resolves OCP types against a real Nextcloud tree rather than the app's stubs. --- lib/BackgroundJob/AdvisoryRefreshJob.php | 20 +- lib/Controller/ApiController.php | 94 ++++ .../Advisory/AdvisoryDigestNotifier.php | 144 ++++++ .../Advisory/AdvisorySettingsStore.php | 112 +++++ openapi.json | 412 ++++++++++++++++++ src/App.vue | 120 +++++ .../BackgroundJob/AdvisoryRefreshJobTest.php | 7 + tests/unit/Controller/ApiTest.php | 3 + .../Advisory/AdvisoryDigestNotifierTest.php | 185 ++++++++ .../Advisory/AdvisorySettingsStoreTest.php | 110 +++++ 10 files changed, 1204 insertions(+), 3 deletions(-) create mode 100644 lib/Service/Advisory/AdvisoryDigestNotifier.php create mode 100644 lib/Service/Advisory/AdvisorySettingsStore.php create mode 100644 tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php create mode 100644 tests/unit/Service/Advisory/AdvisorySettingsStoreTest.php diff --git a/lib/BackgroundJob/AdvisoryRefreshJob.php b/lib/BackgroundJob/AdvisoryRefreshJob.php index e303d098..56d130e1 100644 --- a/lib/BackgroundJob/AdvisoryRefreshJob.php +++ b/lib/BackgroundJob/AdvisoryRefreshJob.php @@ -12,9 +12,11 @@ namespace OCA\AppVersions\BackgroundJob; +use OCA\AppVersions\Service\Advisory\AdvisoryDigestNotifier; use OCA\AppVersions\Service\Advisory\AdvisoryNotifier; use OCA\AppVersions\Service\Advisory\AdvisoryResultStore; use OCA\AppVersions\Service\Advisory\AdvisoryService; +use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; @@ -29,8 +31,6 @@ * @psalm-api */ class AdvisoryRefreshJob extends TimedJob { - /** Re-resolve advisories every 6 hours. */ - private const INTERVAL_SECONDS = 6 * 60 * 60; /** * Wall-clock ceiling for the sweep, in seconds. @@ -50,11 +50,16 @@ public function __construct( ITimeFactory $time, private AdvisoryService $advisoryService, private AdvisoryNotifier $advisoryNotifier, + private AdvisoryDigestNotifier $digestNotifier, private AdvisoryResultStore $resultStore, + private AdvisorySettingsStore $settings, private LoggerInterface $logger, ) { parent::__construct($time); - $this->setInterval(self::INTERVAL_SECONDS); + // Administrator-settable (6h default, 1–24 supported). Read here + // because TimedJob fixes its interval at construction; the next run + // after a settings change therefore picks up the new value. + $this->setInterval($this->settings->getIntervalSeconds()); } /** @@ -98,6 +103,15 @@ protected function run($argument): void { if ($fired > 0) { $this->logger->info('AdvisoryRefreshJob: raised advisory notifications', ['count' => $fired]); } + + // The weekly digest of everything that is NOT urgent. It rate- + // limits itself, so calling it on every sweep is correct — the + // sweep runs up to 24 times a day and the digest still sends once + // a week. + $digested = $this->digestNotifier->sendIfDue($correlations, $this->time->getTime()); + if ($digested > 0) { + $this->logger->info('AdvisoryRefreshJob: sent the weekly advisory digest', ['recipients' => $digested]); + } } catch (\Throwable $error) { $this->logger->error('AdvisoryRefreshJob: refresh failed', ['message' => $error->getMessage()]); } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 3b62acd0..c73b13e2 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -17,6 +17,7 @@ use OCA\AppVersions\Db\Pat; use OCA\AppVersions\Db\PatMapper; use OCA\AppVersions\Service\Advisory\AdvisoryResultStore; +use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore; use OCA\AppVersions\Service\AutoUpdate\AutoUpdateSettingsStore; use OCA\AppVersions\Service\AutoUpdate\AutoUpdateWindow; use OCA\AppVersions\Service\Cache\ArtifactCache; @@ -63,6 +64,7 @@ public function __construct( private PatExpiryEvaluator $patExpiryEvaluator, private DiscoveryAggregator $discoveryAggregator, private AdvisoryResultStore $advisoryResultStore, + private AdvisorySettingsStore $advisorySettingsStore, private AuditEntryMapper $auditEntryMapper, private PinStore $pinStore, private IAppManager $appManager, @@ -801,6 +803,98 @@ public function updateAutoUpdateSettings(?string $enabled = null): DataResponse ]); } + /** + * Returns the advisory check settings: how often the sweep runs and + * whether the weekly digest is sent (admin-only). + * + * The supported bounds travel WITH the values. A client that has to + * hardcode the range in order to build a control will drift from the + * server the first time the range changes. + * + * @return DataResponse|DataResponse + * + * 200: Advisory settings returned + * 403: Caller is not an administrator + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + #[ApiRoute(verb: 'GET', url: '/api/advisory/settings')] + public function advisorySettings(): DataResponse { + if (!$this->isAdmin()) { + return new DataResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + return new DataResponse([ + 'intervalHours' => $this->advisorySettingsStore->getIntervalHours(), + 'minIntervalHours' => AdvisorySettingsStore::MIN_INTERVAL_HOURS, + 'maxIntervalHours' => AdvisorySettingsStore::MAX_INTERVAL_HOURS, + 'digestEnabled' => $this->advisorySettingsStore->isDigestEnabled(), + ]); + } + + /** + * Updates the advisory check settings (admin-only). + * + * An out-of-range interval is REJECTED here rather than silently clamped, + * because a UI that asks for 48 hours and is answered "200 OK" while the + * server stores 24 has been lied to. The store still clamps, for values + * that arrive by other routes such as `occ config:app:set`. + * + * @param ?string $intervalHours How often the sweep runs, in hours. Omitted leaves it unchanged. + * @param ?string $digestEnabled Whether the weekly digest is sent ('1'/'0'). Omitted leaves it unchanged. + * @return DataResponse|DataResponse|DataResponse + * + * 200: Advisory settings updated + * 400: intervalHours outside the supported range + * 403: Caller is not an administrator + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + #[ApiRoute(verb: 'PUT', url: '/api/advisory/settings')] + #[PasswordConfirmationRequired] + public function updateAdvisorySettings(?string $intervalHours = null, ?string $digestEnabled = null): DataResponse { + if (!$this->isAdmin()) { + return new DataResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + if ($intervalHours !== null && $intervalHours !== '') { + if (!is_numeric($intervalHours)) { + return new DataResponse( + ['message' => 'intervalHours must be a number.'], + Http::STATUS_BAD_REQUEST + ); + } + $hours = (int)$intervalHours; + if ($hours < AdvisorySettingsStore::MIN_INTERVAL_HOURS || $hours > AdvisorySettingsStore::MAX_INTERVAL_HOURS) { + return new DataResponse( + ['message' => sprintf( + 'intervalHours must be between %d and %d.', + AdvisorySettingsStore::MIN_INTERVAL_HOURS, + AdvisorySettingsStore::MAX_INTERVAL_HOURS, + )], + Http::STATUS_BAD_REQUEST + ); + } + $this->advisorySettingsStore->setIntervalHours($hours); + } + + // Same empty-string-is-an-explicit-false handling as the auto-update + // kill switch above: PHP casts a JSON `false` to "", not "0". + if ($digestEnabled !== null) { + $digestParam = ($digestEnabled === '') ? '0' : $digestEnabled; + $this->advisorySettingsStore->setDigestEnabled( + $this->readBinaryBool($digestParam, $this->advisorySettingsStore->isDigestEnabled()), + ); + } + + return new DataResponse([ + 'intervalHours' => $this->advisorySettingsStore->getIntervalHours(), + 'minIntervalHours' => AdvisorySettingsStore::MIN_INTERVAL_HOURS, + 'maxIntervalHours' => AdvisorySettingsStore::MAX_INTERVAL_HOURS, + 'digestEnabled' => $this->advisorySettingsStore->isDigestEnabled(), + ]); + } + /** * Lists PATs visible to the current admin, redacted, with derived * `expiryState`/`daysRemaining`; see "PAT management API" and diff --git a/lib/Service/Advisory/AdvisoryDigestNotifier.php b/lib/Service/Advisory/AdvisoryDigestNotifier.php new file mode 100644 index 00000000..954648da --- /dev/null +++ b/lib/Service/Advisory/AdvisoryDigestNotifier.php @@ -0,0 +1,144 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +use OCA\AppVersions\AppInfo\Application; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\Notification\IManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * A weekly summary of advisories that are NOT urgent — apps with a security + * history whose installed version is already safe. + * + * WHY A DIGEST RATHER THAN MORE NOTIFICATIONS. {@see AdvisoryNotifier} fires + * immediately when an installed version is actually inside an affected range, + * and that must stay rare enough to be read. Informational advisories are far + * more numerous — the published feed averages several new records a month + * across 53 packages — so notifying on each would train administrators to + * dismiss the channel that carries the urgent ones. + * + * Like the urgent notifier, this class has NO dependency on any installer or + * version-mutation service: it can only inform. + * + * @psalm-api + */ +class AdvisoryDigestNotifier { + private const CONFIG_LAST_SENT = 'advisory.digest_last_sent'; + + /** Seven days. */ + private const DIGEST_INTERVAL_SECONDS = 7 * 24 * 60 * 60; + + public function __construct( + private IManager $notificationManager, + private IGroupManager $groupManager, + private IAppConfig $config, + private AdvisorySettingsStore $settings, + private LoggerInterface $logger, + ) { + } + + /** + * Sends the digest if one is due, and reports how many admins received it. + * + * Returns 0 — without sending — when the digest is disabled, when one was + * sent inside the last seven days, or when there is nothing informational + * to report. A digest that says "nothing to report" every week is how a + * channel stops being read. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + * @param array, recommendedVersion: ?string, error: ?string}> $correlations + */ + public function sendIfDue(array $correlations, int $now): int { + if (!$this->settings->isDigestEnabled()) { + return 0; + } + + $lastSent = $this->config->getValueInt(Application::APP_ID, self::CONFIG_LAST_SENT, 0); + if ($lastSent > 0 && ($now - $lastSent) < self::DIGEST_INTERVAL_SECONDS) { + return 0; + } + + $informational = array_values(array_filter( + $correlations, + static fn (array $entry): bool => $entry['state'] === AdvisoryService::STATE_AVAILABLE + && $entry['advisories'] !== [], + )); + if ($informational === []) { + // Nothing to say. The clock is NOT advanced, so the first week + // with something to report sends immediately rather than waiting + // out a window that was consumed by silence. + return 0; + } + + $appCount = count($informational); + $advisoryCount = array_sum(array_map( + static fn (array $entry): int => count($entry['advisories']), + $informational, + )); + + $fired = 0; + foreach ($this->adminUids() as $uid) { + if ($this->fire($uid, $appCount, $advisoryCount)) { + $fired++; + } + } + + // Only record a send that actually reached someone. Advancing the + // clock on a failed dispatch would suppress the next seven days of + // digests as well. + if ($fired > 0) { + $this->config->setValueInt(Application::APP_ID, self::CONFIG_LAST_SENT, $now); + } + + return $fired; + } + + private function fire(string $uid, int $appCount, int $advisoryCount): bool { + try { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setDateTime(new \DateTime()) + ->setUser($uid) + ->setObject('advisory_digest', (string)$appCount) + ->setSubject('advisory_digest', [ + 'apps' => $appCount, + 'advisories' => $advisoryCount, + ]); + $this->notificationManager->notify($notification); + + return true; + } catch (Throwable $error) { + $this->logger->warning('AdvisoryDigestNotifier: could not notify admin', [ + 'user' => $uid, + 'message' => $error->getMessage(), + ]); + + return false; + } + } + + /** + * @return list + */ + private function adminUids(): array { + $uids = []; + foreach ($this->groupManager->get('admin')?->getUsers() ?? [] as $user) { + $uids[] = $user->getUID(); + } + + return $uids; + } +} diff --git a/lib/Service/Advisory/AdvisorySettingsStore.php b/lib/Service/Advisory/AdvisorySettingsStore.php new file mode 100644 index 00000000..51f3e82c --- /dev/null +++ b/lib/Service/Advisory/AdvisorySettingsStore.php @@ -0,0 +1,112 @@ + + * + * SPDX-FileCopyrightText: 2025 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + + +namespace OCA\AppVersions\Service\Advisory; + +use OCA\AppVersions\AppInfo\Application; +use OCP\IAppConfig; + +/** + * Administrator-settable behaviour for advisory checking: how often the sweep + * runs, and whether the weekly digest is sent. + * + * The interval is CLAMPED rather than validated-and-rejected. A stored value + * outside the supported range — from a hand-edited `occ config:app:set`, or a + * future version narrowing the range — must still produce a working schedule; + * refusing to run because a number is out of bounds would silently stop + * security checks altogether, which is a worse outcome than checking at a + * neighbouring frequency. + * + * @psalm-api + */ +class AdvisorySettingsStore { + public const CONFIG_INTERVAL_HOURS = 'advisory.interval_hours'; + public const CONFIG_DIGEST_ENABLED = 'advisory.digest_enabled'; + + /** Four sweeps a day: well inside the window in which an advisory matters. */ + public const DEFAULT_INTERVAL_HOURS = 6; + + /** + * Hourly is the floor because the sweep pulls the App Store catalogue and + * the GHSA feed; more often than that is sustained upstream traffic for no + * practical gain. + */ + public const MIN_INTERVAL_HOURS = 1; + + /** + * Daily is the ceiling. Beyond it, "your instance is checked for known + * vulnerabilities" stops being a claim the feature can honestly make. + */ + public const MAX_INTERVAL_HOURS = 24; + + public function __construct( + private IAppConfig $config, + ) { + } + + /** + * The configured sweep interval in hours, clamped to the supported range. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function getIntervalHours(): int { + $stored = $this->config->getValueInt( + Application::APP_ID, + self::CONFIG_INTERVAL_HOURS, + self::DEFAULT_INTERVAL_HOURS, + ); + + return max(self::MIN_INTERVAL_HOURS, min(self::MAX_INTERVAL_HOURS, $stored)); + } + + /** + * The sweep interval in seconds, for scheduling. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function getIntervalSeconds(): int { + return $this->getIntervalHours() * 3600; + } + + /** + * Stores the sweep interval. Out-of-range values are clamped, not + * rejected, so the stored value always describes what actually happens. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function setIntervalHours(int $hours): void { + $clamped = max(self::MIN_INTERVAL_HOURS, min(self::MAX_INTERVAL_HOURS, $hours)); + $this->config->setValueInt(Application::APP_ID, self::CONFIG_INTERVAL_HOURS, $clamped); + } + + /** + * Whether the weekly digest of non-urgent advisories is sent. + * + * Defaults to ON. The urgent path (an installed version actually in an + * affected range) notifies immediately regardless; the digest is what + * carries everything else, and defaulting it off would make that material + * invisible unless an admin went looking for a setting they do not know + * exists. + * + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function isDigestEnabled(): bool { + return $this->config->getValueBool(Application::APP_ID, self::CONFIG_DIGEST_ENABLED, true); + } + + /** + * @spec openspec/specs/security-advisory-correlation/spec.md + */ + public function setDigestEnabled(bool $enabled): void { + $this->config->setValueBool(Application::APP_ID, self::CONFIG_DIGEST_ENABLED, $enabled); + } +} diff --git a/openapi.json b/openapi.json index ce5d8183..849cb02d 100644 --- a/openapi.json +++ b/openapi.json @@ -3396,6 +3396,418 @@ } } }, + "/ocs/v2.php/apps/app_versions/api/advisory/settings": { + "get": { + "operationId": "api-advisory-settings", + "summary": "Returns the advisory check settings: how often the sweep runs and whether the weekly digest is sent (admin-only).", + "description": "The supported bounds travel WITH the values. A client that has to hardcode the range in order to build a control will drift from the server the first time the range changes.\nopenspec/specs/security-advisory-correlation/spec.md\nThis endpoint requires admin access", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Advisory settings returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "intervalHours", + "minIntervalHours", + "maxIntervalHours", + "digestEnabled" + ], + "properties": { + "intervalHours": { + "type": "integer", + "format": "int64" + }, + "minIntervalHours": { + "type": "integer", + "format": "int64" + }, + "maxIntervalHours": { + "type": "integer", + "format": "int64" + }, + "digestEnabled": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Caller is not an administrator", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "api-update-advisory-settings", + "summary": "Updates the advisory check settings (admin-only).", + "description": "An out-of-range interval is REJECTED here rather than silently clamped, because a UI that asks for 48 hours and is answered \"200 OK\" while the server stores 24 has been lied to. The store still clamps, for values that arrive by other routes such as `occ config:app:set`.\nopenspec/specs/security-advisory-correlation/spec.md\nThis endpoint requires admin access\nThis endpoint requires password confirmation", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "intervalHours": { + "type": "string", + "nullable": true, + "default": null, + "description": "How often the sweep runs, in hours. Omitted leaves it unchanged." + }, + "digestEnabled": { + "type": "string", + "nullable": true, + "default": null, + "description": "Whether the weekly digest is sent ('1'/'0'). Omitted leaves it unchanged." + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Advisory settings updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "intervalHours", + "minIntervalHours", + "maxIntervalHours", + "digestEnabled" + ], + "properties": { + "intervalHours": { + "type": "integer", + "format": "int64" + }, + "minIntervalHours": { + "type": "integer", + "format": "int64" + }, + "maxIntervalHours": { + "type": "integer", + "format": "int64" + }, + "digestEnabled": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "intervalHours outside the supported range", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Caller is not an administrator", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/app_versions/api/pats": { "get": { "operationId": "api-list-pats", diff --git a/src/App.vue b/src/App.vue index 71a6e1d4..02117e40 100644 --- a/src/App.vue +++ b/src/App.vue @@ -511,6 +511,76 @@ const loadAdvisories = async (): Promise => { } } +// ── Advisory check settings ─────────────────────────────────────────────── +// The supported range comes from the SERVER rather than being hardcoded here: +// a client that pins its own bounds drifts from the server the first time the +// range changes, and then rejects values the server would have accepted. +const advisoryIntervalInput = ref('6') +const advisoryDigestEnabled = ref(true) +const advisoryMinInterval = ref(1) +const advisoryMaxInterval = ref(24) +const advisorySavedInterval = ref('6') +const advisorySavedDigest = ref(true) +const isSavingAdvisorySettings = ref(false) +const advisorySettingsError = ref('') +const advisorySettingsNotice = ref('') + +const isAdvisoryIntervalValid = computed((): boolean => { + const raw = advisoryIntervalInput.value.trim() + if (!/^\d+$/.test(raw)) { + return false + } + const hours = Number(raw) + return hours >= advisoryMinInterval.value && hours <= advisoryMaxInterval.value +}) + +const isAdvisorySettingsDirty = computed((): boolean => + advisoryIntervalInput.value.trim() !== advisorySavedInterval.value + || advisoryDigestEnabled.value !== advisorySavedDigest.value) + +const loadAdvisorySettings = async (): Promise => { + try { + const response = await fetch(apiUrl(withOcsJson('/ocs/v2.php/apps/app_versions/api/advisory/settings')), { headers: { ...ocsHeaders, Accept: 'application/json' }, signal: AbortSignal.timeout(BACKGROUND_FETCH_TIMEOUT_MS) }) + const payload = await unwrapOcsResponse<{ intervalHours: number, minIntervalHours: number, maxIntervalHours: number, digestEnabled: boolean }>(response) + advisoryMinInterval.value = payload.minIntervalHours + advisoryMaxInterval.value = payload.maxIntervalHours + advisoryIntervalInput.value = String(payload.intervalHours) + advisorySavedInterval.value = String(payload.intervalHours) + advisoryDigestEnabled.value = payload.digestEnabled + advisorySavedDigest.value = payload.digestEnabled + } catch { + // Non-fatal: the settings control simply keeps its defaults. + } +} + +const saveAdvisorySettings = async (): Promise => { + isSavingAdvisorySettings.value = true + advisorySettingsError.value = '' + advisorySettingsNotice.value = '' + try { + const response = await fetch(apiUrl(withOcsJson('/ocs/v2.php/apps/app_versions/api/advisory/settings')), { + method: 'PUT', + headers: { ...ocsHeaders, 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + intervalHours: advisoryIntervalInput.value.trim(), + // '1'/'0' rather than a JSON boolean: PHP casts a JSON `false` + // to '' and the server would read that as "unspecified". + digestEnabled: advisoryDigestEnabled.value ? '1' : '0', + }), + }) + const payload = await unwrapOcsResponse<{ intervalHours: number, digestEnabled: boolean }>(response) + advisoryIntervalInput.value = String(payload.intervalHours) + advisorySavedInterval.value = String(payload.intervalHours) + advisoryDigestEnabled.value = payload.digestEnabled + advisorySavedDigest.value = payload.digestEnabled + advisorySettingsNotice.value = t('app_versions', 'Advisory settings saved.') + } catch (error) { + advisorySettingsError.value = error instanceof Error ? error.message : String(error) + } finally { + isSavingAdvisorySettings.value = false + } +} + // Unix seconds of the last completed sweep; null means none has completed. const advisoriesCheckedAt = ref(null) // True only when the fetch itself failed — never merely because the map is empty. @@ -1490,6 +1560,7 @@ onMounted(async () => { void loadAdvisories().catch(() => undefined) void loadPins().catch(() => undefined) void loadPolicies().catch(() => undefined) + void loadAdvisorySettings().catch(() => undefined) }) watch([safeModeEnabled, installedVersion, selectedVersion], () => { @@ -1679,6 +1750,49 @@ watch(dryRunEnabled, () => { @click="saveAutoUpdateSettings"> {{ t('app_versions', 'Save') }} + +

{{ t('app_versions', 'Security advisory checks') }}

+

+ {{ t('app_versions', 'App Versions checks published Nextcloud security advisories against your installed versions and notifies administrators immediately when an installed version is affected.') }} +

+ +

+ {{ t('app_versions', 'Enter a whole number of hours between {min} and {max}.', { min: advisoryMinInterval, max: advisoryMaxInterval }) }} +

+ +

+ {{ advisorySettingsError }} +

+

+ {{ advisorySettingsNotice }} +

+ + {{ t('app_versions', 'Save') }} +
@@ -2148,6 +2262,12 @@ watch(dryRunEnabled, () => { margin-top: 8px; } +/* Separates the advisory settings from the auto-update block above, which is + a different subject sharing the same panel. */ +.advisorySettingsHeading { + margin-top: 24px; +} + /* Freshness line for the advisory snapshot. Muted, because it is context for the badges rather than a finding of its own — but always present, since the age of a security answer is part of the answer. */ diff --git a/tests/unit/BackgroundJob/AdvisoryRefreshJobTest.php b/tests/unit/BackgroundJob/AdvisoryRefreshJobTest.php index 7731e1fe..2dc4bb85 100644 --- a/tests/unit/BackgroundJob/AdvisoryRefreshJobTest.php +++ b/tests/unit/BackgroundJob/AdvisoryRefreshJobTest.php @@ -5,7 +5,9 @@ namespace OCA\AppVersions\Tests\Unit\BackgroundJob; use OCA\AppVersions\BackgroundJob\AdvisoryRefreshJob; +use OCA\AppVersions\Service\Advisory\AdvisoryDigestNotifier; use OCA\AppVersions\Service\Advisory\AdvisoryNotifier; +use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore; use OCA\AppVersions\Service\Advisory\AdvisoryResultStore; use OCA\AppVersions\Service\Advisory\AdvisoryService; use OCP\AppFramework\Utility\ITimeFactory; @@ -34,11 +36,16 @@ private function runJob( $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturn($now); + $settings = $this->createMock(AdvisorySettingsStore::class); + $settings->method('getIntervalSeconds')->willReturn(6 * 3600); + $job = new AdvisoryRefreshJob( $time, $service, $notifier ?? $this->createMock(AdvisoryNotifier::class), + $this->createMock(AdvisoryDigestNotifier::class), $store, + $settings, $logger ?? $this->createMock(LoggerInterface::class), ); diff --git a/tests/unit/Controller/ApiTest.php b/tests/unit/Controller/ApiTest.php index d988d795..16313405 100644 --- a/tests/unit/Controller/ApiTest.php +++ b/tests/unit/Controller/ApiTest.php @@ -9,6 +9,7 @@ use OCA\AppVersions\Db\AuditEntryMapper; use OCA\AppVersions\Db\PatMapper; use OCA\AppVersions\Service\Advisory\AdvisoryResultStore; +use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore; use OCA\AppVersions\Service\AutoUpdate\AutoUpdateSettingsStore; use OCA\AppVersions\Service\Cache\ArtifactCache; use OCA\AppVersions\Service\Discovery\DiscoveryAggregator; @@ -67,6 +68,7 @@ private function buildController( $this->createMock(PatExpiryEvaluator::class), $this->createMock(DiscoveryAggregator::class), $this->createMock(AdvisoryResultStore::class), + $this->createMock(AdvisorySettingsStore::class), $auditEntryMapper ?? $this->createMock(AuditEntryMapper::class), $pinStore ?? $this->createMock(PinStore::class), $appManager ?? $this->createMock(IAppManager::class), @@ -113,6 +115,7 @@ private function buildAdminController( $this->createMock(PatExpiryEvaluator::class), $this->createMock(DiscoveryAggregator::class), $this->createMock(AdvisoryResultStore::class), + $this->createMock(AdvisorySettingsStore::class), $auditEntryMapper ?? $this->createMock(AuditEntryMapper::class), $pinStore ?? $this->createMock(PinStore::class), $appManager ?? $this->createMock(IAppManager::class), diff --git a/tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php b/tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php new file mode 100644 index 00000000..6003d0c4 --- /dev/null +++ b/tests/unit/Service/Advisory/AdvisoryDigestNotifierTest.php @@ -0,0 +1,185 @@ + */ + private array $stored = []; + + private int $notifyCalls = 0; + + /** + * @param list $admins + */ + private function notifier(bool $digestEnabled = true, array $admins = ['alice', 'bob'], bool $notifyThrows = false): AdvisoryDigestNotifier { + $this->notifyCalls = 0; + + $notification = $this->createMock(INotification::class); + foreach (['setApp', 'setDateTime', 'setUser', 'setObject', 'setSubject'] as $setter) { + $notification->method($setter)->willReturnSelf(); + } + + $manager = $this->createMock(IManager::class); + $manager->method('createNotification')->willReturn($notification); + $manager->method('notify')->willReturnCallback(function () use ($notifyThrows): void { + $this->notifyCalls++; + if ($notifyThrows) { + throw new \RuntimeException('notifications app is gone'); + } + }); + + $users = array_map(function (string $uid): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + + return $user; + }, $admins); + + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn($users); + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('get')->willReturn($group); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->stored[$key] ?? $default), + ); + $config->method('setValueInt')->willReturnCallback( + function (string $app, string $key, int $value): bool { + $this->stored[$key] = $value; + + return true; + }, + ); + + $settings = $this->createMock(AdvisorySettingsStore::class); + $settings->method('isDigestEnabled')->willReturn($digestEnabled); + + return new AdvisoryDigestNotifier( + $manager, + $groupManager, + $config, + $settings, + $this->createMock(LoggerInterface::class), + ); + } + + /** + * @param list $advisories + */ + private function correlation(string $appId, string $state, array $advisories = []): array { + return [ + 'appId' => $appId, + 'installedVersion' => '1.0.0', + 'state' => $state, + 'advisories' => $advisories, + 'recommendedVersion' => null, + 'error' => null, + ]; + } + + private function informational(string $appId): array { + return $this->correlation($appId, AdvisoryService::STATE_AVAILABLE, [ + ['id' => 'GHSA-' . $appId, 'severity' => 'medium', 'summary' => 'Historic issue'], + ]); + } + + public function testSendsToEveryAdminWhenInformationalAdvisoriesExist(): void { + $notifier = $this->notifier(); + + $sent = $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000); + + $this->assertSame(2, $sent, 'both admins must be notified'); + $this->assertSame(2, $this->notifyCalls); + } + + /** + * The urgent path already notifies immediately for these. Including them + * would mean an admin is told twice about the same thing. + */ + public function testIgnoresVulnerableEntriesWhichTheUrgentPathAlreadyCovers(): void { + $notifier = $this->notifier(); + + $sent = $notifier->sendIfDue([ + 'mail' => $this->correlation('mail', AdvisoryService::STATE_VULNERABLE, [ + ['id' => 'GHSA-x', 'severity' => 'high', 'summary' => 'Urgent'], + ]), + ], 1_700_000_000); + + $this->assertSame(0, $sent); + } + + public function testSendsNothingWhenThereIsNothingInformationalToReport(): void { + $notifier = $this->notifier(); + + $sent = $notifier->sendIfDue(['mail' => $this->correlation('mail', AdvisoryService::STATE_NONE)], 1_700_000_000); + + $this->assertSame(0, $sent, 'a digest that says "nothing to report" weekly is how a channel stops being read'); + } + + /** + * A quiet week must NOT consume the window. Otherwise the first week with + * something to say waits another seven days. + */ + public function testAQuietWeekDoesNotAdvanceTheClock(): void { + $notifier = $this->notifier(); + $notifier->sendIfDue(['mail' => $this->correlation('mail', AdvisoryService::STATE_NONE)], 1_700_000_000); + + $sent = $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_001); + + $this->assertSame(2, $sent, 'the digest must send as soon as there is something to report'); + } + + public function testDoesNotResendInsideTheWeek(): void { + $notifier = $this->notifier(); + $this->assertSame(2, $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000)); + + $again = $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000 + self::WEEK - 60); + + $this->assertSame(0, $again); + } + + public function testSendsAgainAfterAWeek(): void { + $notifier = $this->notifier(); + $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000); + + $again = $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000 + self::WEEK); + + $this->assertSame(2, $again); + } + + public function testSendsNothingWhenTheDigestIsDisabled(): void { + $notifier = $this->notifier(digestEnabled: false); + + $this->assertSame(0, $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000)); + $this->assertSame(0, $this->notifyCalls); + } + + /** + * A dispatch that reached nobody must not suppress the next seven days as + * well — that would turn one transient failure into a silent fortnight. + */ + public function testAFailedDispatchDoesNotConsumeTheWindow(): void { + $notifier = $this->notifier(notifyThrows: true); + $this->assertSame(0, $notifier->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_000)); + + $working = $this->notifier(); + $this->assertSame(2, $working->sendIfDue(['mail' => $this->informational('mail')], 1_700_000_060)); + } +} diff --git a/tests/unit/Service/Advisory/AdvisorySettingsStoreTest.php b/tests/unit/Service/Advisory/AdvisorySettingsStoreTest.php new file mode 100644 index 00000000..949fd958 --- /dev/null +++ b/tests/unit/Service/Advisory/AdvisorySettingsStoreTest.php @@ -0,0 +1,110 @@ + */ + private array $stored = []; + + private function store(): AdvisorySettingsStore { + $config = $this->createMock(IAppConfig::class); + $config->method('getValueInt')->willReturnCallback( + fn (string $app, string $key, int $default = 0): int => (int)($this->stored[$key] ?? $default), + ); + $config->method('setValueInt')->willReturnCallback( + function (string $app, string $key, int $value): bool { + $this->stored[$key] = $value; + + return true; + }, + ); + $config->method('getValueBool')->willReturnCallback( + fn (string $app, string $key, bool $default = false): bool => (bool)($this->stored[$key] ?? $default), + ); + $config->method('setValueBool')->willReturnCallback( + function (string $app, string $key, bool $value): bool { + $this->stored[$key] = $value; + + return true; + }, + ); + + return new AdvisorySettingsStore($config); + } + + public function testDefaultsToSixHours(): void { + $this->assertSame(6, $this->store()->getIntervalHours()); + $this->assertSame(6 * 3600, $this->store()->getIntervalSeconds()); + } + + public function testStoresAndReadsBackAnIntervalInRange(): void { + $store = $this->store(); + $store->setIntervalHours(12); + + $this->assertSame(12, $store->getIntervalHours()); + $this->assertSame(12 * 3600, $store->getIntervalSeconds()); + } + + /** + * A value outside the range must still yield a WORKING schedule. Refusing + * to run because a stored number is out of bounds would silently stop + * security checks — worse than checking at a neighbouring frequency. + * + * @dataProvider outOfRangeValues + */ + public function testClampsRatherThanRefusing(int $requested, int $expected): void { + $store = $this->store(); + $store->setIntervalHours($requested); + + $this->assertSame($expected, $store->getIntervalHours()); + } + + /** + * @return array + */ + public static function outOfRangeValues(): array { + return [ + 'below the floor' => [0, 1], + 'negative' => [-5, 1], + 'above the ceiling' => [48, 24], + 'absurd' => [100000, 24], + 'at the floor' => [1, 1], + 'at the ceiling' => [24, 24], + ]; + } + + /** + * A value written by `occ config:app:set`, bypassing the API's validation, + * must still read back as something schedulable. + */ + public function testClampsAValueThatArrivedOutsideTheApi(): void { + $this->stored[AdvisorySettingsStore::CONFIG_INTERVAL_HOURS] = 999; + + $this->assertSame(24, $this->store()->getIntervalHours()); + } + + /** + * Defaults ON: the urgent path notifies regardless, and the digest is what + * carries everything else. Defaulting it off would hide that material + * behind a setting nobody knows exists. + */ + public function testTheDigestDefaultsToEnabled(): void { + $this->assertTrue($this->store()->isDigestEnabled()); + } + + public function testTheDigestCanBeTurnedOffAndBackOn(): void { + $store = $this->store(); + + $store->setDigestEnabled(false); + $this->assertFalse($store->isDigestEnabled()); + + $store->setDigestEnabled(true); + $this->assertTrue($store->isDigestEnabled()); + } +} From aa2798503104f3ba8629e749806090065f70196b Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 10:00:00 +0200 Subject: [PATCH 5/6] test(e2e): cover the advisory settings, and drop an unused fixture param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions: - the interval control renders with the bounds the SERVER reports, rather than a range hardcoded in the test as well. A test that pins its own copy of the range stops catching a server-side change to it. - an out-of-range interval is REFUSED (400) and leaves the stored value untouched. That is the half of the clamp/reject split which is easy to regress into a silent clamp, and a 200 that stored something else is exactly the lie the endpoint exists to avoid. Also removes the unused `page` fixture from the job-registration test added in #164 — it drives occ, not the browser, and eslint was right about it. --- tests/e2e/advisories.spec.ts | 56 +++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/e2e/advisories.spec.ts b/tests/e2e/advisories.spec.ts index 222bc406..2d9b713f 100644 --- a/tests/e2e/advisories.spec.ts +++ b/tests/e2e/advisories.spec.ts @@ -69,7 +69,61 @@ test.describe('security advisories', () => { ).toContain('checkedAt') }) - test('the refresh job is registered, so a snapshot will actually be produced', async ({ page }) => { + test('the advisory check interval is administrator-configurable', async ({ page }) => { + await openSettings(page) + await openTab(page, 'Apps') + + const interval = page.getByTestId('advisory-interval') + await expect(interval, 'the interval control must be present in settings').toBeVisible() + + // The bounds come from the server, so assert the control reflects them + // rather than hardcoding 1..24 here as well — a test that pins its own + // copy of the range stops catching a server-side change. + const settings = await page.request.get('/ocs/v2.php/apps/app_versions/api/advisory/settings?format=json', { + headers: { 'OCS-APIRequest': 'true' }, + }) + expect(settings.ok(), 'GET /api/advisory/settings should answer 200').toBeTruthy() + const data = (await settings.json())?.ocs?.data + expect(data, 'the OCS envelope should carry a data object').toBeTruthy() + expect(Object.keys(data)).toEqual( + expect.arrayContaining(['intervalHours', 'minIntervalHours', 'maxIntervalHours', 'digestEnabled']), + ) + + await expect(interval).toHaveAttribute('min', String(data.minIntervalHours)) + await expect(interval).toHaveAttribute('max', String(data.maxIntervalHours)) + await expect(interval).toHaveValue(String(data.intervalHours)) + + await expect(page.getByTestId('advisory-digest-enabled')).toBeVisible() + }) + + test('an out-of-range interval is refused rather than silently clamped', async ({ page }) => { + await openSettings(page) + + const settings = await page.request.get('/ocs/v2.php/apps/app_versions/api/advisory/settings?format=json', { + headers: { 'OCS-APIRequest': 'true' }, + }) + const before = (await settings.json())?.ocs?.data + + // A UI told "200 OK" while the server stored something else has been + // lied to. The store still clamps for values arriving via occ; the API + // must say no. + const rejected = await page.request.put('/ocs/v2.php/apps/app_versions/api/advisory/settings?format=json', { + headers: { 'OCS-APIRequest': 'true', 'Content-Type': 'application/json' }, + data: { intervalHours: String(before.maxIntervalHours + 24) }, + }) + expect( + rejected.status(), + 'an interval above the supported maximum must be refused, not accepted-and-clamped', + ).toBe(400) + + // And the stored value must be untouched by the refusal. + const after = await page.request.get('/ocs/v2.php/apps/app_versions/api/advisory/settings?format=json', { + headers: { 'OCS-APIRequest': 'true' }, + }) + expect((await after.json())?.ocs?.data?.intervalHours).toBe(before.intervalHours) + }) + + test('the refresh job is registered, so a snapshot will actually be produced', async () => { // Without this the endpoint is honest but permanently empty: it would // report "not checked yet" forever and nothing would ever say why. // Reading the job list proves the sweep is wired to cron, which no From 8143d9e7b0664ff7a8453e21619c518affc7065f Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 21 Aug 2026 10:21:09 +0200 Subject: [PATCH 6/6] fix(advisories): do not promote the settings store to a property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit psalm in CI: UnusedProperty — $settings is read only in the constructor, where TimedJob's interval is fixed. Keeping a reference implied the job could re-read the setting during its life, which it cannot: a changed interval takes effect because the job is constructed afresh on the next run, not because anything re-reads it. Local psalm did not flag this. CI resolves OCP types against a real Nextcloud tree and sees the whole class; the local stub tree does not. --- lib/BackgroundJob/AdvisoryRefreshJob.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/BackgroundJob/AdvisoryRefreshJob.php b/lib/BackgroundJob/AdvisoryRefreshJob.php index 56d130e1..eba8c3ad 100644 --- a/lib/BackgroundJob/AdvisoryRefreshJob.php +++ b/lib/BackgroundJob/AdvisoryRefreshJob.php @@ -52,14 +52,17 @@ public function __construct( private AdvisoryNotifier $advisoryNotifier, private AdvisoryDigestNotifier $digestNotifier, private AdvisoryResultStore $resultStore, - private AdvisorySettingsStore $settings, + // NOT promoted to a property: the interval is read exactly once, here. + // TimedJob fixes its interval at construction, so keeping a reference + // would suggest the job can re-read the setting mid-life, which it + // cannot — the next run after a change picks up the new value because + // the job is constructed afresh. + AdvisorySettingsStore $settings, private LoggerInterface $logger, ) { parent::__construct($time); - // Administrator-settable (6h default, 1–24 supported). Read here - // because TimedJob fixes its interval at construction; the next run - // after a settings change therefore picks up the new value. - $this->setInterval($this->settings->getIntervalSeconds()); + // Administrator-settable: 6h default, 1–24 supported. + $this->setInterval($settings->getIntervalSeconds()); } /**