From 26f3d5ee9a370690e8fda2dce6f7049c66251c11 Mon Sep 17 00:00:00 2001 From: ciprian-cgr Date: Sat, 22 Aug 2026 12:38:13 +0200 Subject: [PATCH] Add Corbulo results Corbulo is an AI code review tool. Reviews were collected from the 50 benchmark PRs forked into the corbulo-martian-benchmark org, where the Corbulo GitHub App is installed; each PR carries exactly one review posted by corbulo-core[bot]. Adds 50 review entries (251 comments: 201 inline, 50 review bodies) to benchmark_data.json, and Corbulo to the evaluated-tools table. No other tool's data is modified. --- offline/README.md | 1 + offline/results/benchmark_data.json | 1808 ++++++++++++++++++++++++++- 2 files changed, 1808 insertions(+), 1 deletion(-) diff --git a/offline/README.md b/offline/README.md index 78b2815..4e570bd 100644 --- a/offline/README.md +++ b/offline/README.md @@ -11,6 +11,7 @@ Open replication of the code review benchmark used by companies like [Augment](h | [Claude Code](https://claude.ai) | AI assistant | | [CodeAnt](https://www.codeant.ai/) | AI code review | | [CodeRabbit](https://www.coderabbit.ai/) | AI code review | +| [Corbulo](https://corbulo.dev/) | AI code review | | [Cursor Bugbot](https://cursor.com) | AI code review | | [Cubic](https://cubic.dev/) | AI code review | | [Devin](https://devin.ai/) | AI assistant | diff --git a/offline/results/benchmark_data.json b/offline/results/benchmark_data.json index f6a1028..1b552ad 100644 --- a/offline/results/benchmark_data.json +++ b/offline/results/benchmark_data.json @@ -1789,6 +1789,60 @@ "created_at": "2026-06-28T23:12:51Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/37429", + "review_comments": [ + { + "path": "misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java", + "line": 88, + "body": "### 🔵 Low · Identifier \"santizeAnchors\" misspells \"sanitize\"\n\n\"santizeAnchors\" contains \"santize\", a misspelling of \"sanitize\" — 'santize' is a clear misspelling of 'sanitize' (missing 'i').\n\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "themes/src/main/resources-community/theme/base/account/messages/messages_zh_CN.properties", + "line": 112, + "body": "### 🟡 Medium · Locale value for \"totpStep1\" uses the wrong Chinese variant for zh_CN\n\nThe value of \"totpStep1\" in a zh_CN file: \"在您的手機上安裝以下應用程式之一:\" — The value uses Traditional Chinese characters (手機, 應用程式) while the declared locale is zh_CN (Simplified Chinese). (expected: 在您的手机上安装以下应用程序之一:)\n\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "themes/src/main/resources-community/theme/base/login/messages/messages_lt.properties", + "line": 71, + "body": "### 🟡 Medium · Locale value for \"loginTotpStep1\" is not written in lt\n\nThe value of \"loginTotpStep1\" in a lt locale file is written in Įdiekite vieną iš šių programų į savo mobilųjį telefoną:: \"Installa una delle seguenti applicazioni sul tuo cellulare:\" — Declared locale is 'lt' (Lithuanian), but the value is written entirely in Italian ('Installa una delle seguenti applicazioni sul tuo cellulare:'). The text must be translated to Lithuanian.\n\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "themes/src/main/resources-community/theme/base/account/messages/messages_lt.properties", + "line": 101, + "body": "### 🟡 Medium · Locale value for \"totpStep1\" is not written in lt\n\nThe value of \"totpStep1\" in a lt locale file is written in Įdiekite vieną iš šių programų savo mobiliajame telefone:: \"Installa una delle seguenti applicazioni sul tuo cellulare:\" — The value is written in Italian, but the declared locale is Lithuanian (lt).\n\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java", + "line": 79, + "body": "### 🔵 Low · verifySafeHtml hard-aborts the whole verification run with an unchecked RuntimeException when the derived English sibling is missing\n\nThe derived English path is computed and opened with a FileInputStream; if the file is missing or unreadable, an unchecked RuntimeException is thrown at line 79. This is not caught by verify() (which only catches IOException at lines 52-54) nor by ThemeVerifierMojo.execute() (which calls verify() with no try/catch and aggregates only messages). Thus one message file without a corresponding _en sibling aborts the entire per-file loop and the whole build, instead of adding a per-file message and letting the mojo report it in MojoFailureException. This breaks the collect-and-report convention used by verifyNoDuplicateKeys (lines 193-195). The trigger is concrete (any processed message file whose derived English sibling is absent); only corpus reachability is unverifiable from this checkout, hence confidence 50. It might be intentional to fail fast on missing English files, but the unchecked exception bypasses the established error-reporting contract.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). `VerifyMessageProperties.java:74-79` unconditionally derives the English sibling path with `replaceAll(\"resources-community\", \"resources\").replaceAll(\"_[a-zA-Z-_]*\\\\.properties\", \"_en.properties\")` and opens it in a `FileInputStream` inside a try block that catches only `IOException` at line 78, rethrowing as `new RuntimeException(\"unable to read file \" + englishFile, e)` at line 79. `verify()` at lines 46-56 only catches `IOException` around lines 49-54, so the unchecked `RuntimeException` propagates. `ThemeVerifierMojo.execute()` at lines 42-57 iterates resources/files at lines 45-52 and calls `new VerifyMessageProperties(file).verify()` at line 51 with no try/catch; only `MojoException`/`MojoFailureException` are declared. `MessagePropertiesFilter.java:27-28` shows the filter accepts any `messages_*.properties`, not just non-English files — so `messages_en.properties` files are also processed; for those, the derived sibling is `messages_en.properties` itself, which exists. The themes corpus confirms: `themes/src/main/resources-community/theme/base/login/messages/` contains `messages_ar.properties` … `messages_zh_TW.properties` but NO `messages_en.properties`; the `resources` counterpart `themes/src/main/resources/theme/base/login/messages/` contains only `messages_en.properties`. The mojo is bound to the build in both `themes/pom.xml:37-49` and `js/pom.xml:53-65`, and the module is included in the root `pom.xml` (line 294). Because the `resources-community` directory is added as a resource under a profile activated by default (`!skipCommunityTranslations`, `themes/pom.xml:54-67`), the resources iterator in the mojo traverses both `resources` and `resources-community` trees; the community tree has 30+ language files (ar, ca, cs, da, de, …) in `themes/src/main/resources-community/theme/base/login/messages/` with no `messages_en.properties` sibling, so the derived `_en` path resolves to `themes/src/main/resources/theme/base/login/messages/messages_en.properties`, which exists. Each message file processed from the community tree on the mojo path triggers the English sibling computation; the corpus shows the corresponding `resources/…/messages_en.properties` exists for the base login theme. Whether any current corpus file empirically lacks a derived English sibling could not be exhaustively confirmed for every theme directory; however, the code path for the missing-English-file case is real and reachable: a missing/renamed `_en` file or a future translation without an English source aborts the whole run. `verifySafeHtml()` is also exercised concurrently with, and after, `verifyNoDuplicateKeys()` inside the same `verify()` call, so the hard abort bypasses the collect-and-report convention established at lines 193-195 and `ThemeVerifierMojo.execute()` lines 54-56.\n_Impact: Any message file without its derived English sibling (e.g. a missing/renamed `messages_en.properties`, or a translation file added before the English source) aborts the entire theme-verifier run and the whole Maven build with an unchecked `RuntimeException`, instead of producing the per-file validation message and a `MojoFailureException` listing all problems — so a single corpus gap masks all other validation errors and breaks the build with an unhelpful stack trace._\n_Queries: read_file(misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java) · read_file(misc/theme-verifier/src/main/java/org/keycloak/themeverifier/ThemeVerifierMojo.java) · read_file(misc/theme-verifier/src/main/java/org/keycloak/themeverifier/MessagePropertiesFilter.java) · grep(\"VerifyMessageProperties|\\.verify\\(\\)\", path=misc/theme-verifier) · glob(\"misc/theme-verifier/**/*.java\") · read_file(misc/theme-verifier/pom.xml) · grep(\"theme-verifier|verify-theme\", glob=pom.xml) · read_file(themes/pom.xml) · read_file(js/pom.xml) · glob(\"themes/src/main/resources*/**/messages_*.properties\") · glob(\"**/messages_de.properties\") · grep(\".\", glob=\"**/messages_en.properties\") · list_dir(themes/src/main/resources-community/theme/base/login/messages) · list_dir(themes/src/main/resources/theme/base/login/messages)_\n\n> **Fix** — Catch the IOException and add a per-file message to the messages list (or throw a checked exception that the mojo can aggregate), instead of throwing an unchecked RuntimeException.\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java", + "line": 124, + "body": "### 🔵 Low · Choice-clause strip bypasses the sanitizer gate\n\nThe normalizeValue method strips choice clauses with a regex that consumes everything up to the first '}', including embedded HTML markup. This happens before the value reaches the sanitizer, and since the English value is stripped identically, containsHtml sees no tags, so POLICY_NO_HTML is used and sanitized == value, so no violation is reported. This silently accepts an XSS vector for the two key families. The comment shows the intent was to suppress the choice-syntax '<', but hiding arbitrary HTML inside the clause is an unintended side effect. Whether the clause text is later rendered as HTML is outside this checkout, but the verifier's purpose is to flag illegal HTML and it fails to do so here.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). VerifyMessageProperties.java:124-126: for keys `linkExpirationFormatter.timePeriodUnit*` and `error-invalid-multivalued-size`, `normalizeValue` executes `value.replaceAll(\"\\\\{[0-9]+,choice,[^}]*}\", \"...\")`. `verifySafeHtml` calls `normalizeValue` on both the translation (line 84) and the English value (line 86) before `containsHtml(englishValue)` (line 91) and before `Objects.equals(sanitized, value)` (line 99). The regex `[^}]*` consumes any text inside the clause up to the first `}` — including embedded HTML tags — so HTML inside a choice clause is absent from both sides when the policy is chosen (→ POLICY_NO_HTML) and when the sanitizer output is compared (sanitized == value → no violation). The gate is structurally blind to HTML inside choice clauses for these keys. ThemeVerifierMojo.java:35-51 is `@Mojo(name=\"verify-theme\", defaultPhase=LifecyclePhase.INSTALL)`; it iterates module resources, filters `*.properties`, and calls `new VerifyMessageProperties(file).verify()` per file, throwing `MojoFailureException` on any message. The plugin is bound in `themes/pom.xml:37-49` and `js/pom.xml:55`. The scanned resources contain the named keys with real choice clauses: `themes/src/main/resources/theme/base/admin/messages/messages_en.properties:69` (`{2,choice,0#values|1#value|1 **Fix** — Strip only the choice-syntax '<' character, or sanitize the value before stripping the choice clause, or verify the stripped content contains no HTML before removing it.\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": "misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java", + "line": 155, + "body": "### 🟡 Medium · Inside a while (matcher.find()) loop the exact CharSequence that backs the matcher (matcher = ANCHOR_PATTERN.matcher(value)) is reassigned via value = value.replaceFirst(...)/replaceAll(...). The Matcher keeps scanning the original pre-mutation sequence with its old region/indices, so the returned string diverges from what the loop is iterating — a stale matcher that yields partial or wrong results. Use the Matcher.appendReplacement/appendTail idiom, or a single replaceAll outside the loop.\n\n\n[CWE-691: Insufficient Control Flow Management] Inside a while (matcher.find()) loop the exact CharSequence that backs the matcher (matcher = ANCHOR_PATTERN.matcher(value)) is reassigned via value = value.replaceFirst(...)/replaceAll(...). The Matcher keeps scanning the original pre-mutation sequence with its old region/indices, so the returned string diverges from what the loop is iterating — a stale matcher that yields partial or wrong results. Use the Matcher.appendReplacement/appendTail idiom, or a single replaceAll outside the loop.\n\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. Reachable — ThemeVerifierMojo.java:35 (@Mojo verify-theme, install phase), :51 `new VerifyMessageProperties(file).verify()`, bound in js/pom.xml:55-61 and themes/pom.xml:39-45; call chain reaches santizeAnchors at VerifyMessageProperties.java:88, loop at :153-160. Triggerable — ANCHOR_PATTERN `]*>` (:145); loop body with the `value = value.replaceFirst(...)` mutation (:155) runs for any translation value containing anchor tags with positionally-matching English anchors (fixtures changedAnchor_*.properties show anchors in real message files). Harm — refuted by the semantics of the exact lines: matcher iterates the original (immutable) value's anchors in positional order; each iteration's replaceFirst removes the earliest remaining occurrence of the quoted anchor text, and since any literal occurrence of that tag text is itself an anchor already processed/removed (or the current one), each removal deletes exactly the current anchor. Returned string = original minus each matched anchor — the intended result, equivalent to the appendReplacement idiom the finding recommends; matcher never reads indices into the mutated string. Error branch (:157-158) breaks and records the message; ThemeVerifierMojo.java:54-56 throws MojoFailureException, so the partially-stripped return is discarded. No realistic case (duplicate anchors, reordered anchors, prefix/superstring tag texts) produces a divergent or wrong result string.\n_Impact: n/a — the stale matcher does not produce wrong results: each replaceFirst removes exactly the current anchor, so the returned string is the intended value-minus-anchors; on error the build fails by design and the return value is unused._\n_Queries: read_file(misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java) · glob(**/VerifyMessageProperties.java) · grep(VerifyMessageProperties, misc/theme-verifier) · grep(santizeAnchors|new VerifyMessageProperties|\\.verify\\(, misc/theme-verifier) · read_file(misc/theme-verifier/src/main/java/org/keycloak/themeverifier/ThemeVerifierMojo.java) · read_file(misc/theme-verifier/src/test/java/org/keycloak/themeverifier/VerifyMessagePropertiesTest.java) · read_file(misc/theme-verifier/src/test/resources/changedAnchor_de.properties) · read_file(misc/theme-verifier/src/test/resources/changedAnchor_en.properties) · grep(verify-theme|theme-verifier)_\n\n", + "created_at": "2026-08-21T22:36:03Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Identifier \"santizeAnchors\" misspells \"sanitize\" | `misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java:88` |\n| 🟡 Medium | Locale value for \"totpStep1\" uses the wrong Chinese variant for zh_CN | `themes/src/main/resources-community/theme/base/account/messages/messages_zh_CN.properties:112` |\n| 🟡 Medium | Locale value for \"loginTotpStep1\" is not written in lt | `themes/src/main/resources-community/theme/base/login/messages/messages_lt.properties:71` |\n| 🟡 Medium | Locale value for \"totpStep1\" is not written in lt | `themes/src/main/resources-community/theme/base/account/messages/messages_lt.properties:101` |\n| 🔵 Low | verifySafeHtml hard-aborts the whole verification run with an unchecked RuntimeException when the derived English sibling is missing | `misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java:79` |\n| 🔵 Low | Choice-clause strip bypasses the sanitizer gate | `misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java:124` |\n| 🟡 Medium | Inside a while (matcher.find()) loop the exact CharSequence that backs the matcher (matcher = ANCHOR_PATTERN.matcher(value)) is reassigned via value = value.replaceFirst(...)/replaceAll(...). The Matcher keeps scanning the original pre-mutation sequence with its old region/indices, so the returned string diverges from what the loop is iterating — a stale matcher that yields partial or wrong results. Use the Matcher.appendReplacement/appendTail idiom, or a single replaceAll outside the loop. | `misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java:155` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 50 (D) | 50 (D) | -0.0 |\n| Runtime | 100 (A+) | 99 (A+) | -0.7 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **76 (B)** | **76 (B)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:36:03Z" + } + ] } ] }, @@ -3247,6 +3301,54 @@ "created_at": "2026-06-28T21:20:12Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/37634", + "review_comments": [ + { + "path": "services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java", + "line": 78, + "body": "### 🟡 Medium · Test contract for unknown/incorrect grant type is internally contradictory with a production encoder\n\ntestIncorrectGrantType asserts 'ac' must throw while testUnknownGrantType asserts 'na' must decode, but both pass only because before() seeds a synthetic 'na' entry. The production parser never falls back to UNKNOWN generically, so the test does not exercise the production fallback path and the real factory mappings are never tested.\n\nThis may be intentional test isolation, but it masks the production defect in F1.\n\n> **Fix** — Remove the synthetic 'na' entry and test the production factory's actual mappings, or add a test that exercises the encode path with a missing grant type.\n", + "created_at": "2026-08-21T22:36:07Z" + }, + { + "path": "server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeFactory.java", + "line": 33, + "body": "### 🟡 Medium · Abstract getShortcut() breaks third-party OAuth2GrantTypeFactory implementations (AbstractMethodError)\n\nThe new abstract method getShortcut() is invoked unconditionally for every registered factory in DefaultTokenContextEncoderProviderFactory.postInit. Any third-party factory compiled against a previous Keycloak version won't have an overriding getShortcut(), causing AbstractMethodError at server startup.\n\nAdditionally, a null-returning factory crashes startup with NPE at ConcurrentHashMap.put(null, ...) instead of the intended IllegalStateException validation. This is a real extension-compat break, though whether maintainers accept it is a policy decision.\n\n> **Fix** — Provide a default implementation of getShortcut() on the interface, or validate null returns with an explicit IllegalStateException before putting into the map.\n", + "created_at": "2026-08-21T22:36:07Z" + }, + { + "path": "services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java", + "line": 57, + "body": "### 🔵 Low · ClientCredentials/Permission (UMA) paths never set Constants.GRANT_TYPE — token IDs encode grant type \"na\" instead of \"cc\"\n\nThe client-credentials and UMA/permission token-issuance paths (via AuthorizationTokenService) never write Constants.GRANT_TYPE, so the new encoder falls back to UNKNOWN (\"na\") instead of the intended \"cc\" shortcut. This silently degrades token metadata for those flows, causing downstream consumers to misclassify every such token as unknown. The mechanism is confirmed from the read code; the impact is metadata/log degradation rather than a crash. It might be intentional if the \"na\" fallback is acceptable for these flows, but the PR's own added shortcut suggests the intent was to encode \"cc\" here.This comment also covers: ClientCredentialsGrantTypeFactory.getShortcut() returning \"cc\" is unreachable on the client-credentials flow\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). 1. Encoder fallback (cause confirmed): DefaultTokenContextEncoderProvider.java:57-60 — getTokenContextFromClientSessionContext reads clientSessionContext.getAttribute(Constants.GRANT_TYPE, String.class); when null, sets grantType = UNKNOWN (\"na\", defined at line 33). 2. Encoder is invoked on every token issuance: TokenManager.java:1052-1054 — initToken calls encoder.getTokenContextFromClientSessionContext(clientSessionCtx, …) then token.id(encoder.encodeTokenId(tokenCtx)). initToken is reached via createClientAccessToken (TokenManager.java:611-616) and via AccessTokenResponseBuilder.generateAccessToken() (TokenManager.java:1203-1208 → createClientAccessToken). 3. Client-credentials path never sets GRANT_TYPE: ClientCredentialsGrantType.process() (line 60) overrides the base process() and builds the response directly at lines 140-141 via tokenManager.responseBuilder(...).generateAccessToken() — it never calls OAuth2GrantTypeBase.createTokenResponse(), which is the only normal-path GRANT_TYPE write (OAuth2GrantTypeBase.java:109). 4. UMA/permission path never sets GRANT_TYPE: AuthorizationTokenService.createAuthorizationResponse() line 357-358 builds the RPT via tokenManager.responseBuilder(...).generateAccessToken() without setting GRANT_TYPE on the context. 5. Exhaustive enumeration of every GRANT_TYPE write in the repo (5 sites): TokenManager.java:248 (refresh token), StandardTokenExchangeProvider.java:237 (token exchange), OAuth2GrantTypeBase.java:109 (generic grant base), PreAuthorizedCodeGrantType.java:82, ResourceOwnerPasswordCredentialsGrantType.java:134. None of these is the client-credentials path, the UMA/permission path, or the other direct callers (PolicyEvaluationService.java:289, KeycloakIdentity.java:148). 6. The \"cc\" shortcut is real and registered: ClientCredentialsGrantTypeFactory.java:40 returns \"cc\"; the factory is listed in META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory, so DefaultTokenContextEncoderProviderFactory.postInit (lines 70-77) registers \"cc\" as a valid shortcut. The intent to encode \"cc\" for this flow is present; the flow just never supplies the grant-type attribute that would select it. 7. No crash: \"na\" is registered in the factory's shortcut maps (DefaultTokenContextEncoderProviderFactory.java:78-79), so encodeTokenId resolves it and no exception occurs — the token ID is silently emitted with \"na\" as the grant-type field.\n_Impact: Every client-credentials and UMA/permission access token is issued with grant-type \"na\" embedded in its token ID instead of \"cc\"/\"pg\" — wrong token metadata in real operation, causing downstream consumers (the provider's own decoder, event/tracing/log consumers, clients inspecting token IDs) to misclassify all such tokens as unknown grant._\n_Queries: read_file(services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java) · read_file(services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderFactory.java) · read_file(services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java:60-179) · read_file(services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantTypeFactory.java) · read_file(services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java:280-369) · read_file(services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java:595-654) · read_file(services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java:1035-1109) · grep(pattern=\"setAttribute\\(Constants\\.GRANT_TYPE|setAttribute\\(OAuth2Constants\\.GRANT_TYPE\") · grep(pattern=\"GRANT_TYPE\", glob=\"**/grants/*.java\") · grep(pattern=\"createClientAccessToken\") · grep(pattern=\"initToken\", glob=\"**/TokenManager.java\") · grep(pattern=\"getTokenContextFromTokenId|encodeTokenId\")_\n\n> **Fix** — Set Constants.GRANT_TYPE on the client-credentials and permission paths before token generation, e.g. in AuthorizationTokenService.createAuthorizationResponse or in the respective grant-type process methods, mirroring the wiring added in OAuth2GrantTypeBase.createTokenResponse.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37634__20260821/blob/daffb05b5ad091d3ec09933c3c99c2b54f448696/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantTypeFactory.java#L36-L40\n\n
\n", + "created_at": "2026-08-21T22:36:07Z" + }, + { + "path": "testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java", + "line": 483, + "body": "### 🔵 Low · AssertEvents matcher uses wrong substring window and inverted polarity, making grant-type assertions pass vacuously\n\nThe matcher extracts characters at 0-based indices 3-4 via substring(3,5), but the encoded token format places the grant shortcut at indices 4-5 (as the decoder itself parses at substring(4,6)). Additionally, the polarity is inverted: matchesSafely returns false exactly when the grant matches, and true when it does not.\n\nBecause token-type shortcuts always end in 't', the extracted window always starts with 't', while all expected grant shortcuts ('ac', 'dg', 'rt', 'ci') start with other letters, making the return false branch unreachable. The matcher therefore falls through to isUUID() and accepts any token ID of shape 'xxxxxx:', silently weakening four integration assertions that never verify the PR's core feature.\n\nThis could be intentional if the test author intended only to check UUID format, but the code comment and the four call sites clearly intend to verify the grant shortcut, so it is a real defect.\n\n> **Fix** — Change substring(3,5) to substring(4,6) and invert the condition to 'if (!items[0].substring(4, 6).equals(expectedGrantShortcut)) return false;'\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. isAccessTokenId grant-shortcut substring/equality inverted**\n\nThe matcher extracts the wrong substring (indices 3-5 instead of 4-6) for the grant shortcut, making the grant check vacuous, and the boolean logic is inverted so a genuine match returns false. This allows a wrong grant shortcut in the new token-id encoding to pass the testsuite undetected.\n\nIt is a test-oracle defect with no production path.\n\n
\n", + "created_at": "2026-08-21T22:36:07Z" + }, + { + "path": "services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java", + "line": 73, + "body": "### 🟠 High · Duplicated null-check on `grantType` leaves `rawTokenId` unvalidated\n\n`grantType` is null-checked more than once while the sibling parameter `rawTokenId` — which is stored to a field — is never passed to any null-check. This is a copy-pasted validation that forgot to re-point at `rawTokenId`: the duplicate check is dead and `rawTokenId` can be null.\n\n> **Fix** — Change the duplicated `Objects.requireNonNull(grantType)` to validate `rawTokenId` instead.\n", + "created_at": "2026-08-21T22:36:07Z" + }, + { + "path": "services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java", + "line": 78, + "body": "### 🟡 Medium · This negative test asserts a call throws (fail() after the call) but catches a broad supertype (RuntimeException/Exception/Throwable) with an empty body. Catching the supertype means an unrelated failure — a refactor NPE, an assertion error, any RuntimeException — also satisfies the test, hiding regressions instead of proving the intended exception. Catch the narrowest expected type and assert on the caught exception (type/message).\n\n\n[CWE-396: Declaration of Catch for Generic Exception] This negative test asserts a call throws (fail() after the call) but catches a broad supertype (RuntimeException/Exception/Throwable) with an empty body. Catching the supertype means an unrelated failure — a refactor NPE, an assertion error, any RuntimeException — also satisfies the test, hiding regressions instead of proving the intended exception. Catch the narrowest expected type and assert on the caught exception (type/message).\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). DefaultTokenContextEncoderProviderTest.java:78-84 — `try { … getTokenContextFromTokenId(\"ofrtac:5678\"); Assert.fail(...); } catch (RuntimeException iae) { // ignored }`: catch type is the broad `RuntimeException` with an empty body, not the narrowest expected type. Resolved production target: DefaultTokenContextEncoderProvider.getTokenContextFromTokenId (grep found the only implementation at services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java:66). For \"ofrtac:5678\", `getGrantTypeByShortcut(\"ac\")` returns null (test factory registers only \"ro\"/\"cc\"/\"na\" — test lines 43-45) → throws `IllegalArgumentException` at line 93, the intended exception. The same method throws `IllegalArgumentException` from two other validation sites (lines 85, 89: session-type, token-type); `catch (RuntimeException)` cannot distinguish which site fired nor any other RuntimeException (refactor NPE, IllegalStateException) — all satisfy the test. Correction to a sub-detail: `Assert.fail()` throws `AssertionError` (an Error, not RuntimeException), so it is NOT swallowed — the test still fails when nothing is thrown. The core mechanism (any unrelated RuntimeException satisfies the test) holds.\n_Impact: A regression that changes the exception type/cause in `getTokenContextFromTokenId` (refactor NPE, wrong validation site firing) is silently swallowed by this negative test, so the product regression ships undetected in CI._\n_Queries: read_file(services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java) · read_file(services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java) · grep(pattern=\"getGrantTypeByShortcut|grantsByShortcuts|getSessionTypeByShortcut|getTokenTypeByShortcut\", glob=\"**/DefaultTokenContextEncoderProviderFactory.java\") · grep(pattern=\"getTokenContextFromTokenId\", glob=\"**/*.java\")_\n\n", + "created_at": "2026-08-21T22:36:08Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Test contract for unknown/incorrect grant type is internally contradictory with a production encoder | `services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java:78` |\n| 🟡 Medium | Abstract getShortcut() breaks third-party OAuth2GrantTypeFactory implementations (AbstractMethodError) | `server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeFactory.java:33` |\n| 🔵 Low | ClientCredentials/Permission (UMA) paths never set Constants.GRANT_TYPE — token IDs encode grant type \"na\" instead of \"cc\" | `services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java:57` |\n| 🔵 Low | AssertEvents matcher uses wrong substring window and inverted polarity, making grant-type assertions pass vacuously | `testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java:483` |\n| 🟠 High | Duplicated null-check on `grantType` leaves `rawTokenId` unvalidated | `services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java:73` |\n| 🟡 Medium | This negative test asserts a call throws (fail() after the call) but catches a broad supertype (RuntimeException/Exception/Throwable) with an empty body. Catching the supertype means an unrelated failure — a refactor NPE, an assertion error, any RuntimeException — also satisfies the test, hiding regressions instead of proving the intended exception. Catch the narrowest expected type and assert on the caught exception (type/message). | `services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java:78` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 93 (A) | -6.6 |\n| Test Coverage | 52 (D+) | 100 (A+) | +47.7 |\n| Code Smell | 84 (B+) | 84 (B+) | -0.0 |\n| **Overall** | **76 (B)** | **83 (B+)** | **+7.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:36:08Z" + } + ] } ] }, @@ -4705,6 +4807,30 @@ "created_at": "2026-06-28T23:26:03Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/38446", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java", + "line": 19, + "body": "### 🟠 High · Unchecked Optional.get() on recovery authn code credential can throw NoSuchElementException\n\nThe Optional returned by RecoveryAuthnCodesUtils.getCredential(user) is empty whenever the user holds no recovery-code credential in federated or local storage, and the login form is rendered on the authenticator's failure/brute-force paths and after all-codes-used removal, leading to a concrete NoSuchElementException. This may be intentional if the form is only reachable when a credential exists, but the code lacks a guard.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). The bean is constructed at `services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java:272`: `attributes.put(\"recoveryAuthnCodesInputBean\", new RecoveryAuthnCodeInputLoginBean(session, realm, user));` That construction happens inside `case LOGIN_RECOVERY_AUTHN_CODES_INPUT:` (line 271), which is reached via `createLoginRecoveryAuthnCode()` → `createResponse(LoginFormsPages.LOGIN_RECOVERY_AUTHN_CODES_INPUT)` (line 656-657). The only production caller of `createLoginRecoveryAuthnCode()` is `RecoveryAuthnCodesFormAuthenticator.createLoginForm(...)` at `services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java:138`. This method is invoked (a) from `authenticate()` (line 37-39), (b) from `action()` via the failure path `failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseChallenge)` (line 74-77), (c) from the brute-force-disabled path (lines 63-65, 96-97), and (d) from `isDisabledByBruteForce` (line 110). The bean itself is at `RecoveryAuthnCodeInputLoginBean.java:19`: `RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get())` — an unconditional `Optional.get()`. `getCredential()` at `server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java:56-62` returns `Optional.empty()` when neither the federated credential stream nor local stored credentials contain a `RecoveryAuthnCodesCredentialModel.TYPE` credential. There is no guard before the `.get()`. Keycloak's flow framework gates configured execution via `DefaultAuthenticationFlow.processSingleFlowExecutionModel`, which calls `authenticator.configuredFor(session, realm, authUser)` before invoking `authenticator.authenticate(context)` (lines 440-455). The `RecoveryAuthnCodesFormAuthenticator.configuredFor` (line 148-150) checks `user.credentialManager().isConfiguredFor(RecoveryAuthnCodesCredentialModel.TYPE)`, and `RecoveryAuthnCodesCredentialProvider.isConfiguredFor` (lines 94-96) checks local stored credentials via `getStoredCredentialsByTypeStream(credentialType).anyMatch(...)`. However, `RecoveryAuthnCodesUtils.getCredential` and `RecoveryAuthnCodesCredentialProvider.isConfiguredFor` consult different sources: the former also checks `getFederatedCredentialsStream()` first; the latter only checks local stored credentials. So there is a credible misalignment where `configuredFor` could pass for a federated credential while `getCredential` finds one (that direction is safe), and a direction where `configuredFor` passes locally but `getCredential` also finds the local one (safe). The empty case would arise when `configuredFor` was not consulted (e.g., `factory.isUserSetupAllowed()` is true, and the execution is configured with `SETUP_REQUIRED` handling at lines 445-450 in `DefaultAuthenticationFlow`), or when the flow is invoked with a user whose credential was concurrently removed, or when the form is rendered before the credential exists. The strongest concrete trigger is the all-codes-used path: at `RecoveryAuthnCodesFormAuthenticator.java:80-91`, the code already fully anticipates that `getCredential` can return empty and that the credential can be missing/fully consumed — it null-checks the optional result (`optUserCredentialFound.isPresent()`), removes a fully-consumed credential, and falls into `addRequiredAction(CONFIGURE_RECOVERY_AUTHN_CODES)`. In the strict reading, no path in this authenticator renders the form after removing the credential without re-running `configuredFor` first, but the same authenticator's own code acknowledges the \"no credential\" condition as real, and the framework's `SETUP_REQUIRED` path (line 445-450) shows the form can be reached precisely where the \"credential exists\" invariant the bean assumes can be absent (a user with no recovery-codes credential, `isUserSetupAllowed()==true`, required execution). The `getNextRecoveryAuthnCode()` on line 21 is also an unchecked `Optional.get()`, but that mechanism is not asserted by this finding. The defect mechanism is confirmed: line 19 unconditionally calls `.get()` on an `Optional` whose contract (`RecoveryAuthnCodesUtils.getCredential` returns `Optional.empty()` when no credential exists) is explicitly documented. Whether a concrete no-credential state is actually reachable at render time depends on the exact admin flow configuration and lifecycle timing (a concurrent removal, a SETUP_REQUIRED-ish rendering, a federated-only credential race), which the framework plumbing supports but the code path here does not definitively establish from static inspection alone.\n_Impact: When `RecoveryAuthnCodesUtils.getCredential(user)` returns empty (no federated or local recovery-code credential, or all codes consumed and removed), the login page render throws `NoSuchElementException` at `RecoveryAuthnCodeInputLoginBean.java:19`, crashing the login form instead of presenting it; the user cannot complete authentication via recovery codes._\n_Queries: read_file(services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java) · glob(**/RecoveryAuthnCodeInputLoginBean.java) · grep(RecoveryAuthnCodeInputLoginBean, services/src/main/java) · glob(**/RecoveryAuthnCodesUtils.java) · read_file(server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java) · grep(LOGIN_RECOVERY_AUTHN_CODES_INPUT, services/src/main/java) · grep(setLoginRecoveryAuthnCodesInput, services/src/main/java) · read_file(FreeMarkerLoginFormsProvider.java:240-299) · grep(createLoginRecoveryAuthnCode, services/src/main/java) · read_file(RecoveryAuthnCodesFormAuthenticator.java) · grep(configuredFor, server-spi-private/src/main/java/org/keycloak/authentication) · glob(**/DefaultAuthenticationFlow.java) · read_file(DefaultAuthenticationFlow.java) · read_file(RecoveryAuthnCodesCredentialProvider.java:80-119) · grep(auth-recovery-authn-code-form, repo) · grep(configuredFor, model/storage/src/main/java/org/keycloak/credential/UserCredentialManager.java) · read_file(UserCredentialManager.java:1-80)_\n\n> **Fix** — Add an isPresent() check before calling get(), or use orElseThrow with a meaningful message, or handle the empty case gracefully.\n", + "created_at": "2026-08-21T21:33:57Z" + }, + { + "path": "server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java", + "line": 115, + "body": "### 🔴 Critical · Raw recovery codes stored in plaintext in federated storage\n\nIn CredentialHelper.createRecoveryCodesCredential (server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java:115-131), the raw generated recovery codes are serialized into JSON and passed to user.credentialManager().updateCredential(...). When the user storage provider accepts the credential, the raw codes are stored in federated storage. The BackwardsCompatibilityUserStorage.updateCredential (testsuite/.../BackwardsCompatibilityUserStorage.java:193-201) stores input.getChallengeResponse() (raw JSON of unhashed codes) directly into recoveryCodesModel.setCredentialData(...). In contrast, the local storage path (RecoveryAuthnCodesCredentialModel.createFromValues at server-spi/.../RecoveryAuthnCodesCredentialModel.java:58-85) hashes each code via RecoveryAuthnCodesUtils.hashRawCode before storing. The federated path bypasses hashing entirely. Anyone with read access to the federated store can use the codes to authenticate as the user.This comment also covers: Federated credential model reconstruction fails — secretData never populated\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). Raw codes enter the updateCredential path unhashed: CredentialHelper.java:119 serializes the raw generatedCodes list to JSON; CredentialHelper.java:123-125 wraps them in new UserCredentialModel(\"\", type, recoveryCodesJson) and calls user.credentialManager().updateCredential(...). The federated branch receives the raw codes and skips the hashed local path: UserCredentialManager.updateCredential (model/storage/.../UserCredentialManager.java:83-99) for a federated user whose storage provider implements CredentialInputUpdater and supportsCredentialType(...) calls the provider's updateCredential(realm, user, input) with the raw UserCredentialModel (line 92); a true return short-circuits before the local CredentialInputUpdater chain (lines 96-98). The reference/provided federated provider stores the raw codes in plaintext: BackwardsCompatibilityUserStorage.updateCredential (line 193-201) sets recoveryCodesModel.setCredentialData(input.getChallengeResponse()) — the raw JSON of unhashed codes persisted in credentialData. Its isValid (line 326-340) deserializes the raw list back and checks generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse())) (line 340) — plaintext equality, no hashing involved. The local path hashes: RecoveryAuthnCodesCredentialModel.createFromValues (server-spi/.../RecoveryAuthnCodesCredentialModel.java:65-68) hashes each code via RecoveryAuthnCodesUtils.hashRawCode (RS512, RecoveryAuthnCodesUtils.java:31-38). Reachability confirmed: the only non-test call site is RecoveryAuthnCodesAction.java:116 — createRecoveryCodesCredential(...) inside processAction, a required-action provider registered in DefaultRequiredActions.java:83. The feature is Profile.Feature.RECOVERY_CODES (common/.../Profile.java:96, Type.PREVIEW); testsuite/.../BackwardsCompatibilityUserStorageTest.java enables it via @EnableFeature(RECOVERY_CODES) and drives exactly this federated path. The flow is triggered when a federated user needs to configure recovery codes (admin-initiated required action or RecoveryAuthnCodesFormAuthenticator.java:90-91 adding the action when codes run out) and the storage provider supports the type (BackwardsCompatibilityUserStorage.supportsCredentialType returns true for RecoveryAuthnCodesCredentialModel.TYPE, line 111-120). Secondary sub-claim refuted: BackwardsCompatibilityUserStorage.getCredentials (lines 237-241) rebuilds the model via RecoveryAuthnCodesCredentialModel.createFromValues(...) from the raw codes — re-hashing them, populating secretData, and producing a well-formed credential that RecoveryAuthnCodesCredentialProvider.isValid can consume. Reconstruction does not fail for this provider; secretData is populated at read time. The plaintext-at-rest problem is unaffected: the persisted credentialData still contains the raw codes.\n_Impact: Unhashed recovery codes persisted at rest in federated storage are directly usable for authentication; the equivalent local path stores RS512 hashes only. Anyone with read access to the federated store (admin, backup, dump) can authenticate as the user — a second-factor bypass._\n_Queries: read_file(server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java, offset=90, limit=70) · read_file(testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java, offset=170/60/240/319/215, various limits) · read_file(server-spi/src/main/java/org/keycloak/models/credential/RecoveryAuthnCodesCredentialModel.java) · grep(\"createRecoveryCodesCredential\") · read_file(services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java) · read_file(model/storage/src/main/java/org/keycloak/credential/UserCredentialManager.java) · read_file(server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java) · read_file(services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProvider.java) · read_file(server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java) · read_file(services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java) · grep(\"RECOVERY_CODES\", glob=\"*.java\") · read_file(common/src/main/java/org/keycloak/common/Profile.java, offset=88, limit=15) · read_file(core/src/main/java/org/keycloak/util/JsonSerialization.java) · glob(\"**/UserCredentialModel.java\") · glob(\"**/RecoveryAuthnCodesCredentialModel.java\") · glob(\"**/RecoveryAuthnCodesUtils.java\") · glob(\"**/RecoveryAuthnCodesCredentialProvider.java\") · glob(\"**/RecoveryAuthnCodesFormAuthenticator.java\") · glob(\"**/Profile.java\")_\n\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR38446__20260821/blob/ce2dcc537b4b1f98a335e88cfe8e0b804483252a/server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java#L54-L58\n\n
\n", + "created_at": "2026-08-21T21:33:57Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟠 High | Unchecked Optional.get() on recovery authn code credential can throw NoSuchElementException | `services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java:19` |\n| 🔴 Critical | Raw recovery codes stored in plaintext in federated storage | `server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java:115` |\n| 🟠 High | Recovery code consumption bypassed for federated credentials | `services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java:71` (not in the diff) |\n\n---\n\n### 🟠 High · Recovery code consumption bypassed for federated credentials\n\n`services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java:71`\n\nRecoveryAuthnCodesCredentialProvider.isValid (services/.../RecoveryAuthnCodesCredentialProvider.java:99-119) only searches getStoredCredentialsByTypeStream (local storage). When recovery codes are stored in federated storage, this provider returns false.\n\nDuring login, RecoveryAuthnCodesFormAuthenticator.isRecoveryAuthnCodeInputValid (services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java:71-72) calls credentialManager().isValid(...), which dispatches to the user storage provider first. If the user storage provider validates the code, it's removed from the toValidate list, so the local provider's isValid is never invoked with that input.\n\nThe BackwardsCompatibilityUserStorage.isValid (testsuite/.../BackwardsCompatibilityUserStorage.java:326-345) validates the code against the stored list but does not consume it (does not remove the used code). The local RecoveryAuthnCodesCredentialProvider.isValid (which would consume via removeRecoveryAuthnCode() and updateStoredCredential) is never reached for federated credentials.\n\nRecovery codes stored in federated storage can be reused indefinitely, defeating the purpose of one-time recovery codes.This comment also covers: removeStoredCredentialById doesn't remove federated recovery credentials\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 46 (D) | 44 (D-) | -2.6 |\n| Runtime | 100 (A+) | 92 (A) | -8.2 |\n| Consistency | 94 (A) | 94 (A) | -0.0 |\n| **Overall** | **75 (B-)** | **73 (B-)** | **-2.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:33:57Z" + } + ] } ] }, @@ -5863,6 +5989,30 @@ "created_at": "2026-06-28T22:40:43Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/36882", + "review_comments": [ + { + "path": "quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/UpdateCompatibilityCheck.java", + "line": 48, + "body": "### 🟡 Medium · Command handler hard-exits the JVM via `System.exit`\n\n`UpdateCompatibilityCheck.run()` is a picocli `@Command` entrypoint, but this call (via `picocli.exit`) reaches `System.exit`, which hard-exits the JVM from inside the command handler.\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. Resolved target: picocli.exit @ UpdateCompatibilityCheck.java:48/57 → org.keycloak.quarkus.runtime.cli.Picocli#exit(int) (Picocli.java:203), Keycloak's own wrapper — not the picocli library's exit. Guard: Picocli.java:204 — System.exit gated on exitCode != OK && (!isTestLaunchMode() || isRebuildCheck()); isTestLaunchMode @ Environment.java:171–173. Normal path: the same exit() is invoked by parseAndRun for every command (Picocli.java:132) — the handler's call is the designated exit path, invoked early. Contract: exit codes 0/3/4 documented as the tool's scripting contract (CompatibilityResult.java:30–35; command description, UpdateCompatibilityCheck.java:30–33). Reachable path: Main.java:71 → UpdateCompatibility.java:26–27 → KeycloakMain.main (line 94) → parseAndRun (Picocli.java:129) → run(). Cleanup: System.exit runs JVM shutdown hooks; no registered cleanup is bypassed.\n_Impact: No harmful consequence: the call resolves to the codebase's own guarded, standard exit wrapper used by parseAndRun itself, the test-runner termination is impossible by the guard at Picocli.java:204, and the exit codes are the tool's documented reporting contract._\n_Queries: read_file(quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/UpdateCompatibilityCheck.java) · grep(\"picocli\\\\.exit\", quarkus) · ast_find_symbol(\"picocli\") · grep(\"Picocli picocli|picocli =|picocli;|CommandLine picocli\", quarkus) · read_file(AbstractCommand.java) · read_file(Picocli.java, offset 85, limit 140) · read_file(Environment.java, offset 160, limit 25) · read_file(CompatibilityResult.java) · grep(\"FEATURE_DISABLED|exitCode|class CompatibilityResult\", quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/compatibility) · grep(\"UpdateCompatibilityCheck|UpdateCompatibilityMetadata|UpdateCompatibility\\\\.\", quarkus) · read_file(KeycloakMain.java, offset 60, limit 60) · read_file(AbstractUpdatesCommand.java)_\n\n> **Fix** — Return an exit code (implement `IExitCodeGenerator` or return an `int` from `call()`) rather than calling System.exit/Runtime.halt; reserve process termination for the top-level launcher.\n", + "created_at": "2026-08-21T22:35:51Z" + }, + { + "path": "quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/UpdateCompatibilityMetadata.java", + "line": 48, + "body": "### 🟡 Medium · Command handler hard-exits the JVM via `System.exit`\n\n`UpdateCompatibilityMetadata.run()` is a picocli `@Command` entrypoint, but this call (via `picocli.exit`) reaches `System.exit`, which hard-exits the JVM from inside the command handler.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). In normal (non-test) launches, `picocli.exit(4)` (UpdateCompatibilityMetadata.java:48) dispatches to `Picocli.exit(int)` (Picocli.java:203-208). Since `FEATURE_DISABLED = 4 != CommandLine.ExitCode.OK`, and `isTestLaunchMode()` is false unless the `quarkus.test.launch-mode`/`LAUNCH_MODE` sysprop is literally `\"test\"` (Environment.java:171-173), the guard passes and `System.exit(4)` runs, terminating the whole JVM from inside the command's `run()` method. The only bypass is the test-launch-mode guard at Picocli.java:204; in a test harness that does not set `LAUNCH_MODE=test`, the hard exit still fires. The flow is: root `keycloak` command (Main.java:62-72) → `update-compatibility metadata` (UpdateCompatibility.java:25-28) → `run()` (UpdateCompatibilityMetadata.java:45) → feature-disabled branch (line 46) → `picocli.exit(4)` (line 48). The `ROLING_UPDATES` feature is `Type.PREVIEW` (Profile.java:136), so the disabled-feature path is also the default path when the feature is not explicitly enabled, making the trigger the common operational case for operators running the command without preview features.\n_Impact: The entire JVM — including the embedding host or test runner — is hard-terminated with exit code 4 when the `rolling-updates` preview feature is disabled, bypassing picocli’s exit-code flow and any cleanup or error propagation; the normal return/`IExitCodeGenerator` path is never used._\n_Queries: read_file(UpdateCompatibilityMetadata.java) · grep(\"picocli.exit\") · read_file(AbstractUpdatesCommand.java) · read_file(UpdateCompatibilityCheck.java) · read_file(AbstractCommand.java) · read_file(Picocli.java) · grep(\"parseAndRun\") · read_file(KeycloakMain.java) · read_file(Environment.java) · read_file(Main.java) · read_file(UpdateCompatibility.java) · read_file(CompatibilityResult.java)_\n\n> **Fix** — Return an exit code (implement `IExitCodeGenerator` or return an `int` from `call()`) rather than calling System.exit/Runtime.halt; reserve process termination for the top-level launcher.\n", + "created_at": "2026-08-21T22:35:51Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Command handler hard-exits the JVM via `System.exit` | `quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/UpdateCompatibilityCheck.java:48` |\n| 🟡 Medium | Command handler hard-exits the JVM via `System.exit` | `quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/UpdateCompatibilityMetadata.java:48` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -1.2 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **75 (B)** | **75 (B)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:51Z" + } + ] } ] }, @@ -7384,6 +7534,42 @@ "created_at": "2026-06-28T22:50:30Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/36880", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java", + "line": 214, + "body": "### 🟡 Medium · hasPermission(client, scope) evaluates scopes in the wrong order and returns false for a direct client resource\n\nThe method constructs a ResourcePermission with resource.getScopes() (the full scope set) and evaluates it, then checks whether the requested scope appears in the returned permission scopes. If the evaluation returns only granted scopes, this could work, but the ordering of scope evaluation may cause false negatives for direct client resources.\n\nThe analysis could not fully confirm the defect without reading root.evaluatePermission, but the medium-confidence finding stands as a potential real inconsistency. It might be intentional if the evaluation semantics differ, but the risk of wrong permission denial warrants attention.\n\n> **Fix** — Verify the evaluatePermission semantics and ensure the requested scope is checked against the granted scope set correctly; consider filtering the resource scopes to only the requested scope before evaluation.\n", + "created_at": "2026-08-21T22:35:55Z" + }, + { + "path": "tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/PermissionClientTest.java", + "line": 163, + "body": "### 🔵 Low · Client listing assertion checks count, not identity\n\nThe assertion verifies only that exactly one client is visible, not that it is the intended 'myclient'. If a product-side bug swapped which client the MANAGE permission binds to, or leaked 'realmClient' while hiding 'myclient', the list would still have size 1 and the test would pass, defeating the test's stated intent to verify scoping. This is a test-robustness weakness; no product-code defect is confirmed.\n\nIt may be intentional as a lightweight check, but the identity discriminator is structurally absent.\n\n> **Fix** — Assert identity, e.g. assertThat(allClients, hasItem(hasProperty(\"id\", equalTo(myclient.getId())))).\n", + "created_at": "2026-08-21T22:35:55Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java", + "line": 138, + "body": "### 🟠 High · Incomplete ClientPermissionsV2 implementation under V2 feature: token exchange and client removal throw UnsupportedOperationException, getClientsWithPermission returns empty for per-client grants\n\nThe method iterates resources by type \"clients\" but per-client grants are stored on resources named by client UUID, so the granted resource is never matched and the method returns an empty set for per-client grants. This breaks the central listing guarantee for callers holding only per-client permissions. It may be intentional if the listing path never consults this method, but the dedicated test asserts the opposite.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). ClientPermissionsV2.java:138-142 calls resourceStore.findByType(server, AdminPermissionsSchema.CLIENTS_RESOURCE_TYPE /* \"Clients\" */, ...) and adds resource.getName() raw. AdminPermissionsSchema.java:104 (getOrCreateResource) creates per-client resources via resourceStore.create(resourceServer, name, resourceServer.getClientId()) with no setType, so per-client resources have null type. AdminPermissionsSchema.java:249-250 (in init) creates the type-level resource with new ResourceRepresentation(type, ...) + resource.setType(type), so the only resource with type \"Clients\" is the all-clients resource whose name is the literal \"Clients\". JPAResourceStore.java:253-275 (findByType) filters on the type field, so null-typed per-client resources cannot be returned. The method adds resource.getName() directly (line 140), so the matching all-clients resource would yield the literal string \"Clients\" — not a client id. V1 sibling ClientPermissions.java:680-683 uses findByType(server, \"Client\", ...) and strips RESOURCE_NAME_PREFIX from names to recover client ids. V2 has no such prefix/recovery logic. The only consumer is AvailableRoleMappingResource.java:229-231 (getRoleIdsWithPermissions), which calls realm.getClientById(cid) on the returned values. With the current V2 behavior: per-client grants produce {} (nothing listed); type-level grants produce {\"Clients\"} and realm.getClientById(\"Clients\") would return null (or throw depending on the provider). V2 is reachable only when ADMIN_FINE_GRAINED_AUTHZ_V2 is enabled (AdminPermissions.java:40-42); the feature is Type.EXPERIMENTAL (Profile.java:58), i.e., explicitly opt-in but supported. I could not locate any dedicated test for getClientsWithPermission in the V2 FGAP test suite (grep for ClientPermissionsV2|getClientsWithPermission in test files returned no matches), so I cannot confirm the claim's assertion that \"the dedicated test asserts the opposite.\"\n_Impact: With the V2 FGAP feature enabled, the admin UI \"available roles\" listing returns wrong results for fine-grained admins: per-client grants yield an empty list instead of the client roles they are permitted to map, and the all-clients grant yields the literal \"Clients\" instead of real client ids — breaking the admin-role-mapping UI (AvailableRoleMappingResource.java:229-231)._\n_Queries: read_file(ClientPermissionsV2.java) · read_file(AdminPermissionsSchema.java) · read_file(ClientPermissions.java) around line 680 · grep getClientsWithPermission · read_file(AvailableRoleMappingResource.java) · read_file(AdminPermissions.java) · grep + read_file for findByType JPA implementation · read_file(Profile.java) · grep for test references to ClientPermissionsV2 / getClientsWithPermission_\n\n> **Fix** — Map the resource name back to the client id (as V1 does) or query by the client-id-named resource directly, and ensure the grant lookup uses the same resource identity as the listing.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Client removal throws UnsupportedOperationException from ClientPermissionsV2.setPermissionsEnabled when V2 feature is enabled**\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR36880__20260821/blob/1950a511026d520a7329c7b6b9ee60a4af8f8b55/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java#L192-L196\n\n**2. ClientPermissionsV2.getClientsWithPermission returns empty set or literal 'Clients' instead of client ids**\n\nThe method filters resources by type 'Clients' but per-client V2 resources are created without a type (null), so they never match. Only the all-clients resource has type 'Clients', and its name is the literal 'Clients' — not a client id.\n\nThis means per-client grants return an empty set, and all-clients grants return the bogus literal 'Clients'. The V1 sibling implementation filters by type 'Client' and strips a name prefix to recover real client ids, establishing the intended contract. This may be intentional if the listing endpoint is not yet wired, but the structural asymmetry makes it a real defect.\n\n**3. ClientPermissionsV2.getClientsWithPermission returns empty set or literal 'Clients' instead of client ids (requeued confirmation)**\n\nThe requeued verdict confirms the same mechanism: findByType('Clients') only matches the all-clients resource (typed at AdminPermissionsSchema.java:250), while per-client resources are untyped (created at AdminPermissionsSchema.java:104 with no setType). The method adds resource.getName() raw, so per-client grants yield {} and all-clients grants yield {'Clients'}. The V1 sibling (ClientPermissions.java:680-683) filters by type 'Client' and strips RESOURCE_NAME_PREFIX to recover real ids.\n\nThis may be intentional if the endpoint is not yet reachable, but the structural defect is real.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR36880__20260821/blob/1950a511026d520a7329c7b6b9ee60a4af8f8b55/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java#L168-L172\n\n
\n", + "created_at": "2026-08-21T22:35:55Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java", + "line": 77, + "body": "### 🟠 High · Feature gate uses the base flag while siblings use the `_V2` variant\n\nThis gate reads the base feature flag, but 5 sibling gates in the same file read the `_V2` variant of the same flag family. If the `_V2` flag is the live one and the base is retired/off, this gate never fires while its siblings do.\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. The divergence is real: AdminPermissions.java:77 gates on Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ; lines 40, 46, 53, 60, 67 gate on ADMIN_FINE_GRAINED_AUTHZ_V2. The listener is reachable: AdminPermissions.registerListener is invoked at DefaultKeycloakSessionFactory.java:122 (constructor) and QuarkusKeycloakSessionFactory.java:88 (from repo-wide grep of registerListener) — real server-startup entry points; the gate is evaluated on every role/client/group removal event. The finding's premise (\"base retired/off, V2 live\") is false in this codebase: Profile.java:56 defines ADMIN_FINE_GRAINED_AUTHZ as Type.PREVIEW (enabled by the preview profile per isEnabledByDefault, Profile.java:316-325) and Profile.java:58 defines ADMIN_FINE_GRAINED_AUTHZ_V2 as Type.EXPERIMENTAL (never default-enabled). The base flag is the only version default-enabled in any supported profile; V2 is opt-in. The guarded operation does not exist in V2: the listener body calls management(...).clients().setPermissionsEnabled(client, false) (AdminPermissions.java:91), and ClientPermissionsV2.setPermissionsEnabled throws UnsupportedOperationException(\"Not supported in V2\") (ClientPermissionsV2.java:193-195); same for UserPermissionsV2 (line 169). Changing the gate to _V2 — as the finding demands — would make the listener throw on ClientRemovedEvent under V2. Both flags can never be enabled simultaneously: Profile.configure throws ProfileException for multiple versions of the same feature (Profile.java:283-287), so the listener can only ever run against the V1 implementation; the V1 gate is coherent by construction. Nothing is left uncleaned under V2-only configs: the legacy resources the listener deletes are created only via endpoints gated on the same base flag — RoleContainerResource.java:494, ClientResource.java:706, GroupResource.java:323, RealmAdminResource.java:521 all ProfileHelper.requireFeature(ADMIN_FINE_GRAINED_AUTHZ).\n_Queries: read_file(path=\"services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java\", offset=1, limit=200) · grep(pattern=\"ADMIN_FINE_GRAINED_AUTHZ\", path=\".\", max_results=100) · read_file(path=\"common/src/main/java/org/keycloak/common/Profile.java\", offset=1, limit=160) · read_file(path=\"common/src/main/java/org/keycloak/common/Profile.java\", offset=160, limit=180) · read_file(path=\"common/src/main/java/org/keycloak/common/Profile.java\", offset=336, limit=45) · grep(pattern=\"RoleRemovedEvent\", path=\".\", max_results=60) · grep(pattern=\"setPermissionsEnabled\", path=\".\", max_results=60) · read_file(path=\"services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissionsV2.java\", offset=1, limit=120) · read_file(path=\"services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java\", offset=150, limit=70) · read_file(path=\"services/src/main/java/org/keycloak/services/resources/admin/permissions/RolePermissions.java\", offset=51, limit=60) · read_file(path=\"services/src/main/java/org/keycloak/services/DefaultKeycloakSessionFactory.java\", offset=115, limit=14) · grep(pattern=\"registerListener\", path=\".\", max_results=50)_\n\n> **Fix** — Use the `_V2` flag here too, matching the sibling gates (or confirm this gate is intentionally pinned to the base flag).\n", + "created_at": "2026-08-21T22:35:55Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | hasPermission(client, scope) evaluates scopes in the wrong order and returns false for a direct client resource | `services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java:214` |\n| 🔵 Low | Client listing assertion checks count, not identity | `tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/PermissionClientTest.java:163` |\n| 🟠 High | Incomplete ClientPermissionsV2 implementation under V2 feature: token exchange and client removal throw UnsupportedOperationException, getClientsWithPermission returns empty for per-client grants | `services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionsV2.java:138` |\n| 🔵 Low | getResourceName does not handle the CLIENTS resource type (display-name regression) | `server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java:304` (not in the diff) |\n| 🟠 High | Feature gate uses the base flag while siblings use the `_V2` variant | `services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java:77` |\n\n---\n\n### 🔵 Low · getResourceName does not handle the CLIENTS resource type (display-name regression)\n\n`server-spi-private/src/main/java/org/keycloak/authorization/AdminPermissionsSchema.java:304`\n\nThe PR adds the CLIENTS resource type to the schema, but getResourceName only special-cases the USERS resource type. For CLIENTS resources, it falls through to return resource.getDisplayName(), which is null/empty for client resources created via getOrCreateResource.\n\nThe affected consumer is PolicyResourceService.getResources() (line 218), which calls AdminPermissionsSchema.SCHEMA.getResourceName(...). This results in a null/empty display name for client-scoped policies — a display-only regression, not a functional authorization break.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 89 (A-) | -11.3 |\n| Test Coverage | 52 (D+) | 100 (A+) | +48.0 |\n| Duplication | 100 (A+) | 100 (A+) | -0.0 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **90 (A-)** | **96 (A+)** | **+6.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:55Z" + } + ] } ] }, @@ -8963,6 +9149,48 @@ "created_at": "2026-06-28T22:57:21Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/37038", + "review_comments": [ + { + "path": "tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeEvaluationTest.java", + "line": 174, + "body": "### 🔵 Low · testManageAllGroups reuses id-bearing GroupRepresentation, so addChild exercises move branch (204) instead of create branch (201)\n\nThe test reuses a GroupRepresentation that already has a server-assigned id (set at line 166), so the POST at line 174 routes into the server's move-existing-group branch (GroupResource.java:207-213) which returns 204, not the create branch (201). The test's NO_CONTENT assertion at line 175 is satisfied by the move branch, so the create-child path under a manage-all-groups grant is never actually verified.\n\nThis is a defect because the negative case at line 146 uses a fresh rep and the analogous positive case in testManageGroup (lines 219-228) uses a fresh rep and asserts CREATED — only this one positive case reuses the id-bearing rep, so it does not mirror its own negative case and would silently pass even if the create-child path were broken. The comment at line 173 confirms the intended verification was creation.\n\nIt could be intentional if the test author deliberately wanted to test move semantics, but the comment and the asymmetry with the negative case argue against that.\n\n> **Fix** — Use a fresh GroupRepresentation (without setId) for the positive create-child case, so the POST goes through the create branch and the test asserts CREATED (201) instead of NO_CONTENT.\n", + "created_at": "2026-08-21T22:35:59Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java", + "line": 122, + "body": "### 🟠 High · GroupPermissionsV2.getGroupIdsWithViewPermission returns authorization-resource UUIDs as group IDs and evaluates against wrong ID: getUsersCount and searchForUser drop per-user canView filter\n\nThe method passes the authorization-resource UUID to hasPermission and adds that UUID to the returned set, but resource names are group IDs. The findByName lookup always misses, so per-group grants are dropped or all groups are granted when an all-groups policy exists.\n\nConsumers in UsersResource use these values as group IDs for membership filtering, so group-scoped user counts return 0 and searches return empty for V2 admins with legitimate per-group view-members/manage-members grants. This may be intentional if the V2 design intended to use resource UUIDs, but the consumers clearly expect group IDs, and V1 correctly uses resource.getName().\n\n> **Fix** — Use groupResource.getName() (the group ID) both for the hasPermission argument and for the value added to granted, mirroring GroupPermissions.java:315.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. GroupPermissionsV2.getGroupIdsWithViewPermission uses the authorization-resource ID where the group model ID / resource name is required**\n\nThe method claims to return group IDs that the caller may view members of, but it enumerates objects of type GROUPS_RESOURCE_TYPE and records groupResource.getId() — the authorization store's resource ID (generated UUID), not the group model ID and not the resource name. The parameter is then handed back to hasPermission(...), which resolves it via resourceStore.findByName(server, groupId), i.e. the same value is treated as a name.\n\nThe sibling pattern in UserPermissionsV2.java:105 establishes the convention that V2 entity resources are looked up by the entity model ID as the resource name. A generated resource UUID will therefore miss the findByName and silently fall back to the all-groups type resource, so the internal permission probe evaluates the all-groups grant for every enumerated group instead of the per-group grant — the returned set becomes all-or-nothing based on the all-groups policy, and the returned values, if consumed as group model IDs by a downstream filter/query, match nothing.\n\nThis may be intentional if the store's id-versus-name convention differs, but the internal inconsistency (value added as the returned key is simultaneously a groupId argument resolved by name) is a genuine mechanism on added lines.This comment also covers: GroupPermissionsV2.getGroupIdsWithViewPermission returns resource UUIDs instead of group ids, missing per-group grantsThis comment also covers: GroupPermissionsV2.getGroupIdsWithViewPermission returns resource store ids instead of group ids\n\n
\n\n
This same fix applies at 4 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L119-L123\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L121-L125\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java#L396-L400\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java#L446-L450\n\n
\n", + "created_at": "2026-08-21T22:35:59Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java", + "line": 70, + "body": "### 🟠 High · GroupPermissionsV2.canManage() grants manage for the VIEW scope (privilege escalation)\n\nThe global canManage() evaluates whether the caller may perform manage-level group operations, but its scope list includes VIEW. A principal granted only view on the groups resource type satisfies hasPermission(null, VIEW, MANAGE) and therefore satisfies canManage(). This allows a view-only admin to create top-level groups via GroupsResource.addTopLevelGroup, which calls auth.groups().requireManage(). The sibling canManage(GroupModel group) correctly uses only MANAGE, and canView() uses the identical VIEW, MANAGE scope list, indicating the canManage() scope list was copied from canView() without removing VIEW. This may be intentional if the design intends VIEW to imply manage, but the asymmetry with the per-group method and the privilege escalation consequence make it a defect.\n\n**Unadjudicated** — the proof pass could not review this finding (the verification batch failed twice). Reachability, triggerability and harm were NOT established, and equally NOT disproven; it is reported on its detecting lane's evidence alone.\n\n> **Fix** — Change the scope list in canManage() to use only AdminPermissionsSchema.MANAGE, matching the per-group canManage(GroupModel) implementation.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. GroupPermissionsV2.canManage() accepts the VIEW scope, allowing view-only admins to manage groups**\n\nThe no-arg canManage() calls hasPermission(null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE), and hasPermission returns true if any expected scope is granted. An admin granted only view on the Groups resource type therefore gets canManage() == true, granting full manage-all-groups authority from a read-only grant.\n\nThe siblings (UserPermissionsV2.canManage and GroupPermissionsV2.canManage(GroupModel)) use MANAGE only, proving the intended contract. This may be intentional if the no-arg form is meant to allow view-only admins to manage, but the asymmetry with the siblings and the privilege escalation make it a defect.\n\n**2. V2 no-arg canManage() accepts VIEW scope alongside MANAGE — confirmed privilege escalation via addTopLevelGroup**\n\nThe requeued verdict confirms the same mechanism as the L1 finding: GroupPermissionsV2.canManage() (no-arg) at line 70 accepts AdminPermissionsSchema.VIEW alongside MANAGE — a copy of the canView() gate — while every sibling (GroupPermissionsV2.canManage(GroupModel) line 79, ClientPermissionsV2.canManage() line 73) requires MANAGE only. Since GroupsResource.addTopLevelGroup (line 181) is the sole caller of the no-arg requireManage(), a V2 admin holding only a view grant on the groups type resource can create top-level groups and move child groups to top-level.\n\nThis is the same defect as the L1 finding, but the requeued verdict supersedes the earlier verdict for this lead, so this is the verdict of record.This comment also covers: V2 canManage() accepts VIEW scope instead of MANAGE onlyThis comment also covers: V2 no-arg canManage() returns true for VIEW scope on all-groups type resource, allowing view-only admin to create top-level groupsThis comment also covers: GroupPermissionsV2.canManage() treats a view-only all-groups grant as permission to manage groupsThis comment also covers: GroupPermissionsV2.canManage permits admins holding only VIEW to create groups (missing canManage-scoped check)\n\n
\n\n
This same fix applies at 4 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L74-L78\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L63-L67\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L64-L68\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR37038__20260821/blob/7355f05e6aafe2a7acc6434fb40c686665416606/services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java#L60-L64\n\n
\n", + "created_at": "2026-08-21T22:35:59Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java", + "line": 156, + "body": "### 🟠 High · GroupPermissionsV2.hasPermission omits per-resource guard present in UserPermissionsV2.hasPermission\n\nThe newly added GroupPermissionsV2.hasPermission method evaluates permissions but never filters returned Permission entries by resourceId, unlike its sibling UserPermissionsV2.hasPermission which checks permission.getResourceId().equals(resource.getId()). When a per-group resource exists, a scope permission granted on a different group's resource (or the type-level resource) can be returned with the same scope name, causing canView(group), canManage(group), canViewMembers, canManageMembers, and canManageMembership to over-grant access to a group the principal holds no permission on.\n\nThis is a concrete wrong authorization decision. It might be intentional if the outer evaluator only ever returns entries for the requested resource, but the sibling guards and the type-level fallback path make that unlikely; the omission is the only discriminator between scopes granted on the target group versus another resource.\n\n> **Fix** — Add the per-resource guard to the loop, mirroring UserPermissionsV2.hasPermission: after evaluating permissions, check if (permission.getResourceId().equals(resource.getId())) before iterating scopes, so only permissions for the evaluated resource are honored.\n", + "created_at": "2026-08-21T22:35:59Z" + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java", + "line": 74, + "body": "### 🟠 High · Feature gate uses the base flag while siblings use the `_V2` variant\n\nThis gate reads the base feature flag, but 5 sibling gates in the same file read the `_V2` variant of the same flag family. If the `_V2` flag is the live one and the base is retired/off, this gate never fires while its siblings do.\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. AdminPermissions.java:74 gates the listener on V1 flag ADMIN_FINE_GRAINED_AUTHZ; lines 40/46/53/60/67 gate on ADMIN_FINE_GRAINED_AUTHZ_V2. Profile.java:56/58 define these as versions 1 and 2 of the same unversioned key, and Profile.java:282–307 makes them mutually exclusive (enabling two versions throws; features.put(f, f == enabledFeature)). The listener body (AdminPermissions.java:88/91/94) calls groups()/clients().setPermissionsEnabled, which throw UnsupportedOperationException(\"Not supported in V2\") under V2 (GroupPermissionsV2.java:173–175, ClientPermissionsV2.java:193–195). Under pure V2 the state it cleans cannot exist: every per-entity permission endpoint is gated on the V1 flag via ProfileHelper.requireFeature (GroupResource.java:322/355, ClientResource.java:706/739, RoleContainerResource.java:494/528, RoleByIdResource.java:329/367, IdentityProviderResource.java:444/477, RealmAdminResource.java:521/541), which throws 501 when V1 is off (ProfileHelper.java:30–34). registerListener runs at startup from QuarkusKeycloakSessionFactory.java:88 and DefaultKeycloakSessionFactory.java:122.\n_Impact: The claimed harm (cleanup never running under V2) is benign: V2 cannot create the per-entity permission state the listener exists to clean, and the listener's own operations throw under V2, so registering it would crash event handling rather than clean anything. The V1 gate is the correct gate for this V1-semantics listener; the divergence from the V2-gated factory siblings is intentional design, not a dead gate._\n_Queries: read_file(AdminPermissions.java) · grep(ADMIN_FINE_GRAINED_AUTHZ in Profile.java) · grep(ADMIN_FINE_GRAINED_AUTHZ repo-wide) · read_file(Profile.java) · grep(registerListener|AdminPermissions.management) · glob(MgmtPermissionsV2.java) · read_file(MgmtPermissionsV2.java) · grep(RoleRemovedEvent|ClientRemovedEvent|GroupRemovedEvent) · read_file(GroupPermissionsV2.java) · read_file(MgmtPermissions.java) · grep(setPermissionsEnabled in permissions dir) · read_file(ClientPermissionsV2.java) · read_file(RolePermissions.java) · read_file(RoleContainerResource.java) · grep(isPermissionsEnabled|setPermissionsEnabled in admin dir) · read_file(ProfileHelper.java)_\n\n> **Fix** — Use the `_V2` flag here too, matching the sibling gates (or confirm this gate is intentionally pinned to the base flag).\n", + "created_at": "2026-08-21T22:35:59Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | testManageAllGroups reuses id-bearing GroupRepresentation, so addChild exercises move branch (204) instead of create branch (201) | `tests/base/src/test/java/org/keycloak/tests/admin/authz/fgap/GroupResourceTypeEvaluationTest.java:174` |\n| 🟠 High | GroupPermissionsV2.getGroupIdsWithViewPermission returns authorization-resource UUIDs as group IDs and evaluates against wrong ID: getUsersCount and searchForUser drop per-user canView filter | `services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java:122` |\n| 🟠 High | GroupPermissionsV2.canManage() grants manage for the VIEW scope (privilege escalation) | `services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java:70` |\n| 🟠 High | GroupPermissionsV2.hasPermission omits per-resource guard present in UserPermissionsV2.hasPermission | `services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java:156` |\n| 🟠 High | Feature gate uses the base flag while siblings use the `_V2` variant | `services/src/main/java/org/keycloak/services/resources/admin/permissions/AdminPermissions.java:74` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 47 (D) | 46 (D) | -1.3 |\n| Runtime | 100 (A+) | 89 (A-) | -11.5 |\n| Test Coverage | 52 (D+) | 100 (A+) | +47.8 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **75 (B)** | **81 (B+)** | **+5.9** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:59Z" + } + ] } ] }, @@ -10427,6 +10655,48 @@ "created_at": "2026-06-28T22:36:15Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/33832", + "review_comments": [ + { + "path": "authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java", + "line": 95, + "body": "### 🔵 Low · Client library performs silent first-wins JVM/classloader crypto-provider selection; stub can be pinned and its getBouncyCastleProvider is not BC\n\nAuthzClient.create now mutates the global CryptoIntegration.cryptoProvider static from a client library, and init is first-wins, so whichever caller wins the race fixes the provider for the whole classloader permanently. The companion change replaced the old loud IllegalStateException with silent highest-order-wins, while all three server providers declare the same order 200, so a tie falls back to undocumented ServiceLoader iteration order.\n\nThe new stub (order 100) throws UnsupportedOperationException on several methods, and its getBouncyCastleProvider returns a JCE keystore provider, not BouncyCastle, despite the interface contract. Because CryptoIntegration.init line 34 evaluates BouncyIntegration.PROVIDER unconditionally, that non-BC provider name gets snapshotted classloader-wide; any later getInstance(alg, BouncyIntegration.PROVIDER) caller throws NoSuchAlgorithmException.\n\nThis may be intentional for client-only usage, but the silent first-wins behavior and non-BC provider snapshot could break third-party embeddings where the stub is the only visible provider.\n\n> **Fix** — Consider restoring a fail-fast when multiple providers are present, or ensure the stub's getBouncyCastleProvider returns an actual BC provider, and document the first-wins behavior.\n", + "created_at": "2026-08-21T22:35:47Z" + }, + { + "path": "authz/client/src/main/java/org/keycloak/authorization/client/util/crypto/ASN1Decoder.java", + "line": 160, + "body": "### 🔵 Low · Out-of-bounds guard compares declared length against total buffer rather than remaining bytes\n\nThe guard compares declared length against the total buffer limit rather than remaining bytes. Over-long lengths in (remaining, limit-1] evade the guard and surface only as a generic EOF error in read().\n\nHarm is diagnostic-only (no silent accept, no false reject), but the comparison is imprecise and could produce misleading error messages.\n\n> **Fix** — Compare against remaining bytes instead of total buffer limit, or adjust the guard to use the remaining length.\n", + "created_at": "2026-08-21T22:35:47Z" + }, + { + "path": "common/src/main/java/org/keycloak/common/crypto/CryptoIntegration.java", + "line": 57, + "body": "### 🔵 Low · Crypto provider ordering: `order()` abstract method breaks implementors, and equal-order providers silently resolve by ServiceLoader order because the fail-fast tie guard was removed\n\nThe old code threw IllegalStateException when more than one crypto provider was loaded, surfacing packaging errors loudly. That guard was removed and replaced with picking the first provider and logging the rest only at debug level.\n\nSince all real providers return order 200, the sort cannot distinguish them, so the winner is determined by ServiceLoader iteration order, which is deterministic but semantically arbitrary. In a FIPS deployment this can silently select the non-FIPS provider, disabling FIPS enforcement with no operator-visible signal, or surface later as obscure runtime NoSuchAlgorithmExceptions instead of a clear startup error.\n\nThis may be intentional to allow multiple providers on the classpath, but the loss of the loud failure for a state the old code treated as fatal is a real behavioral change with security-relevant consequences.\n\n> **Fix** — Restore a loud failure (or at least a warning at a visible level) when more than one real provider (order 200) is present, or make the selection explicit and configurable so the chosen provider is deterministic and operator-visible.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR33832__20260821/blob/79d11c4890cc4792e3dc3535af59e4e5f7a2739d/common/src/main/java/org/keycloak/common/crypto/CryptoProvider.java#L42-L46\n\n
\n", + "created_at": "2026-08-21T22:35:47Z" + }, + { + "path": "authz/client/src/test/java/org/keycloak/authorization/client/test/ECDSAAlgorithmTest.java", + "line": 42, + "body": "### 🟡 Medium · ECDSA tests are self-referential with no external ground truth, and testES384/testES512 actually exercise P-256 instead of the named curves\n\nKeyPairGenerator.getInstance(\"EC\") without an explicit key size yields the default 256-bit P-256 key; test(ES384)/test(ES512) therefore sign 64-byte (r||s = 32+32) signatures with SHA-384/SHA-512 digests and rely on integerToBytes padding to reach getSignatureLength() (96/132). The 384/512-bit signatures the test names are never generated, so the padding/truncation path for real 48/66-byte r/s values is untested. This may be intentional to keep tests fast, but it means the named curves are not actually exercised.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). ECDSAAlgorithmTest.java:42 — a single keypair generated from uninitialized KeyPairGenerator.getInstance(\"EC\").genKeyPair() is shared by all three tests (testES256 :60, testES384 :65, testES512 :70). JDK contract: uninitialized keygen uses provider defaults; SunEC default is P-256/secp256r1 — corroborated by AbstractEcKeyProviderFactory.java:33 (project's default EC curve constant is \"P-256\") and by JWKTest.java:250-251 running green with the same uninitialized pattern. AuthzClientCryptoProvider.java:151-163 — integerToBytes(r, qLength) with qLength=48 (ES384) / 66 (ES512): a P-256 r/s is ≤ 32 bytes (33 with the sign byte), so qLength > bytes.length always holds and only the left-pad branch (157-160) executes; the truncation branch (153-156) is dead on this path. DER r/s INTEGERs stay ≤ 33 bytes, so the ASN.1 SEQUENCE length stays < 128 — long-form DER lengths are never encoded or parsed. ECDSAAlgorithmTest.java:53-56 — DER→concat→DER→concat round trip with deterministic zero-padding is exactly symmetric, so assertArrayEquals(rsConcat, rsConcat2) at :56 cannot fail for ES384/ES512 under a P-256 key: the tests pass vacuously and cannot detect a conversion defect specific to genuine 48/66-byte (P-384/P-521) values. ECDSAAlgorithm.java:27-29 — the signature lengths the test passes (96/132) correspond to 48/66-byte r/s values that are never produced by the P-256 key. Reachability: ECDSAAlgorithmTest is a standard JUnit 4 @Test class in src/test/java of module keycloak-authz-client-tests (authz/client/pom.xml:14,62-66), aggregated via authz/pom.xml:20-23; surefire's default includes (**/*Test.java) execute it in the module's test phase.\n_Impact: Regressions in the real P-384/P-521 signature-conversion code paths go undetected because ES384/ES512 tests pass vacuously on padded P-256 values._\n_Queries: read_file(path=\"authz/client/src/test/java/org/keycloak/authorization/client/test/ECDSAAlgorithmTest.java\") · grep(pattern=\"asn1derToConcatenatedRS|concatenatedRSToASN1DER|integerToBytes|getSignatureLength\", path=\"authz\") · read_file(path=\"authz/client/src/main/java/org/keycloak/authorization/client/util/crypto/AuthzClientCryptoProvider.java\", offset=85) · grep(pattern=\"KeyPairGenerator.getInstance(\\\"EC\\\")\", path=repo) · read_file(path=\"services/src/main/java/org/keycloak/keys/AbstractEcKeyProviderFactory.java\") · read_file(path=\"core/src/test/java/org/keycloak/jose/jwk/JWKTest.java\", offset=240) · read_file(path=\"core/src/main/java/org/keycloak/crypto/ECDSAAlgorithm.java\") · read_file(path=\"authz/client/pom.xml\") · read_file(path=\"authz/pom.xml\")_\n\n> **Fix** — Generate separate key pairs with explicit key sizes (384/521 bits) for the ES384/ES512 tests.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. ECDSA round-trip test is self-referential, no external ground truth**\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak__corbulo__PR33832__20260821/blob/79d11c4890cc4792e3dc3535af59e4e5f7a2739d/authz/client/src/test/java/org/keycloak/authorization/client/test/ECDSAAlgorithmTest.java#L54-L58\n\n**2. ES384/ES512 tests use a single P-256 key, so they never exercise real P-384/P-521 signatures**\n\nThe test generates one keypair without specifying a curve, so the JDK default (P-256) is used for all three algorithms. ES384 and ES512 tests then zero-pad the 32-byte P-256 r/s values to 48/66 bytes, and the round-trip conversion is trivially symmetric because the padding is stripped and re-added.\n\nThis means the tests pass vacuously and would not detect a conversion bug specific to genuine 48/66-byte values or long-form DER lengths. This may be intentional if the goal is only to test the padding logic, but the test names claim to test ES384/ES512 signatures, which they do not.\n\n
\n", + "created_at": "2026-08-21T22:35:47Z" + }, + { + "path": "authz/client/src/main/java/org/keycloak/authorization/client/util/crypto/AuthzClientCryptoProvider.java", + "line": 114, + "body": "### 🔵 Low · Dead statements in concatenatedRSToASN1DER\n\nThe statements ASN1Encoder.create().write(rBigInteger); and ASN1Encoder.create().write(sBigInteger); create encoders whose results are discarded; the actual return value is built independently at lines 117-121. This is confirmed dead code with no functional impact, though it may be intentional as a leftover from a refactor.\n\n> **Fix** — Remove the two dead statements at lines 114-115.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Dead no-op encoder writes in concatenatedRSToASN1DER**\n\nTwo ASN1Encoder.create().write(...) statements construct a fresh encoder, write into it, and discard the instance with no observable effect. The actual return value is built from four new encoder instances, so these writes are pure dead code executed on every call.\n\nThis is a code smell only — the returned DER is correct and the round-trip test passes regardless. It might be intentional as leftover scaffolding, but the deadness is structural.\n\n**2. Dead code in concatenatedRSToASN1DER**\n\nTwo statements construct an ASN1Encoder, write into its private ByteArrayOutputStream, and discard the object — no side effect, no use of the result. The actual encoding is done by the writeDerSeq expression on lines 117-121. Harmless functionally; remove it.\n\n
\n", + "created_at": "2026-08-21T22:35:47Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Client library performs silent first-wins JVM/classloader crypto-provider selection; stub can be pinned and its getBouncyCastleProvider is not BC | `authz/client/src/main/java/org/keycloak/authorization/client/AuthzClient.java:95` |\n| 🔵 Low | Out-of-bounds guard compares declared length against total buffer rather than remaining bytes | `authz/client/src/main/java/org/keycloak/authorization/client/util/crypto/ASN1Decoder.java:160` |\n| 🔵 Low | Crypto provider ordering: `order()` abstract method breaks implementors, and equal-order providers silently resolve by ServiceLoader order because the fail-fast tie guard was removed | `common/src/main/java/org/keycloak/common/crypto/CryptoIntegration.java:57` |\n| 🟡 Medium | ECDSA tests are self-referential with no external ground truth, and testES384/testES512 actually exercise P-256 instead of the named curves | `authz/client/src/test/java/org/keycloak/authorization/client/test/ECDSAAlgorithmTest.java:42` |\n| 🔵 Low | Dead statements in concatenatedRSToASN1DER | `authz/client/src/main/java/org/keycloak/authorization/client/util/crypto/AuthzClientCryptoProvider.java:114` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.3 |\n| Test Coverage | 72 (B-) | 100 (A+) | +27.5 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **91 (A)** | **95 (A+)** | **+4.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:47Z" + } + ] } ] }, @@ -11653,6 +11923,30 @@ "created_at": "2026-06-28T23:30:25Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/40940", + "review_comments": [ + { + "path": "tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java", + "line": 140, + "body": "### 🟠 High · Reader thread never joined, causing flaky race in GroupTest\n\nThe reader thread spawned at line 139 is never joined before the assertion at line 157. The main thread flips deletedAll to true at line 155 and immediately asserts caughtExceptions is empty. A reader thread inside the groups() call at line 143 when the flag flips will finish that in-flight call and may append an exception to the CopyOnWriteArrayList after the assertion has already passed, causing a false pass. The missing join is the root cause; the race is real and makes the test flaky.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). GroupTest.java:139 `new Thread(() -> {` spawned and started at :149; read loop :140–148 calling `managedRealm.admin().groups().groups(null, 0, Integer.MAX_VALUE, true)` at :143; `deletedAll.set(true)` at :155; `assertThat(caughtExceptions, Matchers.empty())` at :157. The grep for `join|interrupt|daemon|ExecutorService|Future` returned zero matches: there is no join, no interrupt, no daemon flag, and no executor anywhere in the file. The reader thread is never joined and no other mechanism orders its final `caughtExceptions.add(e)` against the main-thread assertion at :157. The grep for `@Disabled|@Ignore|@EnabledOnOs|assumeTrue` returned zero matches: `@Test createMultiDeleteMultiReadMulti` (line 118–119) is enabled and runs in the `@KeycloakIntegrationTest` (line 102) suite under `tests/base`. `new Thread` grep across the whole admin test tree returned exactly one hit — this thread. The reader is started (line 149) before the 100-delete loop (:152–154), so it is in-flight inside `groups()` when the flag flips at :155; `AtomicBoolean` and `CopyOnWriteArrayList` are individually thread-safe but establish no happens-before between the reader's append and the main's assertion — the false-pass window is real.\n_Impact: Test-integrity defect (false pass): the test can pass while the concurrent reader caught an exception during group deletion, so a server regression in list-groups-under-concurrent-delete ships undetected and CI results are unreliable._\n_Queries: read_file(path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\", offset=100, limit=100) · grep(pattern=\"join|interrupt|daemon|ExecutorService|Future\", path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\") · grep(pattern=\"@Disabled|@Ignore|@EnabledOnOs|assumeTrue\", path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\") · grep(pattern=\"new Thread\", path=\"tests/base/src/test/java/org/keycloak/tests/admin\") · grep(pattern=\"class AbstractGroupTest\", path=\"tests/base/src/test/java/org/keycloak/tests/admin\") · read_file(path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/AbstractGroupTest.java\", limit=50)_\n\n> **Fix** — Retain the thread handle and call join() on it before line 157 to ensure the reader thread has fully terminated before asserting.\n", + "created_at": "2026-08-21T21:34:00Z" + }, + { + "path": "tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java", + "line": 139, + "body": "### 🟡 Medium · Test spawns a thread with a discarded handle and never joins it\n\nThis `@Test` starts `new Thread(...).start()` with the thread handle discarded (no variable binding), then asserts — but never joins the thread or awaits any barrier (join/Future.get/CountDownLatch.await). The worker's writes have no happens-before edge to the assertion, so the test reads state before (or without seeing) the thread's work.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). GroupTest.java:139: thread started with discarded handle — no variable binding, no join anywhere (file-wide grep empty). GroupTest.java:155→157: `deletedAll.set(true)` and the assertion are adjacent statements; the only cross-thread primitive is `AtomicBoolean deletedAll` (line 136), whose `set(true)` orders the flag for the worker's read but creates no happens-before edge from the worker's writes to `caughtExceptions` to the main thread's assertion read. The worker cannot have terminated at assertion time (it must finish its in-flight HTTP call and re-poll the flag), so the assertion always races the worker's writes; exceptions recorded after the read are invisible to the assertion.\n_Impact: The test's verdict is decided before the worker thread finishes; worker-caught exceptions landing after the assertion reads the list are lost, so the test can pass while concurrent read/delete actually threw, and can fail spuriously on timing — a masking/flaking test that lets product regressions ship undetected._\n_Queries: read_file(path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\", offset=90, limit=120) · grep(pattern=\"\\.join\\(|\\.await\\(|Future|CountDownLatch|ExecutorService|\\.get\\(\\)\", path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\") · grep(pattern=\"@Disabled|assumeTrue|@EnabledOnOs\", path=\"tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java\") · grep(pattern=\"class AbstractGroupTest|join\\(|await\\(\", path=\"tests/base/src/test/java/org/keycloak/tests/admin/group\") · grep(pattern=\"createMultiDeleteMultiReadMulti\", path=\"tests\")_\n\n> **Fix** — Bind the thread (`Thread t = new Thread(...); t.start();`) and call `t.join()` before asserting, or synchronize with a `CountDownLatch`/`Future` the assertion awaits.\n", + "created_at": "2026-08-21T21:34:00Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟠 High | Reader thread never joined, causing flaky race in GroupTest | `tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java:140` |\n| 🟡 Medium | Test spawns a thread with a discarded handle and never joins it | `tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java:139` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -5.7 |\n| **Overall** | **76 (B)** | **75 (B-)** | **-1.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:01Z" + } + ] } ] }, @@ -13015,6 +13309,30 @@ "created_at": "2026-06-28T22:13:01Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/keycloak-greptile/pull/1", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernameForm.java", + "line": 47, + "body": "### 🔴 Critical · Conditional-passkey enablement refactor broke the API and the initial-login flow: no-arg isConditionalPasskeysEnabled call fails to compile, and the user != null gate drops passkey setup from first login\n\nUsernameForm.authenticate calls isConditionalPasskeysEnabled() with no arguments, but the only method in the class hierarchy (added by this PR) is isConditionalPasskeysEnabled(UserModel user) at UsernamePasswordForm.java:160. No no-arg overload exists in UsernameForm, UsernamePasswordForm, AbstractUsernameFormAuthenticator, or any other superclass.\n\nJava method resolution cannot bind a zero-argument invocation to a one-UserModel-argument method, so the services module fails to compile and the PR cannot build. The likely intended call is isConditionalPasskeysEnabled(context.getUser()), which would produce the PR's intended re-auth semantics rather than a semantic defect.\n\n> **Fix** — Change line 47 to call isConditionalPasskeysEnabled(context.getUser()).\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Conditional-UI passkey setup removed from initial login due to user != null gate**\n\nhttps://github.com/corbulo-martian-benchmark/keycloak__keycloak-greptile__corbulo__PR1__20260821/blob/3214b188de808fdb0fff335556b686f1b0a63218/services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java#L158-L162\n\n**2. Compilation failure: no-arg call to one-arg isConditionalPasskeysEnabled in UsernameForm.authenticate**\n\nUsernameForm.authenticate calls isConditionalPasskeysEnabled() with no arguments, but the only declaration in the hierarchy is UsernamePasswordForm.isConditionalPasskeysEnabled(UserModel user) which requires one argument. This is a build-breaking compilation error, not a behavioral guard.\n\nThe intended condition is almost certainly !isConditionalPasskeysEnabled(context.getUser()) with the user argument carried in.\n\n
\n", + "created_at": "2026-08-21T22:35:40Z" + }, + { + "path": "services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java", + "line": 115, + "body": "### 🟡 Medium · Conditional-passkeys setup dropped from the first-login page\n\nThe '-' lines invoked webauthnAuth.fillContextForm(context) precisely when user == null (authenticate's else branch; challenge's context.getUser() == null && webauthnAuth != null && webauthnAuth.isPasskeysEnabled()). The new helper isConditionalPasskeysEnabled(UserModel user) requires user != null, so fillContextForm — which sets ENABLE_WEBAUTHN_CONDITIONAL_UI, the WebAuthn challenge, and RP_ID on the form — now runs only on the re-auth path and is skipped for the anonymous first-login page.\n\nThe conditional-UI scenario is by definition the ID-less one, and createErrorResponse only re-establishes the attributes after a failed passkey attempt, so no in-scope path restores the user == null setup. The flip is consistent at both sites and matches the PR's re-auth intent, so this may be deliberate; if not, the change silently disables conditional passkeys on the initial login page.\n\n> **Fix** — If the first-login conditional-UI setup is still required, preserve the user == null condition for fillContextForm calls, or add a separate check that does not require a non-null user.\n", + "created_at": "2026-08-21T22:35:40Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔴 Critical | Conditional-passkey enablement refactor broke the API and the initial-login flow: no-arg isConditionalPasskeysEnabled call fails to compile, and the user != null gate drops passkey setup from first login | `services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernameForm.java:47` |\n| 🟡 Medium | Conditional-passkeys setup dropped from the first-login page | `services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java:115` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 73 (B-) | -27.1 |\n| Consistency | 85 (A-) | 85 (A-) | -0.0 |\n| **Overall** | **75 (B-)** | **70 (C+)** | **-4.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:40Z" + } + ] } ] }, @@ -14441,6 +14759,60 @@ "created_at": "2026-06-28T23:04:21Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/93824", + "review_comments": [ + { + "path": "tests/sentry/spans/consumers/process/test_consumer.py", + "line": 62, + "body": "### 🟡 Medium · Monkeypatched time.sleep at test_consumer.py:62 provides no synchronization\n\nThe test monkeypatches time.sleep to a no-op, which means the sleep(0.1) call provides no actual waiting. This can lead to flaky tests because the consumer may not have processed messages before assertions run.\n\nThis is a confirmed defect from the deterministic analysis. It could be intentional if the test relies on other synchronization mechanisms, but the sleep call suggests the intent was to wait.\n\n> **Fix** — Use a proper synchronization mechanism such as an event or queue to wait for the consumer to finish processing, or remove the sleep and use a more robust wait.\n", + "created_at": "2026-08-21T21:34:25Z" + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 93, + "body": "### 🔵 Low · Flusher process-count knob mishandled: 0/None semantics diverge between entry points, and max-flush-segments silently becomes per-process when processes > 1, multiplying flush volume and delaying backpressure\n\nThe new per-process SpansBuffer at flusher.py:93 recomputes the flush quota from its own shard subset (buffer.py:382-384), so with N processes each computes max_segments_per_shard = ceil(k*M/N) instead of the old ceil(M/N). The k processes run concurrently, so total segments loaded per cycle grows to approximately k*M, silently multiplying the tuned global cap.\n\nThe backpressure trigger (any_shard_at_limit at buffer.py:411-412, backpressure_since at flusher.py:172-176, MessageRejected at flusher.py:277-284) now trips at the inflated per-process threshold, so Redis memory can grow well past the tuned bound before backpressure engages. This may be intentional per-process budgeting, but the option's global-cap contract and backpressure delay are changed without any division of the budget across processes.\n\n> **Fix** — Divide the max-flush-segments budget across processes (e.g., pass max_flush_segments/num_processes to each process's buffer) or document and accept the per-process semantics explicitly.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Verdict on [L1] — CONFIRMED mechanism; filing the finding (per warrant)**\n\nThe requeued verdict confirms the same mechanism as u1: the per-process SpansBuffer at flusher.py:93 recomputes the flush quota from its shard subset, multiplying per-cycle flush volume by the process count and delaying soft backpressure. This is the same defect as u1, so it is reported once with the requeued verdict's confirmation.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR93824__20260821/blob/3162ad68a5c87666788b27a44eb31235025091a9/src/sentry/spans/consumers/process/flusher.py#L49-L53\n\n
\n", + "created_at": "2026-08-21T21:34:25Z" + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 267, + "body": "### 🔵 Low · SpanFlusher.submit fan-out multiplies Redis round trips and cluster memory sweeps\n\nThe new submit method iterates over all buffers, issuing a fresh Redis pipeline per buffer and calling iter_cluster_memory_usage on the same cluster for every buffer. This multiplies round trips by num_processes and sweeps the whole cluster once per process instead of once, causing duplicated work with no correctness effect.\n\nIt may be intentional since submit is documented as not a hot path, but the structural duplication is certain from the diff.\n\n> **Fix** — Consolidate the per-buffer operations into a single pipeline and a single cluster memory sweep, or document and accept the overhead given the low call frequency.\n", + "created_at": "2026-08-21T21:34:25Z" + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 254, + "body": "### 🟡 Medium · Hung worker never killed before restart — duplicate production and worker leak\n\nProduction workers are multiprocessing.context.SpawnProcess (flusher.py:53, 105) — a BaseProcess subclass, not multiprocessing.Process — and test workers are threading.Thread (flusher.py:108). isinstance(process, multiprocessing.Process) at flusher.py:254 is always False, so process.kill() never executes. The restart at flusher.py:259 is unconditional for both dead and hang cases. On hang, the old worker is alive and merely blocked; the replacement gets a fresh SpansBuffer over the same shards and overwrites self.processes[i]/self.buffers[i] (flusher.py:123-125), orphaning the old worker. When the blocked worker resumes, two main loops flush the same span-buf:q:{shard} queues and both produce + delete the same segments (flusher.py:169, 197, 205) → duplicate payloads to buffered-segments and a broken delete-after-produce at-most-once invariant; workers also leak until the 10-restart RuntimeError. The pre-change _ensure_process_alive called self.process.kill() unconditionally, so the hung worker was stopped before restart — the new isinstance guard introduced the gap.This comment also covers: Dead code: _create_process_for_shard duplicates restart path with divergent semantics\n\n**Advisory** — the proof pass refuted the claim as stated on triggerability, but established reachability and harm. Shown for a second look; it will not block a merge. flusher.py:53 `self.mp_context = multiprocessing.get_context(\"spawn\")`; flusher.py:105 `make_process = self.mp_context.Process` → every production worker is a `multiprocessing.context.SpawnProcess`. CPython stdlib contract (repo pins `python:3.13.1-slim-bookworm`, self-hosted/Dockerfile:1): `multiprocessing/__init__.py` binds `Process = process.BaseProcess` (module-level alias) and `multiprocessing/context.py` declares `class SpawnProcess(process.BaseProcess)`. Therefore `multiprocessing.Process` **is** `BaseProcess` and `SpawnProcess` is a subclass of it — `isinstance(SpawnProcess_instance, multiprocessing.Process)` is **True**, refuting the finding's premise that the guard is \"always False\". flusher.py:254-257: the guard matches production workers, so `process.kill()` executes on both the hang and dead paths before the unconditional restart at flusher.py:259; the `except (ValueError, AttributeError)` only swallows \"process not running\" for already-exited workers. flusher.py:346-347 uses the same guard correctly for `terminate()` in `join()`. The only values failing the guard are `threading.Thread` test workers (flusher.py:108, `produce_to_pipe is not None`), which have no `kill()` by design — the guard exists to skip them. Nothing in the repo shadows/re-binds `multiprocessing.Process` (grep over `src/sentry` found no assignment to it). Reachability: factory.py:71-76 wires `SpanFlusher` into the consumer pipeline; `submit` (flusher.py:267) calls `_ensure_processes_alive` (flusher.py:218) → line 254 on every message. Secondary comment: `_create_process_for_shard` (flusher.py:127) is confirmed dead code (grep found zero callers in `src`, only its definition and its internal call at flusher.py:131) — a maintainability note, not the claimed defect.\n_Impact: The claimed harm cannot occur because the hung/dead spawn worker is SIGKILLed (flusher.py:255) before the replacement starts; no duplicate production, no worker leak, no at-most-once violation in production._\n_Queries: read_file(src/sentry/spans/consumers/process/flusher.py) · glob(**/spans/consumers/process/*.py) · grep(_create_process_for_shard, src/sentry/spans) · grep(multiprocessing\\.Process|SpawnProcess, src/sentry/spans) · grep(_create_process_for_shard\\b, src) · grep(kill\\(\\)|process\\.kill|\\.terminate\\(\\), src/sentry/spans) · read_file(tests/sentry/spans/consumers/process/test_flusher.py) · read_file(self-hosted/Dockerfile) · read_file(src/sentry/spans/consumers/process/factory.py) · grep(multiprocessing\\.Process\\s*=|Process\\s*=\\s*BaseProcess|set_start_method|get_context, src/sentry) · ast_document_symbols(src/sentry/spans/consumers/process/flusher.py)_\n\n> **Fix** — Remove the isinstance guard and call process.kill() unconditionally (or check for the actual process type), and ensure the old worker is stopped before starting the replacement.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR93824__20260821/blob/3162ad68a5c87666788b27a44eb31235025091a9/src/sentry/spans/consumers/process/flusher.py#L125-L129\n\n
\n", + "created_at": "2026-08-21T21:34:26Z" + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 199, + "body": "### 🔵 Low · Metrics tag fragmentation: same shard dimension recorded under different keys/types\n\nThe same logical dimension (which shard(s) a metric is about) is recorded under three different spellings/types on changed lines: 'shard' as a comma-joined string at 185/195, 'shards' as the same string at 199, and 'shard' as a raw int at 244. This diverges from the sibling convention using 'shard_i' at buffer.py:372/447.\n\nAny alert/dashboard filtering on tag 'shard' (string form) will miss the int-tagged increments at 244 or the 'shards'-tagged timer at 199. This may be intentional if the metrics backend coerces types, but the key-name divergence at 199 is clearly inconsistent.\n\n> **Fix** — Pick one key ('shard') and one value form (string) for all four sites (185, 195, 199, 244).\n", + "created_at": "2026-08-21T21:34:26Z" + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 341, + "body": "### 🟡 Medium · `break` skips the `terminate()` teardown for unvisited elements\n\nThis loop tears each element down with `terminate()` (line 347), but a `break` on line 341 runs BEFORE that teardown. When the break fires, the current element and every element the loop has not yet reached are left without a `terminate()` call, and no equivalent teardown runs outside the loop.\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. The loop under scrutiny is in `SpanFlusher.join()` at flusher.py:328–347. The `break` at lines 338–341 fires only when `deadline is not None` — i.e., only when `join()` was invoked with a non-None `timeout` — and `remaining_time <= 0`. The consumer's `--join-timeout` option (run.py:537, `@click.option(\"--join-timeout\", type=float, ..., default=None)`) is the only way to supply that timeout (`get_stream_processor` receives it at run.py:635); the `process-spans` consumer registered at consumers/__init__.py:426–439 does not set a static join_timeout, and default is None, so default deployments never hit the break. When the break does fire, the processes skipped are created with `daemon=True` (flusher.py:120, applied to both `mp_context.Process` and `threading.Thread`). Python's multiprocessing atexit handler (`multiprocessing.util._exit_function`) calls `terminate()` on every daemon child at interpreter exit, so the skipped processes are always terminated promptly when the parent exits, and their file descriptors/sockets are reaped by the OS at process death. Daemon processes cannot outlive their parent, so \"orphaned processes\" cannot occur. Additionally, the skipped teardown (`process.terminate()` at line 347) sends SIGTERM, which in `SpanFlusher.main` raises KeyboardInterrupt that is caught at flusher.py:209–210 (`except KeyboardInterrupt: pass`), so even the *executed* terminate path skips `producer.close()`; the producer is closed only when the process observes `stopped` and exits its `while not stopped.value` loop naturally — which the break may skip but is the designed tradeoff of a deadline-bound join. `join` itself is reachable: `SpanFlusher` implementing `ProcessingStrategy` is wired at factory.py:71–76 into the consumer strategy chain, and arroyo's shutdown delegates `join(timeout)` down the chain (same delegation pattern as sibling consumers, e.g. sentry_metrics/consumers/indexer/parallel.py:77 `self.__next_step.join(timeout)`).\n_Impact: n/a — The claimed harm (orphaned processes, unclosed files/sockets) cannot materialize: `daemon=True` at flusher.py:120 guarantees the interpreter terminates the skipped processes/threads at parent exit, and the OS reaps their file descriptors; the remaining timing difference (process runs a few extra moments, possibly mid-produce, before interpreter-exit SIGTERM) is the designed outcome of the deadline bail-out and also matches the outcome of the explicit terminate path (KeyboardInterrupt → `except KeyboardInterrupt: pass` → no `producer.close()`)._\n_Queries: read_file(src/sentry/spans/consumers/process/flusher.py, offset=280) · read_file(src/sentry/spans/consumers/process/flusher.py, offset=1, limit=280) · ast_document_symbols(src/sentry/spans/consumers/process/flusher.py) · grep(\"SpanFlusher\", src/sentry) · grep(\"flusher.*join|\\.join\\(\", src/sentry/spans) · read_file(src/sentry/spans/consumers/process/factory.py) · grep(\"def terminate|def join|def close\", src/sentry) · grep(\"join_timeout\", src/sentry) · read_file(src/sentry/runner/commands/run.py, offset=640, limit=40) · grep(\"get_stream_processor\\(|join_timeout=join_timeout|--join-timeout\", src/sentry) · read_file(src/sentry/runner/commands/run.py offset=530 area for --join-timeout option) · grep(\"join|terminate|close\", tests/sentry/spans/consumers/process/test_flusher.py)_\n\n> **Fix** — Move the `terminate()` call before the `break`, or tear down the remaining elements after the loop, so no element is skipped when the loop bails out early.\n", + "created_at": "2026-08-21T21:34:26Z" + }, + { + "path": "tests/sentry/spans/consumers/process/test_consumer.py", + "line": 62, + "body": "### 🟡 Medium · This test monkeypatches `time.sleep` to a no-op lambda and then calls `time.sleep()` expecting a real wait. The patch is still active, so the call returns immediately and provides no synchronization — whatever the wait was meant to let finish (a flusher, a worker, a retry) has not run when the next assertion executes. Wait on the actual condition (poll with a timeout), or restore the real `time.sleep` before this call.\n\n\n[CWE-670: Always-Incorrect Control Flow Implementation] This test monkeypatches `time.sleep` to a no-op lambda and then calls `time.sleep()` expecting a real wait. The patch is still active, so the call returns immediately and provides no synchronization — whatever the wait was meant to let finish (a flusher, a worker, a retry) has not run when the next assertion executes. Wait on the actual condition (poll with a timeout), or restore the real `time.sleep` before this call.\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). test_consumer.py:15 — `monkeypatch.setattr(\"time.sleep\", lambda _: None)`; no `monkeypatch.undo()`/restore exists in the file, so the patch is active through line 62. test_consumer.py:58 writes `fac._flusher.current_drift.value = 9000`; test_consumer.py:61-62 comment + `time.sleep(0.1)` (no-op); test_consumer.py:64 `step.join()`; test_consumer.py:66 `(msg,) = messages`. flusher.py:166-168 — worker loop reads `current_drift.value` at the top of each iteration; flusher.py:182 `time.sleep(1)` is the same module attribute, so the patch also makes the worker busy-spin. flusher.py:328-344 — `join()` sets `self.stopped.value = True` (line 331); the `while not stopped.value` loop (line 166) exits on its next check **without a final flush**. The flush of the drift-advanced segment happens only in an iteration that starts after the drift write (line 58) and reads 9000 before stopped is set — a sub-millisecond window the no-op sleep does not widen.\n_Impact: flaky/nondeterministic `test_basic` in CI; the stated wait for the flusher to process the drift change does nothing, so regressions in the buffer/flusher path can ship undetected._\n_Queries: read_file(path=\"tests/sentry/spans/consumers/process/test_consumer.py\") · read_file(path=\"src/sentry/spans/consumers/process/flusher.py\", offset=230, limit=120) · read_file(path=\"src/sentry/spans/consumers/process/flusher.py\", offset=1, limit=210) · read_file(path=\"src/sentry/spans/consumers/process/factory.py\") · read_file(path=\"src/sentry/spans/buffer.py\", offset=360, limit=80) · grep(pattern=\"class Flusher|def join|current_drift\", path=\"src/sentry/spans/consumers/process\") · grep(pattern=\"arroyo\", path=\"requirements-base.txt\") · grep(pattern=\"setswitchinterval\", path=\"tests\") · grep(pattern=\"sleep|monkeypatch|undo|restore\", path=\"tests/sentry/spans/consumers/process/test_consumer.py\")_\n\n", + "created_at": "2026-08-21T21:34:26Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Monkeypatched time.sleep at test_consumer.py:62 provides no synchronization | `tests/sentry/spans/consumers/process/test_consumer.py:62` |\n| 🔵 Low | Flusher process-count knob mishandled: 0/None semantics diverge between entry points, and max-flush-segments silently becomes per-process when processes > 1, multiplying flush volume and delaying backpressure | `src/sentry/spans/consumers/process/flusher.py:93` |\n| 🔵 Low | SpanFlusher.submit fan-out multiplies Redis round trips and cluster memory sweeps | `src/sentry/spans/consumers/process/flusher.py:267` |\n| 🟡 Medium | Hung worker never killed before restart — duplicate production and worker leak | `src/sentry/spans/consumers/process/flusher.py:254` |\n| 🔵 Low | Metrics tag fragmentation: same shard dimension recorded under different keys/types | `src/sentry/spans/consumers/process/flusher.py:199` |\n| 🟡 Medium | `break` skips the `terminate()` teardown for unvisited elements | `src/sentry/spans/consumers/process/flusher.py:341` |\n| 🟡 Medium | This test monkeypatches `time.sleep` to a no-op lambda and then calls `time.sleep()` expecting a real wait. The patch is still active, so the call returns immediately and provides no synchronization — whatever the wait was meant to let finish (a flusher, a worker, a retry) has not run when the next assertion executes. Wait on the actual condition (poll with a timeout), or restore the real `time.sleep` before this call. | `tests/sentry/spans/consumers/process/test_consumer.py:62` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -6.2 |\n| Test Coverage | 61 (C) | 100 (A+) | +38.9 |\n| Consistency | 94 (A) | 94 (A) | -0.0 |\n| **Overall** | **79 (B)** | **84 (B+)** | **+5.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:26Z" + } + ] } ] }, @@ -16412,6 +16784,72 @@ "created_at": "2026-06-28T22:58:37Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/5", + "review_comments": [ + { + "path": "src/sentry/issues/endpoints/browser_reporting_collector.py", + "line": 52, + "body": "### 🔵 Low · Validation/gating bypassed: mutual-exclusion validators bypassed by zero values, analytics recorded before feature gate\n\nThe validators use truthiness checks on initial_data, so age:0 or timestamp:0 bypasses the mutual-exclusion invariant documented in the docstrings and tested by test_mixed_fields. This may be intentional to allow zero values, but the documented rule is not enforced for these inputs.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). Route is real: `^reporting-api-experiment/$` → `BrowserReportingCollectorEndpoint.as_view()` at src/sentry/api/urls.py:3533-3535; `post()` at src/sentry/issues/endpoints/browser_reporting_collector.py:89 constructs `BrowserReportSerializer(data=report)` and calls `.is_valid()` at line 109. Only guard is the operator option `issues.browser_reporting.collector_endpoint_enabled` (src/sentry/options/defaults.py:3489-3494, default False, FLAG_AUTOMATOR_MODIFIABLE) — a supported deployment mode (the feature's own tests enable it, test line 64); not a refutation. Field constraints admit the trigger values: `age = IntegerField(required=False)` (line 47, no min_value → 0 valid); `timestamp = IntegerField(required=False, min_value=0)` (line 48 → 0 valid). Mechanism confirmed: validators at lines 52 and 58 use truthiness — `if self.initial_data.get(\"age\"):` and `if self.initial_data.get(\"timestamp\"):` — not presence. DRF runs `validate_` for every present field and accumulates errors; for input `{\"age\": 0, \"timestamp\": 0}` both checks see a falsy counterpart and neither raises → `is_valid()` true → 200. The intended rejection is documented by `test_mixed_fields` (tests/sentry/api/endpoints/test_browser_reporting_collector.py:164-175), which expects 422 with both error messages for nonzero mixed fields. The claim's examples are overbroad (age:0 + nonzero timestamp is still rejected by validate_age; nonzero age + timestamp:0 by validate_timestamp) but the stated mechanism is true for the both-zero input.\n_Impact: accepts with 200 a report the validators' docstrings (lines 51, 57) and test_mixed_fields promise to reject with 422, and increments `browser_reporting.raw_report_received` (line 124) with that mixed-spec report — wrong validation result plus contaminated telemetry. Consequence class: damage; low severity (experimental metrics-only endpoint; no crash, no security boundary, no data loss)._\n_Queries: read_file(src/sentry/issues/endpoints/browser_reporting_collector.py) · glob(**/browser_reporting_collector*.py) · grep(pattern=\"browser_reporting.collector_endpoint_enabled\", path=\"src/sentry\") · read_file(src/sentry/options/defaults.py, offset=3480, limit=20) · grep(pattern=\"reporting-api-experiment\", path=\"src/sentry\") · grep(pattern=\"BrowserReportingCollectorEndpoint\", path=\"src/sentry\") · glob(**/tests/**/*browser_reporting*) · read_file(tests/sentry/api/endpoints/test_browser_reporting_collector.py)_\n\n> **Fix** — Use presence checks like \"age\" in self.initial_data instead of truthiness.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. validate_timestamp/validate_age use truthiness instead of presence, bypassing mutual-exclusion invariant for zero values**\n\nBoth fields are optional and timestamp:0 is valid per min_value=0. A report with age:0 and a timestamp, or age and timestamp:0, passes validation with 200 instead of the promised 422.\n\nThe validators' docstrings promise 'If timestamp is present, age must be absent'. This is a presence-vs-truthiness asymmetry on a new validation path. It may be intentional if age:0 is not considered a realistic browser report, but age:0 means 'just now' and is realistic.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py#L79-L83\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx", + "line": 247, + "body": "### 🟡 Medium · Layout regression from flex/styling refactor: TraceTabsAndVitals lost align-items center and gap, Flex.Item grow={1} lost shrink behavior\n\nThe replacement of `flex: 1` (which is `flex: 1 1 0%`, i.e. flex-basis: 0%) with `` is only equivalent if the Flex.Item grow prop produces the full shorthand. If grow only sets flex-grow, flex-basis remains auto, so the column no longer shrinks to yield space to Metadata (which has margin-right: 100px and margin-left: space(4)), potentially causing overflow on constrained widths.\n\nThis may be intentional if the design system's grow prop is documented to reproduce the shorthand, but that cannot be verified from this checkout.\n\n> **Fix** — Verify Flex.Item's grow prop implementation; if it only sets flex-grow, either use an explicit basis prop or keep the styled flex: 1 container.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx#L91-L95\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py", + "line": 153, + "body": "### 🔵 Low · Timestamp comparison between error event and replay segment uses mismatched formats/units, causing TypeError crash or systematic misordering — Replay breadcrumb summarization: mismatched timestamp units cause TypeError/misordering, broad except swal…\n\ngen_request_data compares error_events[error_idx][\"timestamp\"] (raw nodestore payload, likely ISO-8601 string) with event.get(\"timestamp\", 0) (replay segment epoch milliseconds) using strict '<'. If the error payload is an ISO string, this raises TypeError and 500s the endpoint. If both are numeric, the unit mismatch (epoch seconds vs epoch ms) causes all errors to sort before all segments, violating the chronological-order contract.\n\nThe exact nodestore payload format is outside readable scope, so confidence is limited.\n\n> **Fix** — Normalize both timestamps to the same unit/format (e.g., epoch seconds float) before comparison.\n\n---\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py#L151-L155\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py#L160-L164\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "src/sentry/integrations/github/integration.py", + "line": 406, + "body": "### 🔵 Low · Commit-context comment construction: unsanitized environment/issue title allows Markdown injection, environment suffix from wrong event, per-issue Snuba N+1\n\nThe new get_environment_info call inside the list comprehension triggers 1-2 Snuba queries per issue, up to 10 per merged-PR comment task, whereas the pre-change loop performed zero Snuba queries. This is a performance regression in a background task, bounded and not a crash.\n\nIt may be intentional to provide environment info, but the N+1 pattern is a real inefficiency.\n\n> **Fix** — Batch-fetch recommended events for all issues in a single query, or cache the environment info per issue to avoid repeated Snuba round-trips.\n\n---\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/src/sentry/integrations/source_code_management/commit_context.py#L582-L586\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/src/sentry/integrations/source_code_management/commit_context.py#L588-L592\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "src/sentry/hybridcloud/tasks/deliver_webhooks.py", + "line": 239, + "body": "### 🔵 Low · Taskworker deadlines shorter than task loop budgets: check_auth_identities 60s deadline drops identities, deliver_webhooks 120s deadline terminates mid-iteration\n\nThe 120s taskworker deadline is shorter than the code's own designed runtime budget of 180s (BATCH_SCHEDULE_OFFSET). The deadline check only runs at the top of the while loop, so termination can fire mid-iteration while futures are in flight. Records whose HTTP POST succeeded but whose delete never ran remain in the mailbox and are re-delivered on the next cycle, causing duplicate webhook POSTs. This may be intentional if the taskworker's kill semantics differ from the code's graceful yield, but the inversion is certain from the diff.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). 1. deliver_webhooks.py:239 — `drain_mailbox_parallel` sets `processing_deadline_duration=120`; task.py:135-137,178 pass it as integer **seconds** into `TaskActivation.processing_deadline_duration`. Sibling `drain_mailbox` uses 300s (line 161), so 120s is the divergent value on the parallel path. 2. deliver_webhooks.py:44 + webhookpayload.py:18 — `BATCH_SCHEDULE_OFFSET = timedelta(minutes=BACKOFF_INTERVAL=3)` = **180s**. The task's loop deadline is `timezone.now() + BATCH_SCHEDULE_OFFSET` (line 294) checked only at the top of `while True` (line 301). 120s < 180s — the inversion is arithmetic fact. 3. workerchild.py:235 — task execution wrapped in `timeout_alarm(inflight.activation.processing_deadline_duration, handle_alarm)`; workerchild.py:145-156 — `handle_alarm` raises `ProcessingDeadlineExceeded` (a `BaseException`) at the point of execution where SIGALRM lands. The loop-top check at line 301 cannot prevent a mid-iteration kill; the kill marks the task `TASK_ACTIVATION_STATUS_FAILURE` (workerchild.py:254). 4. deliver_webhooks.py:321 — `with ThreadPoolExecutor(...)`: on the exception, `__exit__` runs `shutdown(wait=True)`, so in-flight `perform_request` POSTs complete while the main-thread result loop — which is what deletes successful records (line 353) — never runs for them. 5. deliver_webhooks.py:385-390 — `deliver_message_parallel` does NOT bump `attempts` on success (sequential `deliver_message` does via `schedule_next_attempt()`), so the surviving record is fully re-deliverable. 6. Re-delivery path: scheduler filters `schedule_for__lte=timezone.now()` (line 113); the mailbox batch was pushed to `now + 180s` (line 146); once elapsed, `drain_mailbox_parallel.delay()` (line 150) re-dispatches → duplicate POST. 7. Reachability: celery beat runs `schedule_webhook_delivery` every 10s (conf/server.py:1066-1071 and 1718-1721); `ast_call_sites(drain_mailbox_parallel)` shows the sole non-test caller is `schedule_webhook_delivery` at deliver_webhooks.py:150, gated on `updated_count >= MAX_MAILBOX_DRAIN/5` (≥60 records, line 149). Default `worker_threads=4` (options/defaults.py:2043). No taskworker retry policy is configured for this task (TaskworkerConfig has `retry=None`), so nothing re-runs the aborted task or cleans the leftover records.\n_Impact: Duplicate webhook deliveries for records already POSTed successfully, and premature abort (FAILURE) of the parallel drain at 120s against its designed 180s completion window._\n_Queries: read_file(src/sentry/hybridcloud/tasks/deliver_webhooks.py) · read_file(src/sentry/hybridcloud/models/webhookpayload.py) · grep(pattern=\"BACKOFF_INTERVAL\", path=\"src/sentry\") · read_file(src/sentry/taskworker/workerchild.py) · read_file(src/sentry/taskworker/task.py) · grep(pattern=\"processing_deadline\", path=\"src/sentry\") · grep(pattern=\"schedule_webhook_delivery\", path=\"src/sentry\") · read_file(src/sentry/conf/server.py, offset=1055, limit=25) · read_file(src/sentry/conf/server.py, offset=1710, limit=20) · ast_call_sites(symbol=\"drain_mailbox_parallel\") · grep(pattern=\"hybridcloud.webhookpayload.worker_threads\", path=\"src/sentry\") · read_file(src/sentry/options/defaults.py, offset=2038, limit=10) · read_file(tests/sentry/hybridcloud/tasks/test_deliver_webhooks.py, offset=585, limit=60)_\n\n> **Fix** — Increase the processing_deadline_duration for drain_mailbox_parallel to at least BATCH_SCHEDULE_OFFSET (180s), or move the deadline check to also run between futures so the task can yield gracefully before termination.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/src/sentry/tasks/auth/check_auth.py#L75-L79\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "static/app/components/events/eventAttachments.tsx", + "line": 143, + "body": "### 🔵 Low · Grid layout overflow: summary sharing list cell's min-content clips on short viewports, long attachment names blow out 1fr column instead of ellipsizing\n\nThe cell wrapper previously had overflow: hidden via overflowEllipsis, which zeroed the automatic minimum size and allowed the 1fr track to shrink so the inner Name's ellipsis engaged. The replacement Flex has no overflow/min-width constraint, so the grid item keeps min-width: auto computed from the nowrap content, the 1fr track grows to full filename width, and the Name's overflow: hidden never engages because the box is never width-constrained.\n\nThis is a visual layout regression triggered by an attachment whose name is wider than the 1fr column. It may be intentional if the new layout deliberately allows full-width names, but the dropped styling property on a changed line indicates a regression.\n\n> **Fix** — Add overflow: hidden (or min-width: 0) to the Flex cell wrapper so the 1fr track can shrink and the inner Name ellipsizes as before.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Container overflowEllipsis dropped in Flex migration breaks attachment name truncation**\n\nThe FlexCenter container previously carried the overflowEllipsis mixin (overflow:hidden; text-overflow:ellipsis; white-space:nowrap), which zeroed the CSS Grid automatic-minimum-size of the grid item, allowing the 1fr name track to shrink and truncate long filenames. The replacement bare lacks overflow:hidden, so the grid item's automatic minimum becomes its min-content size, preventing the name column from shrinking below the full filename width.\n\nLong attachment names will no longer ellipsize; instead the column expands and pushes the Size/Actions columns or overflows the panel. This could be intentional if the unreadable Flex component internally sets min-width:0 or overflow:hidden, which would restore the shrink chain, but that cannot be verified from this checkout.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/static/app/views/feedback/feedbackListPage.tsx#L86-L90\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "static/app/views/dashboards/widgetCard/chart.tsx", + "line": 164, + "body": "### 🟡 Medium · New guard/flag branches bypass loading state: terminal error renders before project store resolves, flag branch discards all table data\n\nWhen the 'use-table-widget-visualization' flag is enabled, the branch hardcodes columns={[]} and empty tableData, discarding result.data, result.meta, fields, fieldAliases, title, and other computed values. This causes table widgets to render zero columns and zero rows despite fully fetched data.\n\nThis may be intentional as a scaffold, but it silently loses data in a live path.\n\n> **Fix** — Pass the actual result.data, result.meta, and computed fields to TableWidgetVisualization instead of empty literals.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Flag-gated branch renders hardcoded empty table payload**\n\nWhen the 'use-table-widget-visualization' flag is present in organization.features, the branch at chart.tsx:165-174 renders TableWidgetVisualization with a literal empty payload (columns={[]}, tableData={{data: [], meta: {fields: {}, units: {}}}}), discarding the real result.data/result.meta and eventView/fields/fieldAliases available in the same scope. The flag is unregistered repo-wide (grep shows it only at chart.tsx:164), so either the flag is granted externally (then every table widget renders blank) or the branch is dead code — either way the new visualization is never wired to real data.\n\nThis may be intentional as a placeholder for a future migration, but as written it produces a table with no headers and no rows whenever the flag is active.\n\n**2. Flag branch renders a permanently blank table**\n\nWith the use-table-widget-visualization flag enabled, the production caller passes columns={[]} and empty tableData to TableWidgetVisualization. Since columns={[]} is truthy, the component's documented fallback to extract fields from tableData never runs, so GridEditable renders zero columns and zero rows — a blank table.\n\nThis may be intentional WIP scaffolding, but no comment or TODO marks it as such, and the component's own spec shows the fallback would render the real fields if exercised.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR5__20260821/blob/ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9/static/app/views/replays/detail/ai/index.tsx#L88-L92\n\n
\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "src/sentry/workflow_engine/endpoints/validators/base/detector.py", + "line": 64, + "body": "### 🟡 Medium · `validated_data.get('detector_type', …)` uses a key no sibling assigns\n\n`update` reads `validated_data.get('detector_type', …)`, but the sibling `create` writes the same destination from key `'data_source'`. A serializer's validated_data is keyed by its declared field names, so a `.get` with a key no sibling uses never hits — this always returns the default and the requested change is silently dropped.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). detector.py:64 reads validated_data.get('detector_type', …) but the serializer declares the field as 'type' (detector.py:35), so validated_data never contains 'detector_type' and the default (instance.group_type) is always used, rewriting instance.type to its current slug. The sibling create writes via validated_data['type'].slug (detector.py:133). The PUT handler organization_detector_details.py:134 is URL-registered, permission-gated, and calls validator.save() at line 149, dispatching to update().\n_Impact: A PUT request that changes the detector type silently no-ops: the requested conversion never happens, no error is raised, HTTP 200 is returned, and the detector keeps operating under its old type (validator, config JSON-schema, and metrics) while the caller believes the change applied._\n_Queries: grep detector_type · read detector.py:35,64,133 · read organization_detector_details.py:134,143,149_\n\n> **Fix** — Use the same key the sibling uses (`validated_data['data_source']` / `.get('data_source', …)`), or confirm `'detector_type'` is a declared serializer field.\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": "src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py", + "line": 118, + "body": "### 🟡 Medium · zip(seq, d.values()) pairs a sequence positionally with the values of a dict returned by an unordered batch fetch (get_multi/mget/multi_get/in_bulk/bulk*). The value-iteration order need not match the input sequence, and a missing/None entry shifts the alignment, so each seq element is paired with the wrong record. Index the dict by key instead — `d[key]` — or iterate `d.items()` to keep key and value together.\n\n\n[CWE-758: Reliance on Undefined, Unspecified, or Implementation-Defined Behavior] zip(seq, d.values()) pairs a sequence positionally with the values of a dict returned by an unordered batch fetch (get_multi/mget/multi_get/in_bulk/bulk*). The value-iteration order need not match the input sequence, and a missing/None entry shifts the alignment, so each seq element is paired with the wrong record. Index the dict by key instead — `d[key]` — or iterate `d.items()` to keep key and value together.\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). zip(error_ids, events.values()) at project_replay_summarize_breadcrumbs.py:118; get_multi (nodestore/base.py:174-210) gives no key-order guarantee matching id_list; partial cache hit (base.py:194-205) appends cached keys at end; all-cache-hit (base.py:190-192) returns arbitrary order; default backend (conf/server.py:2144, DjangoNodeStorage) _get_bytes_multi (django/backend.py:48-49) uses DB row order and missing nodes are absent from dict; endpoint routed at src/sentry/api/urls.py:2707-2711; get() at file:52-101 runs fetch_error_details (line 94) by default when error_ids non-empty (line 84); only three org feature flags gate it (lines 54-64)\n_Impact: Each ErrorEvent (lines 110-120) receives title/timestamp/message of a different error than its id; these flow into generate_error_log_message (line 126-131) and gen_request_data (lines 142-168), interleaving errors chronologically into breadcrumb log sent to Seer (analyze_recording_segments, lines 172-181); misaligned timestamps place errors at wrong points in timeline; misaligned titles/messages misdescribe errors; endpoint output — AI summary of replay's errors — is wrong_\n\n", + "created_at": "2026-08-21T21:34:19Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Validation/gating bypassed: mutual-exclusion validators bypassed by zero values, analytics recorded before feature gate | `src/sentry/issues/endpoints/browser_reporting_collector.py:52` |\n| 🟡 Medium | Layout regression from flex/styling refactor: TraceTabsAndVitals lost align-items center and gap, Flex.Item grow={1} lost shrink behavior | `static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx:247` |\n| 🔵 Low | Query filters bypassed in trace attribute views: HIDDEN_ATTRIBUTES filter dropped from default view and environment filter dropped from attribute keys query | `static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx:61` (not in the diff) |\n| 🔵 Low | Timestamp comparison between error event and replay segment uses mismatched formats/units, causing TypeError crash or systematic misordering — Replay breadcrumb summarization: mismatched timestamp units cause TypeError/misordering, broad except swal… | `src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py:153` |\n| 🔵 Low | Commit-context comment construction: unsanitized environment/issue title allows Markdown injection, environment suffix from wrong event, per-issue Snuba N+1 | `src/sentry/integrations/github/integration.py:406` |\n| 🔵 Low | Taskworker deadlines shorter than task loop budgets: check_auth_identities 60s deadline drops identities, deliver_webhooks 120s deadline terminates mid-iteration | `src/sentry/hybridcloud/tasks/deliver_webhooks.py:239` |\n| 🔵 Low | Grid layout overflow: summary sharing list cell's min-content clips on short viewports, long attachment names blow out 1fr column instead of ellipsizing | `static/app/components/events/eventAttachments.tsx:143` |\n| 🟡 Medium | New guard/flag branches bypass loading state: terminal error renders before project store resolves, flag branch discards all table data | `static/app/views/dashboards/widgetCard/chart.tsx:164` |\n| 🟠 High | Unhandled Detector.DoesNotExist in fire_actions_for_groups causes batch-level failure | `src/sentry/workflow_engine/processors/delayed_workflow.py` (not in the diff) |\n| 🟡 Medium | `validated_data.get('detector_type', …)` uses a key no sibling assigns | `src/sentry/workflow_engine/endpoints/validators/base/detector.py:64` |\n| 🟠 High | Switch over `CombinedAlertType` omits ISSUE that a sibling switch handles | `static/app/views/alerts/list/rules/row.tsx:231` (not in the diff) |\n| 🟡 Medium | zip(seq, d.values()) pairs a sequence positionally with the values of a dict returned by an unordered batch fetch (get_multi/mget/multi_get/in_bulk/bulk*). The value-iteration order need not match the input sequence, and a missing/None entry shifts the alignment, so each seq element is paired with the wrong record. Index the dict by key instead — `d[key]` — or iterate `d.items()` to keep key and value together. | `src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py:118` |\n\n---\n\n### 🔵 Low · Query filters bypassed in trace attribute views: HIDDEN_ATTRIBUTES filter dropped from default view and environment filter dropped from attribute keys query\n\n`static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx:61`\n\nThe memo returns sorted attributes before the HIDDEN_ATTRIBUTES check when searchQuery is empty, so internal attributes like is_segment, project_id, and received remain visible in the default view. This defeats the stated purpose of the change.\n\nThe filter belongs before the early return. Additionally, the hidden check uses exact match while the sibling predicate lowercases/trims, so variants like 'Received ' could leak even when searching.\n\n---\n\n### 🟠 High · Unhandled Detector.DoesNotExist in fire_actions_for_groups causes batch-level failure\n\n`src/sentry/workflow_engine/processors/delayed_workflow.py`\n\nThe new get_detector_by_event call (imported at line 60) sits inside the per-group loop (line 530) with no try/except. get_detector_by_event raises Detector.DoesNotExist (detector.py:46) when no detector matches. A single group lacking a matching detector propagates the exception out of fire_actions_for_groups → process_delayed_workflows (line 715), failing the entire batch and triggering up to 5 retries.\n\nThis is a per-group condition causing batch-level failure — a partial-failure amplification bug. The imports are new in this PR (+17/-5 diff), confirming newly introduced behavior.\n\n---\n\n### 🟠 High · Switch over `CombinedAlertType` omits ISSUE that a sibling switch handles\n\n`static/app/views/alerts/list/rules/row.tsx:231`\n\nThis switch handles 3 member(s) of `CombinedAlertType` and routes `ISSUE` into its catch-all, which does member-specific work rather than erroring. A sibling switch on `CombinedAlertType` in the same unit handles `ISSUE` explicitly — the coverage is asymmetric, so this switch mishandles the omitted member(s).\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 35 (F) | 35 (F) | -0.0 |\n| Runtime | 100 (A+) | 90 (A) | -9.7 |\n| Test Coverage | 61 (C) | 100 (A+) | +39.0 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **76 (B)** | **81 (B+)** | **+5.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:20Z" + } + ] } ] }, @@ -18067,6 +18505,36 @@ "created_at": "2026-06-28T22:12:55Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/1", + "review_comments": [ + { + "path": "src/sentry/api/endpoints/organization_auditlogs.py", + "line": 71, + "body": "### 🟡 Medium · Unconditional organization_context.member.has_global_access dereference on every request, including the default path — AttributeError risk\n\nThe expression request.user.is_superuser or organization_context.member.has_global_access is evaluated on every request before the use_optimized check, even for the default DateTimePaginator path. organization_context.member is a nullable RPC member; for non-superusers with no member row, this raises AttributeError → HTTP 500. Superusers short-circuit the or, so the crash is confined to the non-superuser population.\n\nIt might be intentional if the permission layer guarantees member is never None for non-superusers reaching this line, but that guarantee is not established in the readable scope.\n\n> **Fix** — Guard the dereference, e.g. enable_advanced = request.user.is_superuser or (organization_context.member is not None and organization_context.member.has_global_access).\n", + "created_at": "2026-08-21T21:34:04Z" + }, + { + "path": "src/sentry/api/paginator.py", + "line": 840, + "body": "### 🟠 High · OptimizedCursorPaginator.get_item_key raises TypeError on datetime keys\n\nThe new get_item_key applies math.floor/ceil directly to a datetime object (key='datetime'), which raises TypeError. The sibling DateTimePaginator converts to epoch float first; this conversion was dropped. This crashes on the first page of the optimized audit-log endpoint with any non-empty result set.\n\n> **Fix** — Convert the datetime to an epoch-based numeric value (e.g., float(value.strftime('%s.%f')) * multiplier) before applying floor/ceil, matching DateTimePaginator.get_item_key.\n", + "created_at": "2026-08-21T21:34:04Z" + }, + { + "path": "src/sentry/api/paginator.py", + "line": 877, + "body": "### 🟠 High · Negative-offset slice crashes the Django QuerySet\n\nWhen enable_advanced_features and cursor.offset < 0, the raw negative offset is passed into queryset[start_offset:stop], which Django rejects with 'Negative indexing is not supported.' The in-code comment claiming Django handles negative slicing automatically is false, and the branch is reachable via a hand-crafted cursor=0:-1:0.\n\n> **Fix** — Clamp negative offsets to 0 (as BasePaginator does at line 182) or validate/reject negative offsets before slicing.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Negative offset advanced feature raises Django's ValueError: Negative indexing is not supported — the feature's advertised purpose is unreachable**\n\nThe branch slices queryset[start_offset:stop] with a negative start_offset when enable_advanced_features and cursor.offset < 0. Django's ORM rejects negative slicing of a queryset with ValueError.\n\nThe comment claims Django handles it, but it does not. All sibling paginators in this file explicitly reject negative offsets.\n\nThis makes the feature's core purpose — traversing beyond normal bounds via negative offsets — crash instead of work. It might be intentional if the feature were never meant to be used, but the gate enables it for superusers and has_global_access members.\n\n
\n", + "created_at": "2026-08-21T21:34:04Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Unconditional organization_context.member.has_global_access dereference on every request, including the default path — AttributeError risk | `src/sentry/api/endpoints/organization_auditlogs.py:71` |\n| 🟠 High | OptimizedCursorPaginator.get_item_key raises TypeError on datetime keys | `src/sentry/api/paginator.py:840` |\n| 🟠 High | Negative-offset slice crashes the Django QuerySet | `src/sentry/api/paginator.py:877` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 66 (C+) | -34.2 |\n| Duplication | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **76 (B)** | **71 (B-)** | **-5.8** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:04Z" + } + ] } ] }, @@ -19332,6 +19800,42 @@ "created_at": "2026-06-29T00:07:24Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/97529", + "review_comments": [ + { + "path": "pkg/storage/unified/search/bleve.go", + "line": 137, + "body": "### 🔵 Low · BuildIndex concurrency unsynchronized: narrowed lock allows concurrent same-key disk builds corrupting the index, and TotalDocs() races with concurrent cache writes\n\nThe removal of the whole-function b.cacheMu.Lock()/defer b.cacheMu.Unlock() from bleveBackend.BuildIndex is real and un-replaced: the lock now guards only the final map store (bleve.go:137-139). getOrCreateIndex (search.go:292-298) is an atomicity-free check-then-act, and its own TODO confirms the per-key lock was never added. Nothing else serializes per-key builds, so concurrent Search RPCs or a Search racing the watch-event goroutine's handleEvent can both reach bleve.New(dir, mapper) on the same on-disk directory when FileThreshold < 10, or run duplicate memory builds with last-writer-wins caching.\n\nThis may be intentional if the default FileThreshold makes the disk path unreachable in practice, but the mechanism is real.\n\n> **Fix** — Add a per-key lock (or use singleflight) around the check-then-act in getOrCreateIndex, or restore a broader lock in BuildIndex.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR97529__20260821/blob/26fed312840cd76b766cbd2158e17a7e6c0ec548/pkg/storage/unified/resource/search.go#L214-L218\n\n
\n", + "created_at": "2026-08-21T22:35:36Z" + }, + { + "path": "pkg/storage/unified/resource/server.go", + "line": 258, + "body": "### 🔵 Low · Constructor executes full blocking startup and aborts construction on any init failure\n\nThe constructor now runs the full startup sequence (search index build + watcher/poller goroutine spawn) and returns an error on any init failure, making construction fatal-once with no retry. Previously init was lazy per-RPC and retryable. This is the PR's stated intent ('Init at startup'), so severity is low, but the mechanism is confirmed: blocking I/O, hard-fail construction, and implicit background goroutine spawn.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). server.go:258-262 — NewResourceServer calls s.Init(ctx) and returns nil, err on failure; no fallback, no retry (server discarded). server.go:293-318 — Init = lifecycle + search index build + initWatcher, inside sync.Once (sticky initErr). server.go:752-771 — initWatcher → backend.WatchWriteEvents + goroutine. backend.go:558-568 — SQL WatchWriteEvents runs a DB query (can fail) then spawns go b.poller(...); poller (570-615) polls DB every 100ms until b.done. search.go:181-213 — GetResourceStats and index build can fail (abort init); a second WatchWriteEvents stream is spawned at 203. broadcaster.go:177,276 — 2 goroutines per broadcaster. sql/service.go:113-116 — error aborts storage-server start. legacy_storage.go:28, register.go:110/105/105 — error aborts dashboard API-group registration; three constructions each spawn watchers with no dedup.\n_Impact: Startup crash when any init step fails (DB unavailability at startup), and persistent redundant 100ms DB-polling goroutines per construction (×2 on search path, ×3 dashboard registrations) consuming resources for the server's lifetime even when Watch is never served._\n_Queries: read_file(pkg/storage/unified/resource/server.go) · read_file(pkg/storage/unified/resource/server.go) · read_file(pkg/storage/unified/resource/search.go, offset=172, limit=60) · read_file(pkg/storage/unified/sql/backend.go, offset=520) · read_file(pkg/storage/unified/sql/backend.go, offset=570) · read_file(pkg/storage/unified/resource/broadcaster.go) · ast_call_sites(NewResourceServer) · grep(pattern=\"NewResourceServer\", path=\"pkg\") · read_file(pkg/storage/unified/sql/service.go, offset=80, limit=80) · read_file(pkg/registry/apis/dashboard/legacy_storage.go) · read_file(pkg/registry/apis/dashboard/v0alpha1/register.go, offset=95, limit=30) · grep(pattern=\"\\.NewStore\\(\", path=\"pkg\") · read_file(pkg/registry/apis/dashboard/legacy/storage.go, offset=200, limit=60) · read_file(pkg/storage/unified/apistore/restoptions.go) · read_file(pkg/storage/unified/sql/server.go)_\n\n> **Fix** — Consider an explicit build/Init/Start lifecycle to separate construction from blocking startup, and allow retry or graceful degradation on transient failures.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Eager Init in NewResourceServer makes init failures startup-fatal for all callers**\n\nNewResourceServer now calls s.Init(ctx) in the constructor, so any init failure (lifecycle, index build, watcher/WatchWriteEvents) becomes a fatal construction error instead of a lazy per-request error. This affects all callers including sql/service.go:113 (aborts storage service start) and legacy_storage.go:28 (aborts dashboard API-group registration).\n\nThe mechanism is structural and certain, but concrete harm depends on unreadable backends (legacy DashboardAccess and startup DB availability), so confidence is ~45%.\n\n**2. Watcher started eagerly at construction for every consumer, multiplying streams with no dedup**\n\nInit → initWatcher() calls backend.WatchWriteEvents, which for the SQL backend spawns a persistent 100ms poller goroutine per call. Previously servers that never served an RPC never started a watcher; now every construction spawns the broadcaster plus backend event stream, even for servers never serving a Watch RPC. Repeated constructions (e.g., dashboards registered through three UpdateAPIGroupInfo variants) independently start watchers with no dedup.\n\nGoroutine/stream leak and early DB polling are certain, but real leak impact and legacy backend behavior are unverified, so confidence is ~40%.\n\n
\n", + "created_at": "2026-08-21T22:35:36Z" + }, + { + "path": "pkg/storage/unified/search/bleve.go", + "line": 88, + "body": "### 🔵 Low · BuildIndex span context dropped, trace hierarchy misrepresented\n\nThe derived context carrying the BuildIndex span is discarded because the code uses `_, span :=` instead of `ctx, span :=`. This causes the span to be a leaf in the trace, while its actual descendants (ListIterator and sql query spans) attach to the parent span as siblings, misrepresenting the trace hierarchy.\n\nThe PR's goal of fixing traces is only partially achieved for the index-build path. This is not a regression — the line is unchanged — but the fix is incomplete (3 of 4 sites in the chain converted).\n\nImpact is trace fidelity only; no functional or data consequence. It may be intentional to leave this pre-existing line untouched, but the inconsistency with the converted sibling sites is real.\n\n> **Fix** — Change `_, span :=` to `ctx, span :=` at bleve.go:88 and propagate the derived context into the builder closure so descendant spans nest correctly under BuildIndex.\n", + "created_at": "2026-08-21T22:35:36Z" + }, + { + "path": "pkg/storage/unified/resource/search.go", + "line": 216, + "body": "### 🔵 Low · New TotalDocs() call races with concurrent cache writes in BuildIndex\n\nThe new s.search.TotalDocs() call at search.go:216 iterates the index cache with no lock while the concurrently started event goroutine (search.go:207-213) can trigger a cache write via BuildIndex — a Go map read/write race that is a fatal runtime crash. The lockless iteration pattern is pre-existing, and the practical overlap window is small (cache writes require a full build), but the new call site is an added concurrent access on a changed line.\n\nThis may be intentional if the overlap window is considered negligible, but the mechanism is real.This comment also covers: TotalDocs() unsynchronized map iteration races the newly concurrent BuildIndex cache writes\n\n> **Fix** — Acquire the cache lock (or use an atomic counter) when calling TotalDocs() at the new call site, or move the log line to a point where no concurrent build can be in flight.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR97529__20260821/blob/26fed312840cd76b766cbd2158e17a7e6c0ec548/pkg/storage/unified/search/bleve.go#L144-L148\n\n
\n", + "created_at": "2026-08-21T22:35:36Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | BuildIndex concurrency unsynchronized: narrowed lock allows concurrent same-key disk builds corrupting the index, and TotalDocs() races with concurrent cache writes | `pkg/storage/unified/search/bleve.go:137` |\n| 🔵 Low | Constructor executes full blocking startup and aborts construction on any init failure | `pkg/storage/unified/resource/server.go:258` |\n| 🔵 Low | BuildIndex span context dropped, trace hierarchy misrepresented | `pkg/storage/unified/search/bleve.go:88` |\n| 🔵 Low | New TotalDocs() call races with concurrent cache writes in BuildIndex | `pkg/storage/unified/resource/search.go:216` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.4 |\n| **Overall** | **85 (A-)** | **85 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:36Z" + } + ] } ] }, @@ -20621,6 +21125,30 @@ "created_at": "2026-06-28T23:46:01Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/80168", + "review_comments": [ + { + "path": "src/sentry/incidents/grouptype.py", + "line": 11, + "body": "### 🔵 Low · Stale TODO comment above StatefulDetectorHandler\n\nThe TODO comment claims the class will become a stateful detector when that abstraction is built, but the class directly below it is already a StatefulDetectorHandler, making the comment false and misleading. This is a cosmetic issue that does not affect runtime behavior but should be cleaned up to avoid confusing future maintainers.\n\nIt may be intentional as a historical note, but given the abstraction now exists, it is stale.\n\n> **Fix** — Remove or rewrite the TODO comment to reflect that the stateful detector abstraction has been implemented.\n", + "created_at": "2026-08-21T21:34:42Z" + }, + { + "path": "src/sentry/workflow_engine/processors/detector.py", + "line": 230, + "body": "### 🔵 Low · evaluate docstring still promises a list\n\nThe docstring of the changed evaluate still reads \"Evaluates a given data packet and returns a list of DetectorEvaluationResult\", while the signature this PR changed returns dict[DetectorGroupKey, DetectorEvaluationResult], and the body builds results = {}. Since the abstract DetectorHandler.evaluate and this docstring are the contract subclass implementers of the new hook read, the stale \"list\" wording actively misleads. Cosmetic, but a confirmed factual inconsistency introduced by this change.\n\nIt might be intentional to keep the docstring brief, but it is factually wrong.\n\n> **Fix** — Update the docstring to say it returns a dict keyed by DetectorGroupKey.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Stale docstring on evaluate: says returns list, actually returns dict**\n\nThe docstring still claims the method returns a list of DetectorEvaluationResult, but the code was changed to return a dict keyed by DetectorGroupKey. This is a genuine documentation inconsistency, though cosmetic and possibly intentional to keep the doc brief.\n\n
\n", + "created_at": "2026-08-21T21:34:42Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Stale TODO comment above StatefulDetectorHandler | `src/sentry/incidents/grouptype.py:11` |\n| 🔵 Low | evaluate docstring still promises a list | `src/sentry/workflow_engine/processors/detector.py:230` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 95 (A) | -5.1 |\n| Code Smell | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 94 (A) | 94 (A) | -0.0 |\n| **Overall** | **78 (B)** | **77 (B)** | **-0.9** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:42Z" + } + ] } ] }, @@ -21792,6 +22320,30 @@ "created_at": "2026-06-28T23:21:58Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/80528", + "review_comments": [ + { + "path": "src/sentry/monitors/logic/incident_occurrence.py", + "line": 160, + "body": "### 🔵 Low · Dead schedule_type transformation in get_monitor_environment_context\n\nThe config copy mutated at lines 160-162 (replacing schedule_type with the display string) is discarded; the returned payload keeps the original monitor_environment.monitor.config with the raw integer schedule_type. Either line 168 must return the mutated config, or lines 160-162 are dead code — the transformation has no effect on the payload either way.\n\nThis is confirmed dead code with no behavioral impact.This comment also covers: get_monitor_environment_context discards transformed config copy\n\n> **Fix** — Return the mutated config at line 168, or remove lines 160-162.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR80528__20260821/blob/dcdcadb771128e79259cc9eff9c70c38fc597976/src/sentry/monitors/logic/incident_occurrence.py#L166-L170\n\n
\n", + "created_at": "2026-08-21T21:34:35Z" + }, + { + "path": "src/sentry/monitors/logic/incidents.py", + "line": 93, + "body": "### 🔵 Low · Redundant MonitorCheckIn re-fetch in single-checkin branches\n\nIn the threshold == 1 branch and the ERROR-status branch, previous_checkins is built directly from the in-memory failed_checkin object, which already holds every attribute create_incident_occurrence reads. Line 93 then re-fetches that same row by ID from the DB, causing an unnecessary query on every failed check-in while the environment is in steady-state ERROR. In the threshold > 1 branch the fetch is partially necessary for older checkins' trace_id/monitor_environment, but still redundantly re-fetches failed_checkin itself.\n\nThis is a performance nit (one extra DB round-trip per failed check-in on the hot path), not a correctness bug. It might be intentional for code uniformity, but the redundant re-fetch of an already-in-memory object is confirmed.\n\n> **Fix** — Pass failed_checkin directly, or skip the query when len(previous_checkins) == 1.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Redundant MonitorCheckIn re-fetch in try_incident_threshold**\n\ntry_incident_threshold receives failed_checkin fully loaded. In the failure_issue_threshold == 1 path and the MonitorStatus.ERROR path, previous_checkins is a single-element dict built from failed_checkin itself, so the MonitorCheckIn.objects.filter(id__in=...) at line 93 re-fetches from the DB the object already in memory — an unnecessary query per occurrence creation.\n\nIn the threshold > 1 path the fetch is partially necessary (older checkins' trace_id/monitor_environment are absent from SimpleCheckIn) but still redundantly re-fetches failed_checkin itself. This is a performance-only issue with no correctness impact.\n\nIt might be intentional for code uniformity, but the redundant re-fetch of an already-in-memory object is confirmed.\n\n
\n", + "created_at": "2026-08-21T21:34:35Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Dead schedule_type transformation in get_monitor_environment_context | `src/sentry/monitors/logic/incident_occurrence.py:160` |\n| 🔵 Low | Redundant MonitorCheckIn re-fetch in single-checkin branches | `src/sentry/monitors/logic/incidents.py:93` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.2 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 94 (A) | 94 (A) | -0.0 |\n| **Overall** | **78 (B)** | **78 (B)** | **-0.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:34Z" + } + ] } ] }, @@ -23038,6 +23590,48 @@ "created_at": "2026-06-28T23:18:05Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/77754", + "review_comments": [ + { + "path": "tests/sentry/integrations/services/test_assignment_source.py", + "line": 13, + "body": "### 🔵 Low · Identifier \"test_from_dict_inalid_data\" misspells \"invalid\"\n\n\"test_from_dict_inalid_data\" contains \"inalid\", a misspelling of \"invalid\" — The word 'inalid' is a clear misspelling of 'invalid' (missing the letter 'v'). The identifier 'test_from_dict_inalid_data' clearly intends to test invalid data..\n\n", + "created_at": "2026-08-21T21:34:30Z" + }, + { + "path": "src/sentry/integrations/services/assignment_source.py", + "line": 18, + "body": "### 🟡 Medium · AssignmentSource.queued default datetime evaluated once at class definition time\n\nThe default value for the `queued` field is evaluated once at class definition time, so every instance that omits `queued` shares the same stale timestamp. This makes the timestamp incorrect per instance, which could affect any logic relying on when the assignment source was created.\n\nThe class is frozen, so no mutation occurs, but the value is still wrong. This may be intentional if the exact timestamp is not critical, but it is a real inconsistency.\n\n> **Fix** — Use a `default_factory` (e.g., `field(default_factory=timezone.now)`) so the timestamp is evaluated per instance.\n", + "created_at": "2026-08-21T21:34:30Z" + }, + { + "path": "src/sentry/integrations/utils/sync.py", + "line": 141, + "body": "### 🔵 Low · AssignmentSource.to_dict datetime may break broker serialization in sync_assignee_outbound\n\nThe `assignment_source.to_dict()` result, which contains a live `datetime` for `queued`, is passed into `apply_async(kwargs=...)`. The consumer rebuilds via `AssignmentSource.from_dict`, which only catches `(ValueError, TypeError)`. If the broker uses JSON serialization, the datetime may be stringified, causing a `TypeError` in `from_dict`, which then returns `None`, silently disabling the cycle-prevention logic in `should_sync`. This is unverified because the serializer configuration is outside the read scope, but the mechanism is confirmed. It may be intentional if the broker is always pickle-based, but the risk is real.\n\n**Advisory** — the proof pass could not settle triggerability and harm either way, and the finding arrived below the confirmation band. Nothing here was disproven; it is reported on its investigation evidence alone and will not block a merge. Broker serializer is pickle, not JSON: src/sentry/conf/server.py:739-741 (CELERY_TASK_SERIALIZER = \"pickle\", CELERY_RESULT_SERIALIZER = \"pickle\", CELERY_ACCEPT_CONTENT = {\"pickle\"}), loaded unmodified by the celery app (src/sentry/celery.py:127-128), with zero task-level serializer overrides in src/. Pickle round-trips datetime exactly — the queued value is never stringified. Mechanism false even under JSON: dataclass __init__ (AssignmentSource(**input_dict), assignment_source.py:31-35) performs no type checking — a string queued constructs successfully; from_dict cannot raise TypeError/return None from this input. Harm false: should_sync cycle prevention (issues.py:390) consumes only sync_source.integration_id; queued is not part of that decision, so no mangled value can disable it.\n_Queries: read_file(src/sentry/integrations/utils/sync.py) · read_file(src/sentry/integrations/services/assignment_source.py) · read_file(src/sentry/integrations/tasks/sync_assignee_outbound.py) · grep(pattern=\"task_serializer|accept_content|CELERY_TASK_SERIALIZER\", path=src/sentry) · read_file(src/sentry/conf/server.py, offset=720, limit=40) · read_file(src/sentry/celery.py, offset=85, limit=45) · grep(pattern=\"serializer=\", path=src) · read_file(src/sentry/integrations/mixins/issues.py, offset=382, limit=13) · grep(pattern=\"sync_group_assignee_outbound\", path=src)_\n\n> **Fix** — Ensure the broker serializer handles datetimes correctly, or explicitly serialize/deserialize the `queued` field in a way that survives the broker round-trip.\n", + "created_at": "2026-08-21T21:34:30Z" + }, + { + "path": "src/sentry/integrations/services/assignment_source.py", + "line": 34, + "body": "### 🔵 Low · AssignmentSource.from_dict silently fails open, disabling anti-sync-cycle guard\n\nfrom_dict catches every ValueError/TypeError and returns None; the task treats None as 'no source', and the anti-cycle guard at issues.py:390 only skips when sync_source is truthy. A malformed payload (missing source_name/integration_id or unknown keys) therefore silently bypasses the guard and re-enables the exact outbound sync loop this PR exists to break, with no log line.\n\nThis may be intentional as a fail-open design, but the lack of observability and the loop re-enablement make it a latent defect.\n\n> **Fix** — Log the parse failure and/or distinguish 'no source' from 'parse failed' so the guard cannot be silently disabled.\n", + "created_at": "2026-08-21T21:34:31Z" + }, + { + "path": "tests/sentry/integrations/services/test_assignment_source.py", + "line": 9, + "body": "### 🔵 Low · Test name promises empty-array scenario but passes empty dict\n\nThe test name 'test_from_dict_empty_array' suggests it tests an empty array input, but the test body passes an empty dict ({}). This is a naming/body mismatch on newly added lines.\n\nIt may be intentional if the author meant 'empty input' generically, but the name is misleading and the empty-array case is never exercised.\n\n> **Fix** — Rename the test to reflect the actual input (e.g., 'test_from_dict_empty_dict') or change the input to an empty list if that is the intended scenario.\n", + "created_at": "2026-08-21T21:34:31Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Identifier \"test_from_dict_inalid_data\" misspells \"invalid\" | `tests/sentry/integrations/services/test_assignment_source.py:13` |\n| 🟡 Medium | AssignmentSource.queued default datetime evaluated once at class definition time | `src/sentry/integrations/services/assignment_source.py:18` |\n| 🔵 Low | AssignmentSource.to_dict datetime may break broker serialization in sync_assignee_outbound | `src/sentry/integrations/utils/sync.py:141` |\n| 🔵 Low | AssignmentSource.from_dict silently fails open, disabling anti-sync-cycle guard | `src/sentry/integrations/services/assignment_source.py:34` |\n| 🔵 Low | Test name promises empty-array scenario but passes empty dict | `tests/sentry/integrations/services/test_assignment_source.py:9` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -1.1 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **90 (A)** | **90 (A)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:30Z" + } + ] } ] }, @@ -24429,6 +25023,54 @@ "created_at": "2026-06-28T23:34:19Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/95633", + "review_comments": [ + { + "path": "tests/sentry/uptime/consumers/test_results_consumer.py", + "line": 1922, + "body": "### 🟡 Medium · Error-handling test asserts the opposite of the implementation and is phase-racy\n\nThe worker's finally block calls complete_offset even when result_processor raised, so the 1s commit loop commits the failed message — silently dropped, violating the class's own docstring guarantee. The new test asserts the opposite (test_partition not in committed_offsets) and passes only because the assertion beats the first 1s commit tick; deterministically the implementation produces committed_offsets[test_partition] = 102.\n\nThe test is timing-racy and the production/test contract is internally contradictory.This comment also covers: Worker finally block commits offsets for failed items, contradicting documented at-least-once guarantee\n\n> **Fix** — Fix the implementation to not commit errored offsets, or change the test to assert the actual commit-on-error behavior; also join the commit thread in factory.shutdown() to avoid the race.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/src/sentry/remote_subscriptions/consumers/queue_consumer.py#L146-L150\n\n
\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": "tests/sentry/uptime/consumers/test_results_consumer.py", + "line": 2102, + "body": "### 🔵 Low · Kafka integration test deadline hazard\n\nThe test budgets a hard 5 wall-clock seconds for fetching 5 messages from a real broker, decode/queue/worker processing, the strategy's first commit pass (which includes a 1-second wait), arroyo's ONCE_PER_SECOND commit policy flush, and the broker round-trip for verify_consumer.committed — with assertions running after processor._shutdown(). Two independent 1-second cadences plus broker latency inside a fixed 5s window on a shared CI Kafka is a real deadline flake.\n\nNot deterministic, but a genuine timing hazard. Confidence 35.\n\n> **Fix** — Replace the fixed 5-second loop with a poll for the committed offset with a timeout, so the test waits for the condition rather than racing a wall-clock deadline.\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 185, + "body": "### 🟠 High · Unbounded queue.Queue() causes OOM under slow consumer\n\nThe queue is unbounded and put() never blocks; nothing else throttles the pipeline, so a slow group grows memory without bound leading to OOM. The docstring claiming natural backpressure is false, though it might be intentional if the queue is expected to be small in practice.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). queue_consumer.py:185 — `work_queue: queue.Queue[WorkItem[T]] = queue.Queue()`: the only queue construction on the path, no maxsize → unbounded. queue_consumer.py:204-212 — `FixedQueuePool.submit` → `work_queue.put(work_item)`: never blocks, no size check, no throttle. queue_consumer.py:253 — docstring \"Natural backpressure when queues fill up\": false, the queue never signals full. grep over src/sentry/remote_subscriptions for `Queue(|maxsize|Semaphore|MessageRejected` → exactly one hit (the unbounded construction). grep over src/sentry/uptime/consumers/results_consumer.py for the same → zero hits. No `MessageRejected` (arroyo's documented backpressure signal that pauses the Kafka consumer) is ever raised, so nothing upstream throttles submits. Reachable: \"uptime-results\" consumer in KAFKA_CONSUMERS (consumers/__init__.py:270-274) → `UptimeResultsStrategyFactory` (uptime/consumers/results_consumer.py:600); click option `--mode` with choice `thread-queue-parallel` (consumers/__init__.py:119-124); that mode constructs `FixedQueuePool` (result_consumer.py:131-137) and returns `SimpleQueueProcessingStrategy` (result_consumer.py:244-259); `get_stream_processor` instantiates the factory from CLI args (consumers/__init__.py:457-516). Triggerable: one worker per queue (OrderedQueueWorker, queue_consumer.py:108-156); hash-based group pinning (queue_consumer.py:198-202) means one slow subscription stalls its sole worker; worker handler does Redis get/set, DB lookups, Snuba/EAP produce (results_consumer.py:484-548, 582-595), so a slow group is realistic; while it stalls, the loop keeps submitting and the queue grows without bound. Harmful: unbounded memory growth → OOM crash of the consumer; offset commits stall.\n_Impact: A slow group stalls its single worker while the Kafka loop keeps submitting; the unbounded queue grows without bound until the consumer process OOMs and crashes, stalling result processing and offset commits for the partition._\n_Queries: read_file(src/sentry/remote_subscriptions/consumers/queue_consumer.py) · read_file(src/sentry/remote_subscriptions/consumers/result_consumer.py) · grep(\"ResultsStrategyFactory\", src/sentry) · grep(\"thread-queue-parallel\", src/sentry) · read_file(src/sentry/consumers/__init__.py, offset=100, limit=200) · read_file(src/sentry/consumers/__init__.py, offset=440, limit=120) · read_file(src/sentry/uptime/consumers/results_consumer.py, offset=560, limit=80) · read_file(src/sentry/uptime/consumers/results_consumer.py, offset=440, limit=120) · grep(\"Queue\\(|maxsize|Semaphore|MessageRejected\", src/sentry/remote_subscriptions) · grep(\"MessageRejected|maxsize|Semaphore\", src/sentry/uptime/consumers/results_consumer.py)_\n\n> **Fix** — Use a bounded queue (e.g., queue.Queue(maxsize=N)) or implement explicit backpressure in the producer.\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 317, + "body": "### 🟠 High · Offset commit logic in queue_consumer: commits past pruned gaps and drops filtered messages without committing their offsets\n\nFor a filtered message (payload FilteredPayload, value a plain Value), the decoder raises AssertionError, caught by the blanket except Exception. Since message.value is not a BrokerValue, the except body does nothing — no add_offset, no complete_offset. The offsets are never committed, so stale/poison offsets are re-read on every restart, diverging from sibling strategies that route filtered messages into CommitOffsets. This may be intentional if the consumer never receives such messages, but the mechanism is confirmed on changed lines.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). SimpleQueueProcessingStrategy.submit (queue_consumer.py:293–325) decodes first, asserts isinstance(message.value, BrokerValue) at line 297, and the except Exception block at 317–325 only records/completes offsets if isinstance(message.value, BrokerValue). The decoder wired at result_consumer.py:256 is partial(self.decode_payload, self.topic_for_codec); decode_payload (result_consumer.py:187–198) starts with assert not isinstance(payload, FilteredPayload) at line 188, outside its own try/except. A FilteredPayload therefore raises AssertionError into submit's blanket except. DlqStaleMessages (dlq.py:88–152), when --stale-threshold-sec is set (get_stream_processor, consumers/__init__.py:586–589), emits exactly such a message: Message(Value(FILTERED_PAYLOAD, self.offsets_to_forward)) at dlq.py:136 — a plain Value, not a BrokerValue — and forwards it via self.next_step.submit(filtered_message) at dlq.py:138. The chain reaches SimpleQueueProcessingStrategy.submit (only wrappers in between are MinPartitionMetricTagWrapper and optionally ValidateSchema). The uptime-results consumer is registered in KAFKA_CONSUMERS (consumers/__init__.py:270–274) and thread-queue-parallel is a documented CLI mode (uptime_options, consumers/__init__.py:116–147, route at result_consumer.py:209–259). --stale-threshold-sec is a CLI flag with IntRange(min=120) (run.py:571–575). The sentry_metrics indexer (multiprocess.py:81–88) documents the same FilteredPayload-not-committed consequence as a known/accepted limitation. arroyo is pinned to sentry-arroyo==2.28.0 (requirements-frozen.txt:124), so CommitOffsets/InvalidMessage semantics come from vendor code not inspected here; the repo-side mechanism is fully confirmed.\n_Impact: Under a supported queue-parallel + stale-threshold configuration, stale offsets are never committed via the FilteredPayload path, so every consumer restart re-reads and re-rejects the stale tail, duplicating stale-topic messages and wasting processing._\n_Queries: read_file(src/sentry/remote_subscriptions/consumers/queue_consumer.py) · glob(**/queue_consumer.py) · grep(SimpleQueueProcessingStrategy) · read_file(src/sentry/remote_subscriptions/consumers/result_consumer.py) · grep(FilteredPayload in src/sentry) · grep(arroyo in requirements) · read_file(src/sentry/sentry_metrics/consumers/indexer/multiprocess.py, offset 60) · read_file(src/sentry/consumers/dlq.py) · grep(DlqStaleMessages) · read_file(src/sentry/consumers/__init__.py, offset 540) · grep(def uptime_options) · read_file(src/sentry/uptime/consumers/results_consumer.py, offset 590) · grep(stale_threshold_sec|stale-threshold) · read_file(src/sentry/runner/commands/run.py, offset 555) · glob(**/arroyo*/**) · grep(arroyo==|arroyo>=) · grep(dlq_topic|stale_topic in src/sentry) · grep(InvalidMessage in **/*.py)_\n\n> **Fix** — In the except block, handle the case where message.value is a Value (not BrokerValue) by adding the message's committable offsets to the tracker, or explicitly route filtered messages to CommitOffsets as siblings do.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/src/sentry/remote_subscriptions/consumers/queue_consumer.py#L84-L88\n\n
\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 344, + "body": "### 🔴 Critical · Broken shutdown in queue_consumer: join() destroys pool via close(), close() kills commit thread before draining, queued items dropped, and tests leak the _commit_loop daemon thread\n\njoin is arroyo's routine per-message flush hook, but close() is terminal — it kills the commit thread, flags all workers down, closes all queues, and joins workers. The first join() permanently destroys the pool, causing subsequent messages to be dropped and offsets never committed. The unused wait_until_empty shows the intended join was drain-and-wait. This is a confirmed bug.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). queue_consumer.py:344-345 — `join(self, timeout)` unconditionally calls `self.close()`. queue_consumer.py:335-338 — `close()` sets `self.shutdown_event`, joins `self.commit_thread`, and calls `self.queue_pool.shutdown()`. queue_consumer.py:231-243 — `shutdown()` sets `worker.shutdown = True` for every worker thread, calls `q.shutdown(immediate=False)` on every queue, then joins every worker. queue_consumer.py:222-229 — `wait_until_empty(timeout)` implements the drain-and-wait semantics that `join` should have used; it is never called anywhere (searched the full file; only definition exists). All sibling ProcessingStrategy implementations in the repository (dlq.py:144-145, flusher.py:408-414, sentry_metrics/indexer/common.py:145-155, sentry_metrics/indexer/parallel.py:75-77, sentry_metrics/indexer/multiprocess.py:118-129, billing_metrics_consumer.py:134-135, validate_schema.py:66-67) implement `join(timeout)` as a drain/handoff to `next_step.join(timeout)` — never as teardown. arroyo is pinned in the repo at `sentry-arroyo==2.28.0` (requirements-frozen.txt:124, requirements-base.txt:66); the `ProcessingStrategy.join` contract in that framework is the bounded drain-and-wait hook, called on rebalance/shutdown before `close()`. The repo's own `SetJoinTimeout` (utils/arroyo.py:246-247) forwards `join(self.timeout)` to `next_step.join`, confirming this is the framework pattern expected. The strategy is created in production via `UptimeResultsStrategyFactory` (uptime/consumers/results_consumer.py:600) with `--mode thread-queue-parallel` a supported CLI mode (consumers/__init__.py:121, uptime options at 116-147), producing `SimpleQueueProcessingStrategy` at result_consumer.py:254. After `join()`/`close()` runs: the commit thread loop exits (`shutdown_event.is_set()`), all worker threads see `self.shutdown == True` and exit their loop, and all queue objects are shut down — `submit()` on a shut-down queue raises `queue.ShutDown`, and the `submit()` at queue_consumer.py:293-325 only logs the exception without committing the offset. `_commit_loop` can no longer commit anything. The factory's own `shutdown()` (result_consumer.py:180-185) calls `queue_pool.shutdown()` and nulls `self.queue_pool` — but it does *not* call the strategy's `join()`, so `join()` on the strategy is the only path that performs teardown from the strategy side, and it does so instead of draining.\n_Impact: In `--mode thread-queue-parallel`, the first framework-invoked `join()` permanently destroys the shared pool (kills the commit thread, flags all workers down, shuts down all queues, joins workers). Any subsequent `submit()` call fails (queue.ShutDown) or is silently dropped: the queue is gone, workers are dead, offsets are never committed after rebalancing/shutdown. This causes duplicate reprocessing and permanent message loss (or at minimum replay storms) in the uptime-results consumer._\n_Queries: read_file(src/sentry/remote_subscriptions/consumers/queue_consumer.py) · read_file(src/sentry/remote_subscriptions/consumers/result_consumer.py) · read_file(tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py) · grep(\"\\.join\\(\", path=\"src/sentry\") · read_file(src/sentry/consumers/dlq.py, offset=88) · read_file(src/sentry/utils/arroyo.py, offset=180) · grep(\"ProcessingStrategy|thread-queue-parallel|ResultsStrategyFactory\", path=\"src/sentry\") · read_file(src/sentry/uptime/consumers/results_consumer.py, offset=590) · read_file(src/sentry/consumers/__init__.py, offset=240) · read_file(src/sentry/utils/kafka.py) · read_file(src/sentry/consumers/__init__.py, offset=455)_\n\n> **Fix** — Replace close() with a drain-and-wait implementation (e.g., using wait_until_empty) in join().\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Test leaks the strategy's `_commit_loop` daemon thread**\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py#L413-L417\n\n**2. Shutdown order defeats the graceful drain; queued items dropped at shutdown**\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/src/sentry/remote_subscriptions/consumers/queue_consumer.py#L232-L236\n\n**3. join(timeout) ignores timeout and performs full teardown instead of drain-wait**\n\njoin(self, timeout=None) calls self.close(), which performs full teardown (queue_pool.shutdown()) instead of waiting for in-flight work to drain with the given timeout. Siblings pass the timeout through to drain.\n\nThis can permanently kill the shared pool mid-operation and block up to the worker-join budget, diverging from the expected bounded drain-wait semantics. May be intentional if join is never called with a timeout, but the contract is broken.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/src/sentry/remote_subscriptions/consumers/queue_consumer.py#L333-L337\n\n
\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": "tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py", + "line": 312, + "body": "### 🟡 Medium · test_preserves_order_within_group never asserts order — queue_consumer tests assert nothing: order test never checks order, concurrency test never checks concurrency, distribution test uses probabilistic == 3\n\nThe test docstring promises that messages for the same subscription are processed in order, but the only assertion checks the count of processed results, not their order. The test would pass even if the messages were processed in any permutation, so it cannot detect a regression in the ordering guarantee. This may be intentional if the test is meant only as a smoke test, but the name and docstring imply a stronger guarantee.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). test_queue_consumer.py:302 — `def test_preserves_order_within_group(self)` with docstring at 303: \"Test that messages for the same subscription are processed in order.\" test_queue_consumer.py:304–309 — body sets `expected_items = 5`, clears the completion event, and submits 5 messages, each created by `create_message(\"sub1\", 0, 100 + i)` (255–269) whose payload decodes (decoder at 237–240) to the identical dict `{\"subscription_id\": \"sub1\", \"data\": \"test\"}`. test_queue_consumer.py:311 — `assert self.process_complete_event.wait(timeout=5.0)` (completion wait, fires on item count). test_queue_consumer.py:312 — `assert len(self.processed_results) == 5` (count only). No assertion in the method compares processed results to an expected order; the results are 5 indistinguishable dicts, so an order violation is undetectable by construction of the test data. Contrast sibling test: test_queue_consumer.py:108–135 `test_ordered_processing_within_group` submits distinguishable items (`item_0`…`item_4`) and asserts exact order at line 135: `assert group_items == [\"item_0\", \"item_1\", \"item_2\", \"item_3\", \"item_4\"]` — the ordering assertion the missing-assertion test could have made but does not. The ordering guarantee is real production behavior, documented in queue_consumer.py:160–169 (FixedQueuePool: \"Each group is consistently assigned to the same queue … Items within a queue are processed in FIFO order\") and 250–252 (SimpleQueueProcessingStrategy: \"Items for the same group are processed in order\"), implemented via consistent hashing (198–202) and single-worker FIFO queues (127–156).\n_Impact: An ordering regression in the remote-subscriptions queue consumer (queue assignment or worker FIFO processing) passes CI undetected; the suite's only strategy-level \"in order\" check cannot fail._\n_Queries: read_file(path=\"tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py\") · glob(pattern=\"**/remote_subscriptions/consumers/*.py\") · read_file(path=\"src/sentry/remote_subscriptions/consumers/queue_consumer.py\")_\n\n> **Fix** — Add an assertion that verifies the exact processing order of the messages for the same subscription, similar to the pool-level test_ordered_processing_within_group.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. test_concurrent_processing_different_groups never checks concurrency**\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py#L323-L327\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR95633__20260821/blob/9966ec5a13e331659c3ea00981f9b11b0faf821f/tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py#L104-L108\n\n
\n", + "created_at": "2026-08-21T21:34:38Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Error-handling test asserts the opposite of the implementation and is phase-racy | `tests/sentry/uptime/consumers/test_results_consumer.py:1922` |\n| 🔵 Low | Kafka integration test deadline hazard | `tests/sentry/uptime/consumers/test_results_consumer.py:2102` |\n| 🟠 High | Unbounded queue.Queue() causes OOM under slow consumer | `src/sentry/remote_subscriptions/consumers/queue_consumer.py:185` |\n| 🟠 High | Offset commit logic in queue_consumer: commits past pruned gaps and drops filtered messages without committing their offsets | `src/sentry/remote_subscriptions/consumers/queue_consumer.py:317` |\n| 🔴 Critical | Broken shutdown in queue_consumer: join() destroys pool via close(), close() kills commit thread before draining, queued items dropped, and tests leak the _commit_loop daemon thread | `src/sentry/remote_subscriptions/consumers/queue_consumer.py:344` |\n| 🟡 Medium | test_preserves_order_within_group never asserts order — queue_consumer tests assert nothing: order test never checks order, concurrency test never checks concurrency, distribution test uses probabilistic == 3 | `tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py:312` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 73 (B-) | -27.4 |\n| Test Coverage | 61 (C) | 99 (A+) | +38.2 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **79 (B)** | **80 (B+)** | **+1.8** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:38Z" + } + ] } ] }, @@ -26196,6 +26838,60 @@ "created_at": "2026-06-28T22:21:53Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/2", + "review_comments": [ + { + "path": "src/sentry/scripts/spans/add-buffer.lua", + "line": 47, + "body": "### 🔵 Low · ZUNIONSTORE SUM doubles scores of re-parented spans, corrupting eviction\n\nZUNIONSTORE defaults to AGGREGATE SUM on scores. When a member exists in both the destination and source keys, its score becomes the sum of both copies instead of the single timestamp.\n\nThis corrupts the score of every span whose parent arrived in a later batch, causing the eviction to discard the newest real spans and retain duplicated old ones. This may be intentional if the design accepts score corruption on re-parenting, but the code comment documents the reachability of this path.\n\n> **Fix** — Add AGGREGATE MAX or use ZADD with the existing score preserved, or ensure members are never present in both source and destination before the union.\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/api/paginator.py", + "line": 838, + "body": "### 🔴 Critical · OptimizedCursorPaginator.get_item_key crashes on the datetime key it is wired to (TypeError → 500)\n\nThe new OptimizedCursorPaginator.get_item_key copies plain Paginator.get_item_key but omits the datetime→float normalization that DateTimePaginator performs. The endpoint passes order_by=\"-datetime\" over AuditLogEntry, so self.key=\"datetime\" and getattr(item, \"datetime\") is a datetime object. math.floor(datetime) raises TypeError, causing a 500 on any non-empty page.\n\nThis may be intentional if the paginator were only used with numeric keys, but the wiring to audit logs makes it a real crash.This comment also covers: Crash: audit-log endpoint with optimized_pagination=true 500s on any non-empty pageThis comment also covers: OptimizedCursorPaginator.get_item_key crashes with TypeError on datetime keyThis comment also covers: value_from_cursor injects raw number into datetime WHERE clause\n\n> **Fix** — Add datetime-to-float conversion in get_item_key, e.g., value = float(value.strftime(\"%s.%f\")) * self.multiplier when the key is a datetime, or reuse DateTimePaginator's logic.\n\n---\n\n
This same fix applies at 3 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR2__20260821/blob/8ab88145113dd23a930e23b9cbbcf8b30e4c0b17/src/sentry/api/endpoints/organization_auditlogs.py#L77-L81\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR2__20260821/blob/8ab88145113dd23a930e23b9cbbcf8b30e4c0b17/src/sentry/api/paginator.py#L838-L842\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR2__20260821/blob/8ab88145113dd23a930e23b9cbbcf8b30e4c0b17/src/sentry/api/paginator.py#L841-L845\n\n
\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/api/paginator.py", + "line": 877, + "body": "### 🟠 High · negative-offset \"advanced pagination\" branch feeds Django a negative slice start (ValueError → 500)\n\nThe new branch allows negative cursor offsets, producing queryset[-5:96] which Django's QuerySet.__getitem__ rejects with ValueError(\"Negative indexing is not supported.\"). The in-code claim that Django handles negative slicing automatically is false.\n\nThis is client-reachable via ?cursor=0:-5:0, though masked by Finding 1 on the same endpoint. It may be intentional if negative offsets were meant to be clamped, but the code explicitly passes them through.\n\n> **Fix** — Clamp negative offsets to 0 before slicing, or reject them with a 400 error.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Negative-offset slice crashes with Django AssertionError**\n\nThe negative-offset branch slices queryset with a negative start, which Django ORM does not support, raising AssertionError. The comment claiming the ORM handles it is false.\n\nReachable via crafted cursor when flag and role gate pass. May be intentional for advanced features, but the mechanism is broken.This comment also covers: Negative-offset cursor drives queryset[negative:] → Django AssertionError → 500\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR2__20260821/blob/8ab88145113dd23a930e23b9cbbcf8b30e4c0b17/src/sentry/api/paginator.py#L880-L884\n\n
\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/api/paginator.py", + "line": 840, + "body": "### 🔵 Low · OptimizedCursorPaginator.get_item_key crashes with TypeError on datetime key\n\nWith order_by=\"-datetime\", get_item_key calls math.floor/math.ceil on a datetime.datetime object, which raises TypeError because datetime has no __floor__/__ceil__/__index__. This fires on every non-empty page via build_cursor → _build_next_values (cursors.py:122,137), causing an HTTP 500. The sibling DateTimePaginator (paginator.py:233-236) converts via value.strftime(\"%s.%f\") before floor/ceil, proving the required conversion was dropped.\n\nThis may be intentional as an experimental opt-in feature, but the crash is real.\n\n> **Fix** — Convert the datetime to a numeric value (e.g., value.strftime(\"%s.%f\")) before applying floor/ceil, mirroring DateTimePaginator.get_item_key.\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/spans/buffer.py", + "line": 197, + "body": "### 🟠 High · Null or non-numeric end_timestamp_precise breaks Redis zadd score\n\nIf the payload carries end_timestamp_precise: null, the Span field becomes None and the score passed to zadd is None; redis-py rejects a None score when encoding the command, and the server rejects non-numeric scores at execute. The failure lands inside process_spans and crashes the pipeline.\n\nThis may be intentional if the schema guarantees a numeric value, but that guarantee is not citable in this checkout.\n\n> **Fix** — Validate and coerce the score to a numeric type before passing to zadd, or reject null values earlier.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Client-controlled eviction score can evict legitimate spans or root span**\n\nThe score that decides which spans are evicted is taken unvalidated from the client-supplied end timestamp. A far-future value protects a span from eviction and instead evicts legitimate lower-scored spans; with device clock skew, a skewed child can out-rank and evict the segment's root span, so the flushed segment loses its is_segment anchor.\n\nThis is partly design-inherent, but the unvalidated input into the eviction score is a real mechanism on a changed line.\n\n**2. set→zset format change on live keys without migration/versioning → WRONGTYPE**\n\nThe data structure under the unchanged key namespace span-buf:s:* flipped from SET to ZSET on all three consumers with no key versioning or migration. Mixed-version deploys get sadd vs zadd/zscan on the same trace keys, and same-version deploys hit stale set keys up to 1h old (TTL 3600).\n\nThis causes WRONGTYPE errors that fail ingest batches and can crash the flusher process, leading to a restart loop and consumer downtime. This may be intentional if the deploy procedure flushes the span-buffer keyspace, but that is not verifiable from this checkout.\n\n
\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/spans/consumers/process/factory.py", + "line": 141, + "body": "### 🟡 Medium · KeyError risk when end_timestamp_precise is absent from the ingested event\n\nThe bare subscript val[\"end_timestamp_precise\"] raises KeyError if the key is missing from the event dict. Unlike sibling fields that use .get(), there is no fallback or guard. The cast at line 134 is type-only and provides no runtime validation. A KeyError propagates out of the batch loop (lines 129-144) with no try/except, aborting the entire batch. The field's requiredness is decided by an external schema not in this checkout, so the crash is reachable whenever producers lag behind the consumer's schema version. This may be intentional if the schema guarantees the key is always present, but that invariant is not established in this repo.\n\n**Advisory** — the proof pass could not settle triggerability either way, and the finding arrived below the confirmation band. Nothing here was disproven; it is reported on its investigation evidence alone and will not block a merge. Reachability: `process_batch` is the function bound into the ingest-spans consumer strategy at factory.py:80 (`run_task_with_multiprocessing`) and :90 (`RunTask`); the per-message loop factory.py:129–144 runs on every batch. `process_batch` has no other call sites — it is the production consumer hot path. Triggerability: the field's requiredness is decided by the external `sentry-kafka-schemas==1.3.6` `ingest_spans_v1` schema, which is not present in this checkout (both schema globs empty). In-repo convention treats it as required (bare subscripts for trace_id/span_id/project_id/end_timestamp_precise at factory.py:136–141; `.get()` only for parent_span_id/is_remote), and all test payloads include it (test_consumer.py:44; testutils/cases.py:1286/1357/3248/3481) — but that is convention, not a citable producer guarantee. Cannot establish that a message lacking the field can be produced, nor that it cannot. Harmfulness: factory.py:129–144 has no exception isolation; a KeyError aborts the whole batch, `buffer.process_spans` (factory.py:147) never runs, offsets are not committed, and a persistent bad message reproduces the crash on restart (poison-message restart loop). Secondary eviction claim: mechanism confirmed — raw `end_timestamp_precise` is the zset score (buffer.py:197–199) and `zpopmin` drops lowest scores when a segment exceeds 1000 members (add-buffer.lua:62–64); bounded by attacker co-residence in a victim's segment set and the >1000-span condition, and the newest-kept eviction is the PR's stated design.\n_Impact: If a producer sends a message lacking `end_timestamp_precise`, the KeyError aborts the whole batch, the consumer fails to commit offsets and crash-loops on the poison message, stalling ingest-spans processing and dropping the batch's spans. (Secondary: an attacker-set extreme `end_timestamp_precise` biases which spans survive the >1000-member eviction, silently dropping co-resident victim spans.)_\n_Queries: read_file(src/sentry/spans/consumers/process/factory.py) · read_file(src/sentry/spans/buffer.py) · grep(pattern=end_timestamp_precise, path=.) · glob(pattern=**/add-buffer.lua) · grep(pattern=sentry_kafka_schemas, glob=*.txt) · glob(pattern=**/sentry_kafka_schemas/**) · glob(pattern=**/schema_types/**) · grep(pattern=SpanEvent, path=.) · read_file(tests/sentry/spans/consumers/process/test_consumer.py) · grep(pattern=process_batch, path=.) · read_file(src/sentry/utils/arroyo.py)_\n\n> **Fix** — Replace val[\"end_timestamp_precise\"] with val.get(\"end_timestamp_precise\") plus a defined fallback (e.g., val.get(\"timestamp\"), or skip/drop the message), matching the defensive pattern of the other fields, or add a schema-validation step before subscripting.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Unguarded dict access to end_timestamp_precise can crash ingest pipeline**\n\nThe changed line performs a hard [] read on a payload parsed from an external Kafka topic with no .get() fallback, no schema validation, and no exception handling. A KeyError propagates through RunTask and fails the batch without committing offsets, causing a consumer restart loop.\n\nThis is a new hard requirement introduced by the diff; sibling fields use .get(). It may be intentional if the external schema guarantees the field, but that guarantee is not citable in this checkout.\n\n**2. Untrusted end_timestamp_precise controls eviction score, enabling attacker-influenced span loss**\n\nThe eviction score is taken directly from the untrusted ingest payload with no normalization or range clamp. A client can set an extreme end_timestamp_precise so its spans always carry the highest scores and are never evicted, while legitimate spans in the same segment set are preferentially dropped once the set exceeds 1000 members.\n\nThis introduces silent, attacker-influenceable span loss. Reachability is bounded (eviction only affects members of the same span-buf key, so an attacker must co-reside in a victim's trace), but the retention decision is driven entirely by a spoofable value with no clamping/validation.\n\nThis may be intentional as the PR's stated design is 'eviction during insert' keeping newest-by-end-timestamp spans, but the asymmetry on a retention path is a real concern.\n\n
\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": "src/sentry/scripts/spans/add-buffer.lua", + "line": 62, + "body": "### 🔵 Low · zpopmin eviction can drop the segment root span from large segments\n\nThe new zpopmin eviction unconditionally removes the span_count - 1000 members with the lowest scores (earliest end_timestamp_precise) and contains no protection for the root/segment span. flush_segments derives has_root_span / is_segment purely from the presence of the root payload, so if a segment exceeds 1000 spans and the root's end time is not among the highest 1000 (reachable with merged/cross-project traces, is_remote spans, or clock skew), the flushed segment contains no span flagged is_segment, and the downstream consumer receives a rootless segment. This is inherent to the intended eviction design but is an unguarded invariant, so it is graded honestly low.\n\n> **Fix** — Add a guard in the Lua eviction to always retain the root span (e.g., by score or by a root marker), or handle rootless segments gracefully in flush_segments.\n", + "created_at": "2026-08-21T21:34:07Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | ZUNIONSTORE SUM doubles scores of re-parented spans, corrupting eviction | `src/sentry/scripts/spans/add-buffer.lua:47` |\n| 🔴 Critical | OptimizedCursorPaginator.get_item_key crashes on the datetime key it is wired to (TypeError → 500) | `src/sentry/api/paginator.py:838` |\n| 🟠 High | negative-offset \"advanced pagination\" branch feeds Django a negative slice start (ValueError → 500) | `src/sentry/api/paginator.py:877` |\n| 🔵 Low | OptimizedCursorPaginator.get_item_key crashes with TypeError on datetime key | `src/sentry/api/paginator.py:840` |\n| 🟠 High | Null or non-numeric end_timestamp_precise breaks Redis zadd score | `src/sentry/spans/buffer.py:197` |\n| 🟡 Medium | KeyError risk when end_timestamp_precise is absent from the ingested event | `src/sentry/spans/consumers/process/factory.py:141` |\n| 🔵 Low | zpopmin eviction can drop the segment root span from large segments | `src/sentry/scripts/spans/add-buffer.lua:62` |\n| 🟠 High | Division by an unguarded count/size result | `src/sentry/spans/buffer.py:225` (not in the diff) |\n\n---\n\n### 🟠 High · Division by an unguarded count/size result\n\n`src/sentry/spans/buffer.py:225`\n\nThis modulo divides by `len(self.assigned_shards)`, a count/size/len result that can be zero (empty collection). No `> 0` / `!= 0` guard precedes it, so the modulo panics/traps at runtime.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 71 (B-) | -28.6 |\n| Duplication | 100 (A+) | 100 (A+) | -0.0 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **76 (B)** | **71 (B-)** | **-4.9** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:07Z" + } + ] } ] }, @@ -27930,6 +28626,54 @@ "created_at": "2026-06-28T22:35:45Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/3", + "review_comments": [ + { + "path": "src/sentry/api/endpoints/organization_events_stats.py", + "line": 241, + "body": "### 🔵 Low · Error upsampling skips the count/orderby expression: count() alias untransformed in helper, Top-N ranking orders by raw counts while displayed series are upsampled\n\nIn top-events queries with upsampling enabled, the top-N ranking uses the untransformed orderby (plain count()) while the displayed series are sum(sample_weight). For per-project heterogeneous sample rates the rank order can disagree with the displayed magnitudes.\n\nThis may be intentional if ranking by raw counts is desired, but the inconsistency between ordering and displayed values is a real defect.\n\n> **Fix** — Transform the orderby to use upsampled_count() when upsampling is enabled, or document that ranking intentionally uses raw counts.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry-greptile__corbulo__PR3__20260821/blob/6ad6fe3899f630ccf922ce7ac681147dc7987435/src/sentry/api/helpers/error_upsampling.py#L71-L75\n\n
\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": "src/sentry/api/helpers/error_upsampling.py", + "line": 137, + "body": "### 🟠 High · Naive substring misclassifies negated/mixed event.type queries as error-focused\n\nThe substring check `if \"event.type:error\" in query:` returns True for `!event.type:error` and `event.type:error OR event.type:transaction`, so non-error rows get `sum(sample_weight)` instead of `count()`, producing wrong counts. This contradicts the author's own docstring stating sample_weight doesn't exist for transactions and the PR's own test asserting transaction-matching queries must report count 1, not the upsampled 10. It may be intentional if the allowlist opt-in is never used, but the mechanism is certain and the wrongness on transaction rows is established by the PR's own test.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). error_upsampling.py:135 — query = request.GET.get(\"query\", \"\").lower() (raw, unparsed); :137 — if \"event.type:error\" in query:. Both \"!event.type:error\" and \"event.type:error OR event.type:transaction\" contain the substring → _is_error_focused_query returns True for both. Resolved call path: api/urls.py:1529 registers sentry-api-0-organization-events-stats → OrganizationEventsStatsEndpoint.get calls is_errors_query_for_error_upsampled_projects at organization_events_stats.py:220; on True, transform_query_columns_for_error_upsampling at :233/:277/:296 rewrites count() → upsampled_count() (error_upsampling.py:95), resolved to sum(Column(\"sample_weight\")) at discover.py:1046-1050. Gating: for dataset == discover, _should_apply_sample_weight_transform (error_upsampling.py:122-124) delegates to _is_error_focused_query; no other guard exists (grep for upsampl returns only this helper and the endpoint wiring). Precondition is a supported configuration: option issues.client_error_sampling.project_allowlist registered at defaults.py:3464 (default [], FLAG_AUTOMATOR_MODIFIABLE) and consumed at event_manager.py:776, 1512, 1880; the endpoint test drives this exact path with a non-empty allowlist (test_organization_events_stats.py:3607). Intent contract (author's own): docstring error_upsampling.py:106-107 (\"sample_weight doesn't exist for transactions\"); test test_organization_events_stats.py:3655-3697 asserts a transaction-matching query returns count 1 (line 3696) vs the upsampled 10 (line 3626).\n_Impact: Transaction rows counted as sum(sample_weight) → 0/misweighted counts returned by the public events-stats API._\n_Queries: read_file(path=src/sentry/api/helpers/error_upsampling.py) · glob(pattern=**/error_upsampling.py) · ast_call_sites(symbol=is_errors_query_for_error_upsampled_projects) · ast_call_sites(symbol=transform_query_columns_for_error_upsampling) · grep(pattern=error_upsampling, path=src/sentry) · grep(pattern=client_error_sampling|sample_weight|upsampled_count, path=src/sentry) · grep(pattern=upsampled|sample_weight|error_upsampling, path=tests) · grep(pattern=upsampl, path=src/sentry) · grep(pattern=organization-events-stats, path=src/sentry/api) · read_file(path=src/sentry/api/endpoints/organization_events_stats.py, offset=180, limit=140) · read_file(path=tests/snuba/api/endpoints/test_organization_events_stats.py, offset=3595, limit=130) · read_file(path=tests/sentry/api/helpers/test_error_upsampling.py) · read_file(path=src/sentry/search/events/datasets/discover.py, offset=1025, limit=40) · read_file(path=src/sentry/options/defaults.py, offset=3458, limit=15) · read_file(path=src/sentry/event_manager.py, offset=770, limit=40) · read_file(path=src/sentry/event_manager.py, offset=1500, limit=30)_\n\n> **Fix** — Parse the query properly (e.g., use the search parser) to detect negation and disjunction, or require an explicit positive `event.type:error` term without negation or mixed types.\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": "tests/snuba/api/endpoints/test_organization_events_stats.py", + "line": 3596, + "body": "### 🔵 Low · Transaction event stored without wait_for_event_count → racing assertion\n\nThe test stores a transaction event without awaiting ClickHouse settling, unlike the error events in setUp which use wait_for_event_count. The exact-count assertion at line 3696 depends solely on that unawaited row; if ingestion has not settled, the assertion races and the test flakes. This may be intentional if the test environment guarantees synchronous ingestion, but the author's own pattern elsewhere suggests settling is required.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). `tests/snuba/api/endpoints/test_organization_events_stats.py:3596-3597` — `setUp` explicitly calls `self.wait_for_event_count(self.project.id, 1)` and `self.wait_for_event_count(self.project2.id, 1)` after storing the error events. `tests/snuba/api/endpoints/test_organization_events_stats.py:3660-3676` — `test_error_upsampling_with_transaction_events` stores a transaction event via `self.store_event(...)` with no subsequent `wait_for_event_count`. `tests/snuba/api/endpoints/test_organization_events_stats.py:3678-3697` — the test immediately issues the stats API request and asserts `data[0][1][0][\"count\"] == 1` at line 3696, relying solely on that unawaited row. `src/sentry/testutils/cases.py:1082-1107` — the `wait_for_event_count` helper's own docstring states: \"While snuba is synchronous, clickhouse isn't entirely synchronous.\" This documents that the expected settling delay exists and is why the helper polls `eventstore.backend.get_events`. The grep of the target file found only the two `wait_for_event_count` calls (lines 3596-3597); no wait is applied for the transaction event. The sibling test at `tests/snuba/api/endpoints/test_organization_events_stats.py:699-729` also stores transaction events without waiting, but its existence does not remove the race identified by the helper's own documentation — it merely shows the pattern is repeated.\n_Impact: This exact-count assertion can spuriously fail with `count == 0` when ClickHouse has not yet settled, producing CI flakiness and, if the test is retried or disabled, masking genuine regressions in the error-upsampling/transaction query path._\n_Queries: read_file(\"tests/snuba/api/endpoints/test_organization_events_stats.py\", offset=3550, limit=180) · grep(\"wait_for_event_count\", path=\"tests/snuba/api/endpoints/test_organization_events_stats.py\") · grep(\"wait_for_event_count\", glob=\"test_organization_events_stats.py\") · grep(\"def wait_for_event_count\") · grep(\"class SnubaTestCase\") · read_file(\"src/sentry/testutils/cases.py\", offset=1029, limit=120) · grep(\"class SnubaEventStream\") · grep(\"def insert\", path=\"src/sentry/eventstream\") · read_file(\"src/sentry/eventstream/snuba.py\", offset=440, limit=60) · read_file(\"src/sentry/eventstream/base.py\", offset=100, limit=60) · grep(\"def store_event\", path=\"src/sentry/testutils\") · read_file(\"src/sentry/testutils/factories.py\", offset=1021, limit=80) · grep(\"insert_raw|eventstream\", path=\"src/sentry/event_manager.py\") · glob(\"**/event_manager.py\") · ast_document_symbols(\"src/sentry/event_manager.py\") · read_file(\"src/sentry/event_manager.py\", offset=1036, limit=60) · read_file(\"tests/snuba/api/endpoints/test_organization_events_stats.py\", offset=685, limit=45) · grep(\"wait_for_event_count\\\\\\\\(\", path=\"tests/snuba/api/endpoints/test_organization_events_stats.py\")_\n\n> **Fix** — Add a wait_for_event_count call after storing the transaction event, matching the pattern used for the error events in setUp.\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": "src/sentry/search/events/datasets/discover.py", + "line": 1041, + "body": "### 🔵 Low · upsampled_count registered as a public, user-callable function (missing private=True)\n\nThe new SnQLFunction is registered in function_converter without private=True, unlike its three sibling internal-only functions (examples, rounded_timestamp, column_hash) which all carry private=True. This exposes upsampled_count() to any user query, bypassing the allowlist gate that lives only in the endpoint transform path. On non-allowlisted projects, sample_weight may not exist, so the aggregate silently returns zeros/garbage instead of a real count. This may be intentional if the function is meant to be public, but the sibling convention and the correctness assumption in the comment suggest it should be private.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). src/sentry/search/events/datasets/discover.py:1041-1052: SnQLFunction(\"upsampled_count\", required_args=[], snql_aggregate=lambda args, alias: Function(\"toInt64\", [Function(\"sum\", [Column(\"sample_weight\")])], alias), default_result_type=\"number\") — no private=True. Its three siblings at lines 1014-1039 (examples, rounded_timestamp, column_hash) all carry private=True (lines 1019, 1027, 1039). src/sentry/search/events/fields.py:1515-1535: is_accessible returns True unconditionally when self.private is False (if not is_combinator_private and not self.private: return True); only private functions are checked against the ACL. src/sentry/search/events/builder/base.py:728-738: the resolve path snql_function = self.function_converter[name]; if not snql_function.is_accessible(self.builder_config.functions_acl, combinator): raise InvalidSearchQuery(f\"{snql_function.name}: no access to private function\") — the gate that would block this function, skipped because private defaults to False. src/sentry/search/events/builder/base.py:347-353: parse_config binds self.function_converter = self.config.function_converter; src/sentry/search/events/builder/discover.py:57-71: load_config() returns DiscoverDatasetConfig(self) for Dataset.Discover/Transactions/Events/IssuePlatform. src/sentry/snuba/discover.py:266-333: timeseries_query is the \"High-level API for doing arbitrary user timeseries queries against events\" — it builds TimeseriesQueryBuilder from user-supplied selected_columns, resolving them through the above function_converter path. src/sentry/api/endpoints/organization_events_stats.py:220-296: the allowlist call is_errors_query_for_error_upsampled_projects(...) gates only the automatic transform transform_query_columns_for_error_upsampling(query_columns) (line 95 in error_upsampling.py produces \"upsampled_count() as count\") — it never gates direct user invocation of upsampled_count() as a yAxis column. src/sentry/search/events/datasets/discover.py:1044-1045: the code's own comment says \"Optimized aggregation for error upsampling - assumes sample_weight exists for all events in allowlisted projects as per schema design\"; src/sentry/api/helpers/error_upsampling.py:85-87 likewise: \"We rely on the database schema to ensure sample_weight exists for all events in allowlisted projects.\" Repo-wide grep for sample_weight finds only these two source files and a test file — no schema definition in this repo, confirming the column's existence is preconditioned on the allowlist that only the endpoint transform enforces.\n_Impact: Users on non-allowlisted projects can invoke upsampled_count() and get an error or wrong (zero/garbage) event counts from sum(sample_weight) where the column does not exist — incorrect Discover statistics returned by a public endpoint, bypassing the internal-only designation its three sibling functions all carry._\n_Queries: read_file(src/sentry/search/events/datasets/discover.py, offset=1000, limit=80) · grep(\"upsampled_count\", path=src/sentry) · read_file(src/sentry/api/helpers/error_upsampling.py) · grep(\"is_accessible\", path=src/sentry) · read_file(src/sentry/search/events/fields.py, offset=1320, limit=100) · read_file(src/sentry/search/events/fields.py, offset=1490, limit=80) · read_file(src/sentry/search/events/builder/base.py, offset=710, limit=40) · read_file(src/sentry/search/events/datasets/discover.py, offset=150, limit=50) · grep(\"transform_query_columns_for_error_upsampling|is_errors_query_for_error_upsampled_projects\", path=src/sentry) · read_file(src/sentry/search/events/datasets/discover.py, offset=100, limit=120) · read_file(src/sentry/api/endpoints/organization_events_stats.py, offset=195, limit=120) · grep(\"DiscoverDatasetConfig\", path=src/sentry) · read_file(src/sentry/snuba/discover.py, offset=266, limit=90) · grep(\"sample_weight\", path=src/sentry) · read_file(src/sentry/search/events/builder/discover.py, offset=180, limit=40) · read_file(src/sentry/search/events/builder/discover.py, offset=45, limit=45) · read_file(src/sentry/search/events/builder/base.py, offset=340, limit=30)_\n\n> **Fix** — Add private=True to the upsampled_count SnQLFunction registration, and grant the ACL on the internal path instead.\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": "src/sentry/api/endpoints/organization_events_stats.py", + "line": 220, + "body": "### 🟡 Medium · Closure uses captured `dataset` where its refined parameter `scoped_dataset` is used elsewhere\n\n`_get_event_stats` takes `scoped_dataset` and uses it as the operand at 4 sites, but this call uses the captured enclosing-scope variable `dataset` that `scoped_dataset` refines. The parameter was introduced to replace `dataset` on the remapped path — the lone site still reading the stale captured `dataset` operates on the wrong value.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). dataset at line 221 is not a parameter or local of _get_event_stats (function span 209–324, parameter scoped_dataset only) — it binds to the enclosing get scope variable set at line 170. The divergence is real: the widget-split path (entered when metrics_enhanced and dashboard_widget_id, line 344; metrics_enhanced = dataset ∈ {metrics_performance, metrics_enhanced_performance}, line 191) calls _get_event_stats(discover, …) at lines 363, 370, 386-394, 427-435, 444-452, while the captured dataset is a metrics module. error_upsampling.py:103-127: _should_apply_sample_weight_transform(metrics_*) falls through to return False; _should_apply_sample_weight_transform(discover) returns True iff the request query contains event.type:error (lines 122-124, 130-140). Reachability path: urls.py:1527-1530 registers the GET route → get → get_event_stats_data → get_event_stats → fn → _get_event_stats; executes on every request to this endpoint.\n_Impact: The errors query results returned to the widget (lines 372-380, 467-480, 442-452) are un-upsampled — under-counted error totals for allowlisted projects — inconsistent with the same query served by the direct endpoint, which applies upsampled_count() (test asserts 10 vs 1 at test_organization_events_stats.py:3626-3627). Wrong data in operation; no attacker required._\n_Queries: read_file(src/sentry/api/endpoints/organization_events_stats.py) · discover(enclosing_function, {\"file\":\"src/sentry/api/endpoints/organization_events_stats.py\",\"line\":\"221\"}) · discover(guards_before, {\"file\":\"src/sentry/api/endpoints/organization_events_stats.py\",\"line\":\"221\"}) · read_file(src/sentry/api/helpers/error_upsampling.py) · grep(\"is_errors_query_for_error_upsampled_projects\", src/sentry) · grep(\"organization_events_stats\", src/sentry) · read_file(src/sentry/api/urls.py, offset=1525) · grep(\"get_event_stats_data\", src/sentry/api) · read_file(src/sentry/api/bases/organization_events.py, offset=479) · read_file(tests/snuba/api/endpoints/test_organization_events_stats.py, offset=3595)_\n\n> **Fix** — Use `scoped_dataset` here too, matching the closure's other uses (or confirm the captured `dataset` is intended at this site).\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": "src/sentry/testutils/factories.py", + "line": 355, + "body": "### 🟡 Medium · This guard tests a rate/factor value for truthiness and the body converts it with `float()`, so an explicit value of `0` or `0.0` is treated exactly like a missing value and silently skipped. Zero is a meaningful rate (sample nothing, scale by nothing); dropping it changes behavior for callers that set it deliberately. Test for presence explicitly with `is not None` instead.\n\n\n[CWE-1023: Incomplete Comparison with Missing Factors] This guard tests a rate/factor value for truthiness and the body converts it with `float()`, so an explicit value of `0` or `0.0` is treated exactly like a missing value and silently skipped. Zero is a meaningful rate (sample nothing, scale by nothing); dropping it changes behavior for callers that set it deliberately. Test for presence explicitly with `is not None` instead.\n\n\n**Confirmed by investigation** — the proof pass could not settle an axis either way; the finding stands on its line-cited investigation evidence. The guard at factories.py:353 `if client_sample_rate:` skips assignment when 0 is passed via the sole caller store_event (factories.py:1049). Production consumer event_manager.py:786 enforces `0 < client_sample_rate <= 1`, logs 0 as invalid at :788–796, and test_event_manager.py:3103–3127 explicitly tests 0 as an invalid rate yielding times_seen == 1.\n_Impact: No harm: skipping 0 matches the production contract that defines 0 as invalid; the proposed `is not None` fix would diverge from production behavior._\n_Queries: read_file(src/sentry/testutils/factories.py, offset=330, limit=60) · ast_call_sites(_set_sample_rate_from_error_sampling) · read_file(src/sentry/testutils/factories.py, offset=1000, limit=70) · grep(\"client_sample_rate\", path=\"src/sentry\") · read_file(src/sentry/event_manager.py, offset=774, limit=30) · read_file(tests/sentry/event_manager/test_event_manager.py, offset=3100, limit=45) · grep(\"error_sampling\", path=\"tests\")_\n\n", + "created_at": "2026-08-21T21:34:11Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Error upsampling skips the count/orderby expression: count() alias untransformed in helper, Top-N ranking orders by raw counts while displayed series are upsampled | `src/sentry/api/endpoints/organization_events_stats.py:241` |\n| 🟠 High | Naive substring misclassifies negated/mixed event.type queries as error-focused | `src/sentry/api/helpers/error_upsampling.py:137` |\n| 🔵 Low | Transaction event stored without wait_for_event_count → racing assertion | `tests/snuba/api/endpoints/test_organization_events_stats.py:3596` |\n| 🔵 Low | upsampled_count registered as a public, user-callable function (missing private=True) | `src/sentry/search/events/datasets/discover.py:1041` |\n| 🟡 Medium | Closure uses captured `dataset` where its refined parameter `scoped_dataset` is used elsewhere | `src/sentry/api/endpoints/organization_events_stats.py:220` |\n| 🟡 Medium | This guard tests a rate/factor value for truthiness and the body converts it with `float()`, so an explicit value of `0` or `0.0` is treated exactly like a missing value and silently skipped. Zero is a meaningful rate (sample nothing, scale by nothing); dropping it changes behavior for callers that set it deliberately. Test for presence explicitly with `is not None` instead. | `src/sentry/testutils/factories.py:355` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -6.2 |\n| Test Coverage | 61 (C) | 100 (A+) | +38.9 |\n| Code Smell | 86 (A-) | 86 (A-) | -0.0 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 93 (A) | 93 (A) | -0.0 |\n| **Overall** | **76 (B)** | **82 (B+)** | **+5.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:11Z" + } + ] } ] }, @@ -29189,6 +29933,36 @@ "created_at": "2026-06-28T22:40:53Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/103633", + "review_comments": [ + { + "path": "pkg/services/authz/rbac/service.go", + "line": 116, + "body": "### 🔵 Low · Denial-cache key omits identity-type discriminator causing cross-identity denial shadowing\n\nThe denial cache key is built from the raw subject UID alone, dropping the identity type, while sibling permission-cache keys discriminate identity types (anonymous literal prefix vs UID-based). Since validateSubject parses user:1, sa:1, and anonymous:1 all to UID '1', and getIdentityPermissions proves those types draw from different permission sources, a denial cached for one identity is served to the others, returning Allowed:false without consulting their permission sets for up to 30s.\n\nThe user/SA pair is benign (same DB row), but anonymous/render-vs-user pairs are harmful if their UIDs overlap, which cannot be verified from this checkout. This may be intentional if identity types never share UIDs, but the asymmetry on an authorization caching path warrants reporting at low confidence.\n\n> **Fix** — Include checkReq.IdentityType in userPermDenialCacheKey (cache.go:30) so denial entries are scoped per identity type.\n", + "created_at": "2026-08-21T22:34:42Z" + }, + { + "path": "pkg/services/authz/rbac/service_test.go", + "line": 979, + "body": "### 🟡 Medium · RBAC denial-cache keyed ambiguously: anonymous/render-service UID '0' collision denies wrong identity, (name,parent) boundary collision denies wrong resource, stale entries create 30s false-denial window, and tests seed cache with false so deny-prec…\n\nThe test seeds permCache with map[string]bool{'dashboards:uid:dash1': false} and comments 'Allow access to the dashboard to prove this is not checked'. But checkPermission treats a false map value as denied, so the test passes without the denial-cache feature. The feature's precedence over a genuine allow is untested and the comment is wrong.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). service_test.go:982 seeds s.permCache with map[string]bool{\"dashboards:uid:dash1\": false} under the comment at service_test.go:981 claiming \"Allow access to the dashboard to prove this is not checked\". service.go:558 `if scopeMap[t.scope(req.Name)]` — a false map value is indistinguishable from an absent key (both \"not allowed\"); the seeded value is a deny, not an allow. service.go:116–121 — Check returns denied on the permDenialCache hit (seeded at service_test.go:979) before the permCache is consulted; this is the only path that currently satisfies assert.False (service_test.go:994). If the denial-cache check were removed: getCachedIdentityPermissions (service.go:360–361, key userPermCacheKey(\"org-12\",\"test-uid\",\"dashboards:read\") matches the seed) returns the false-entry map; checkPermission → false (service.go:558; checkInheritedPermissions service.go:581–607 on empty fakeStore folder tree); DB fallback (service.go:139) returns empty because the subtest never sets fakeStore.userPermissions (nil, service_test.go:1359) → false → assert.False still passes. Resolved path: TestService_CacheCheck (service_test.go:893) → subtest at 973 executes in the suite; verb get → action dashboards:read (mapper.go:30–38).\n_Impact: Test-integrity defect: the test asserts precedence coverage it does not provide; cannot fail on feature regression. Denial-cache precedence regressions ship undetected._\n_Queries: read_file(pkg/services/authz/rbac/service_test.go, offset=940, limit=80) · grep(pattern=\"func .*checkPermission|checkPermission\\\\\\(\", path=\"pkg/services/authz/rbac\") · read_file(pkg/services/authz/rbac/service.go, offset=500, limit=90) · read_file(pkg/services/authz/rbac/service.go, offset=80, limit=130) · grep(pattern=\"func setupService|type fakeStore\", path=\"pkg/services/authz/rbac\") · read_file(service_test.go, offset=1325, limit=120) · read_file(pkg/services/authz/rbac/service.go, offset=578, limit=60) · read_file(pkg/services/authz/rbac/mapper.go) · grep(pattern=\"userPermCacheKey|userPermDenialCacheKey|userIdentifierCacheKey\", path=\"pkg/services/authz/rbac\") · read_file(service.go, offset=340, limit=28) · read_file(service_test.go, offset=890, limit=30)_\n\n> **Fix** — Seed permCache with a true value for the dashboard to genuinely prove the denial cache takes precedence over an allow.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Test for explicit cache deny entry is vacuous — seeds cache with false instead of true**\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR103633__20260821/blob/7562f37880367411a62304bcdcdc178bced23906/pkg/services/authz/rbac/service_test.go#L980-L984\n\n
\n\n
This same fix applies at 3 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR103633__20260821/blob/7562f37880367411a62304bcdcdc178bced23906/pkg/services/authz/rbac/cache.go#L29-L33\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR103633__20260821/blob/7562f37880367411a62304bcdcdc178bced23906/pkg/services/authz/rbac/service.go#L115-L119\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR103633__20260821/blob/7562f37880367411a62304bcdcdc178bced23906/pkg/services/authz/rbac/service.go#L114-L118\n\n
\n", + "created_at": "2026-08-21T22:34:42Z" + }, + { + "path": "pkg/services/authz/rbac/cache.go", + "line": 30, + "body": "### 🔵 Low · Denial cache key collision from unescaped underscore concatenation\n\nThe denial cache key is built via raw string concatenation with '_' separators and no escaping. Since Grafana UIDs may contain underscores, inputs like name='a_b', parent='c' and name='a', parent='b_c' produce the same key, causing a false denial for an unrelated resource. Low probability, false-denial only.\n\n", + "created_at": "2026-08-21T22:34:42Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Denial-cache key omits identity-type discriminator causing cross-identity denial shadowing | `pkg/services/authz/rbac/service.go:116` |\n| 🟡 Medium | RBAC denial-cache keyed ambiguously: anonymous/render-service UID '0' collision denies wrong identity, (name,parent) boundary collision denies wrong resource, stale entries create 30s false-denial window, and tests seed cache with false so deny-prec… | `pkg/services/authz/rbac/service_test.go:979` |\n| 🔵 Low | Denial cache key collision from unescaped underscore concatenation | `pkg/services/authz/rbac/cache.go:30` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -1.2 |\n| Test Coverage | 73 (B-) | 100 (A+) | +26.6 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 95 (A+) | 95 (A+) | -0.0 |\n| **Overall** | **86 (A-)** | **91 (A)** | **+4.3** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:42Z" + } + ] } ] }, @@ -30712,6 +31486,36 @@ "created_at": "2026-06-28T22:48:17Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/getsentry/sentry/pull/67876", + "review_comments": [ + { + "path": "src/sentry/integrations/github/integration.py", + "line": 427, + "body": "### 🔵 Low · OAuthLoginView token-exchange error handling diverges from sibling: network calls outside try/except cause 500 on transient GitHub failure and error paths behave differently\n\nThe new copy unconditionally parses the token response with parse_qsl (line 427), where the sibling OAuth2CallbackView.exchange_token is Content-Type aware and falls back to json.loads (oauth2.py:289-291); it collapses all exceptions to {} with no logging (lines 428-429) where the sibling distinguishes/logs SSLError, ConnectionError, JSONDecodeError (oauth2.py:292-314) and surfaces the specific error_description; it never inspects the HTTP status; and it omits redirect_uri (present in the authorize URL this same view issued at line 408) and grant_type from the token request. Trigger: any failed/errored token exchange → generic \"We could not verify the authenticity…\" page, upstream error lost, no log trail.\n\nThis may be intentional as a deliberate simplification, but it loses diagnostic information.\n\n> **Fix** — Reuse the sibling's exchange_token, or at least log the exception and surface the specific error.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/sentry__sentry__corbulo__PR67876__20260821/blob/bb75657fc8f13923c1d7983f422290908a1e7310/src/sentry/integrations/github/integration.py#L421-L425\n\n
\n", + "created_at": "2026-08-21T21:34:15Z" + }, + { + "path": "src/sentry/integrations/github/integration.py", + "line": 132, + "body": "### 🔵 Low · i18n regression: error_short strings no longer extracted for translation\n\nThe refactor into the error() helper passes error_short as a variable to _() at line 141, so xgettext no longer extracts the literal strings. The two pre-existing strings lose translation coverage and the new default never gains it. At runtime _() degrades gracefully to English, so the UI still works but non-English locales show untranslated text. This may be intentional if the strings were not meant to be translated, but the prior code explicitly wrapped them in _(), indicating translation was intended.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). integration.py:141 passes the parameter `error_short` to `_()` — the three literals (\"Invalid installation request.\" at :132, \"GitHub installation pending deletion.\" at :471, \"Github installed on another Sentry organization.\" at :488) never appear as `_()` literal arguments, so makemessages/xgettext cannot extract them. Every locale catalog (en:878–884, de:901–906, fr:919–925, zh_CN:932–936, pt_BR:898–903) still holds these strings as active entries with references to the pre-refactor locations `integrations/github/integration.py:286` and `:309` — proof they were previously extracted via direct `_()` wrapping; the entries are now orphaned and will be dropped as obsolete on the next makemessages run. The new default \"Invalid installation request.\" appears in no catalog (grep over `src/sentry` returned only integration.py:132). All checked locales have empty `msgstr` for these entries, so `_()` already fell back to English at runtime both before and after the change.\n_Impact: Users in non-English locales see untranslated English error text on the GitHub integration install-failure page, and the two previously extracted strings permanently lose translation coverage._\n_Queries: read_file(src/sentry/integrations/github/integration.py, offset=80, limit=120) · read_file(src/sentry/integrations/github/integration.py, offset=400, limit=120) · grep(\"error\\(\", path=src/sentry/integrations/github) · grep(\"Invalid installation request\", path=src/sentry) · grep(\"installation pending deletion\", path=src/sentry) · grep(\"Github installed on another Sentry organization\", path=src/sentry/locale) · read_file of django.po entries in en/de/fr/zh_CN/pt_BR around the two strings_\n\n> **Fix** — Wrap the literals at the call sites and default parameter, e.g. error_short=_(\"GitHub installation pending deletion.\"), so extraction sees them.\n", + "created_at": "2026-08-21T21:34:15Z" + }, + { + "path": "src/sentry/integrations/github/integration.py", + "line": 402, + "body": "### 🟡 Medium · The OAuth `state` parameter is bound to a fixed/derived-static value instead of a per-request random nonce. A predictable state defeats the CSRF/replay protection the parameter exists for: anyone who observes one authorize URL can replay it against the callback (sentry-67876: state = pipeline.signature interpolated into the GitHub authorize redirect). Generate a fresh nonce per authorization request (e.g. secrets.token_hex()), store it in the session, and verify it on the callback.\n\n\n[CWE-352: Cross-Site Request Forgery (CSRF)] The OAuth `state` parameter is bound to a fixed/derived-static value instead of a per-request random nonce. A predictable state defeats the CSRF/replay protection the parameter exists for: anyone who observes one authorize URL can replay it against the callback (sentry-67876: state = pipeline.signature interpolated into the GitHub authorize redirect). Generate a fresh nonce per authorization request (e.g. secrets.token_hex()), store it in the session, and verify it on the callback.\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). src/sentry/integrations/github/integration.py:402 sets `state = pipeline.signature`; line 408 interpolates it into the GitHub authorize redirect; line 412 validates the callback with `request.GET.get(\"state\") != pipeline.signature`. src/sentry/pipeline/base.py:132-133 computes `self.signature = md5_text(*pipe_ids).hexdigest()` where pipe_ids are the pipeline-view class names — a deterministic constant identical for every request, user, and org. src/sentry/integrations/github/integration.py:343-344 returns fixed classes `[OAuthLoginView(), GitHubInstallation()]`, so the state never varies and is publicly visible in every authorize URL. In contrast, src/sentry/identity/oauth2.py:245 and src/sentry/auth/providers/oauth2.py:54 generate `state = secrets.token_hex()` per request, bind it via `pipeline.bind_state(\"state\", state)`, and src/sentry/identity/oauth2.py:325 verifies `state != pipeline.fetch_state(\"state\")` on the callback. The GitHub integration's OAuthLoginView is the sole OAuth state bound to a static derived value instead of a stored per-request nonce. Reachable path: `sentry-extension-setup` URL (src/sentry/web/urls.py:1112) → PipelineAdvancerView.handle (src/sentry/web/frontend/pipeline_advancer.py:29-64) → pipeline.current_step() → OAuthLoginView.dispatch — runs on every GitHub integration install.\n_Impact: The CSRF/replay protection the OAuth state exists for is defeated: a replayed callback carrying an attacker-obtained GitHub `code` plus the known state is accepted in the victim's session, exchanging the attacker's code and injecting attacker-controlled identity into the victim's pipeline (login-CSRF / account-linking). The exchanged code's identity is bound as `github_authenticated_user` (integration.py:438) and consumed in the sender-match check (integration.py:500-505), which constrains full integration hijack but does not eliminate the identity injection._\n_Queries: read_file(path=src/sentry/integrations/github/integration.py, offset=360, limit=80) · grep(pattern=\"def signature\", path=src/sentry) · grep(pattern=\"signature\", path=src/sentry/pipeline) · read_file(path=src/sentry/pipeline/base.py) · read_file(path=src/sentry/integrations/github/integration.py, offset=240, limit=120) · read_file(path=src/sentry/identity/oauth2.py, offset=225, limit=45) · read_file(path=src/sentry/auth/providers/oauth2.py, offset=30, limit=45) · read_file(path=src/sentry/identity/oauth2.py, offset=316, limit=45) · grep(pattern=\"sentry-extension-setup\", path=src/sentry) · read_file(path=src/sentry/web/frontend/pipeline_advancer.py) · grep(pattern=\"github_authenticated_user\", path=src/sentry) · read_file(path=src/sentry/integrations/github/integration.py, offset=480, limit=45)_\n\n", + "created_at": "2026-08-21T21:34:15Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | OAuthLoginView token-exchange error handling diverges from sibling: network calls outside try/except cause 500 on transient GitHub failure and error paths behave differently | `src/sentry/integrations/github/integration.py:427` |\n| 🔵 Low | test_installation_not_found no longer exercises the 404 path; passes via state-mismatch branch | `tests/sentry/integrations/github/test_integration.py:379` (not in the diff) |\n| 🔵 Low | i18n regression: error_short strings no longer extracted for translation | `src/sentry/integrations/github/integration.py:132` |\n| 🟠 High | GitHub integration install path mishandles pipeline-created integrations: new-user validation silently skipped in default path, KeyError on missing metadata['sender'] | `src/sentry/integrations/github/integration.py` (not in the diff) |\n| 🔵 Low | test_basic_flow metadata assertion gap | `tests/sentry/integrations/github/test_integration.py` (not in the diff) |\n| 🟡 Medium | The OAuth `state` parameter is bound to a fixed/derived-static value instead of a per-request random nonce. A predictable state defeats the CSRF/replay protection the parameter exists for: anyone who observes one authorize URL can replay it against the callback (sentry-67876: state = pipeline.signature interpolated into the GitHub authorize redirect). Generate a fresh nonce per authorization request (e.g. secrets.token_hex()), store it in the session, and verify it on the callback. | `src/sentry/integrations/github/integration.py:402` |\n\n---\n\n### 🔵 Low · test_installation_not_found no longer exercises the 404 path; passes via state-mismatch branch\n\n`tests/sentry/integrations/github/test_integration.py:379`\n\nThe test's first GET has no prior init_path request, so OAuthLoginView redirects to GitHub without making an API call. The second GET passes a state that differs from the pipeline signature, so OAuthLoginView exits at the state-mismatch branch with the generic error.\n\nThe 404 stub is never consulted, so the 404 path (build_integration catching ApiError.code == 404) is uncovered. The test passes only because the generic message matches, not because the 404 path is exercised.\n\nThis is a coverage regression introduced by the change.\n\n---\n\n### 🟠 High · GitHub integration install path mishandles pipeline-created integrations: new-user validation silently skipped in default path, KeyError on missing metadata['sender']\n\n`src/sentry/integrations/github/integration.py`\n\nThe new security check accesses integration.metadata[\"sender\"][\"login\"], but the sender key is only set when the integration is created via the webhook. When created via the pipeline's finish_pipeline, ensure_integration overwrites the metadata, removing sender.\n\nSubsequent installation attempts raise an unhandled KeyError (500 error).\n\n---\n\n### 🔵 Low · test_basic_flow metadata assertion gap\n\n`tests/sentry/integrations/github/test_integration.py`\n\nThe test asserts metadata without sender, but doesn't cover the pipeline flow where the metadata overwrite behavior (from Finding 1) occurs, so the bug isn't caught by existing tests.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 37 (F) | 37 (F) | -0.1 |\n| Runtime | 100 (A+) | 95 (A) | -5.3 |\n| Test Coverage | 59 (C-) | 100 (A+) | +40.8 |\n| **Overall** | **77 (B)** | **83 (B+)** | **+6.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:34:15Z" + } + ] } ] }, @@ -31966,6 +32770,24 @@ "created_at": "2026-06-28T22:22:01Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/keycloak/keycloak/pull/32918", + "review_comments": [ + { + "path": "model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java", + "line": 403, + "body": "### 🟡 Medium · BROKER_PUBLIC flip on a realm-only IDP never invalidates the new login caches; stale IDPs render on the org login page\n\nThe new predicate term at server-spi:254 short-circuits on orgId == null, so a realm-only IDP's predicate result cannot change when only BROKER_PUBLIC changes. registerIDPLoginInvalidationOnUpdate (Infinispan:403-418) therefore takes the early return and never invalidates the login cache, yet the delegate query getForLogin(ORG_ONLY, null) (server-spi:173-181) does depend on BROKER_PUBLIC for realm-only IDPs. The cached set becomes stale, and downstream re-checks only verify isEnabled, not BROKER_PUBLIC, so stale IDPs render on the org login page. This may be intentional if the cache is expected to be refreshed by other means, but no such mechanism is evident.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). The predicate at `registerIDPLoginInvalidationOnUpdate` (InfinispanIdentityProviderStorageProvider.java:403-418) evaluates `getLoginPredicate().test(original)` and `getLoginPredicate().test(updated)` and takes the early return at lines 405-407 (if both are NO) or lines 409-412 (if both are YES and the org link is unchanged). The predicate, at server-spi `IdentityProviderStorageProvider.java:254`, includes the IDP when `organizationId == null || Boolean.parseBoolean(config.get(BROKER_PUBLIC))`. For a **realm-only** IDP (`organizationId == null`), toggling `BROKER_PUBLIC` cannot change the predicate result because the first disjunct makes the config term irrelevant. Therefore:\n- A realm-only IDP that is currently a login IDP and has its `BROKER_PUBLIC` toggled will have `getLoginPredicate()` true both before and after the update, so lines 409-412 match (`original` true, `updated` true, `organizationId` equal — both null) and the method returns without invalidating.\n- A realm-only IDP that is currently NOT a login IDP cannot have `BROKER_PUBLIC` toggled in a way that changes its predicate status either, since the disjunct `organizationId == null` keeps the predicate true regardless of the config. The predicate is thus constant over realm-only orgs.\n\nYet `getForLogin(ORG_ONLY, organizationId)` (InfinispanIdentityProviderStorageProvider.java:214-255) caches the set of IDP internal IDs from `idpDelegate.getForLogin(ORG_ONLY, organizationId)`, and the server-spi delegate query at `IdentityProviderStorageProvider.java:173-181` sets `searchOptions.put(OrganizationModel.BROKER_PUBLIC, \"true\")` only when the mode's predicate requires it — the ORG_ONLY path DOES depend on `BROKER_PUBLIC` for org-linked IDPs. (For org-linked IDPs, the predicate term `Boolean.parseBoolean(config.get(BROKER_PUBLIC))` DOES make the predicate flip when the flag is toggled — but the org-link change is what the org-aware invalidation covers. The claimed gap is specific to realm-only IDPs.)\n\nThe IDP models themselves are re-loaded via `session.identityProviders().getById(id)` at line 246 (so the rendered IDs carry the current config), but the cached **set of qualifying IDP internal IDs** is stored inside `IdentityProviderListQuery` keyed only on realm + FetchMode and is not refreshed after a config-only BROKER_PUBLIC toggle.\n\nThe cache test at `OrganizationCacheTest.java:417-536` explicitly verifies that config updates which do not change login-availability status (steps 1-2: `setTrustEmail`, config `\"somekey\"`, `setHideOnLogin`) do NOT invalidate login caches, and that hiding/showing an IDP (step 3, `setHideOnLogin(true→false)`) DOES. It does not test a BROKER_PUBLIC change on a realm-only IDP.\n\nThe claim's own mechanism is therefore real: `registerIDPLoginInvalidationOnUpdate` cannot react to a BROKER_PUBLIC change for a realm-only IDP because the predicate is insensitive to that config for such IDPs, and the cached set stored under the login-fetch cache key is exactly the artifact that would go stale.\n_Impact: Administrators toggling “shown on login page” on an org identity provider observe the org login page continuing to show (or hide) that broker until some unrelated IDP change invalidates the login cache — stale, wrong login-page behavior in production._\n_Queries: read_file(`InfinispanIdentityProviderStorageProvider.java:355-429`) · read_file(`InfinispanIdentityProviderStorageProvider.java:60-211` and `214-255`) · read_file(`IdentityProviderBean.java:55-120`, `205-242`) · read_file(`OrganizationCacheTest.java:417-536`)_\n\n> **Fix** — Make registerIDPLoginInvalidationOnUpdate compare the BROKER_PUBLIC config value (or actual query membership) for realm-only IDPs instead of relying on the short-circuited predicate.\n", + "created_at": "2026-08-21T22:35:44Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | BROKER_PUBLIC flip on a realm-only IDP never invalidates the new login caches; stale IDPs render on the org login page | `model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java:403` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -0.8 |\n| **Overall** | **89 (A-)** | **89 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:43Z" + } + ] } ] }, @@ -33117,6 +33939,30 @@ "created_at": "2026-06-28T23:57:04Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/94942", + "review_comments": [ + { + "path": "pkg/expr/sql/parser.go", + "line": 24, + "body": "### 🔵 Low · The exact vulnerable sink (SELECT json_serialize_sql('%s') with '-escaping) and its AST machinery remain in the tree, now dead, with the disable anchored only by a stub error and no comment/test\n\nTablesList still builds the vulnerable command and calls the stub RunCommands, which always errors, so the AST machinery and constants are unreachable. The RCE/LFI surface is exactly one errors.New(\"not implemented\") removal away from resurrection (e.g., re-adding go-duck to fill the stub), with no comment or test anchoring the intent — the weakest form of a security disable.\n\nPresent-day execution is blocked, so this is low severity.\n\n> **Fix** — Remove the dead vulnerable code entirely, or add a clear comment and test that anchor the disable. If the code must remain, add a guard that is not a stub error.\n", + "created_at": "2026-08-21T22:35:32Z" + }, + { + "path": "pkg/expr/reader.go", + "line": 195, + "body": "### 🔵 Low · enableSqlExpressions unconditionally returns false; the sqlExpressions flag is dead and inverted\n\nThe function computes the negation of the flag and then discards it — both branches return false, so the flag never influences the return. If flag-gating was intended, enabling the flag does not enable the feature and operators get 'sqlExpressions is not implemented'. If hard-disable was intended, the inverted flag consult is dead code whose canonical cleanup (return enabled) would enable SQL when the flag is OFF, silently re-opening the RCE/LFI path this PR exists to close. No test pins the behavior.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). pkg/expr/reader.go:195 — `enabled := !h.features.IsEnabledGlobally(featuremgmt.FlagSqlExpressions)`; reader.go:196-199 — `if enabled { return false }` then `return false`. Both branches return `false`; the flag value is inverted and discarded, so it can never influence the return. pkg/expr/reader.go:129-131 — `QueryTypeSQL` calls `enableSqlExpressions(h)` and returns `\"sqlExpressions is not implemented\"`; `guards_before` shows the `(!enabled)` branch holds at line 132 on every execution of that case. `discover(callers_of, ReadQuery)` resolved exactly two callers, both live: `queryParser.parseRequest` (`pkg/registry/apis/query/parser.go:62`/`:102`, query API server) and `buildCMDNode` (`pkg/expr/nodes.go:137`, SSE expression path) — user-submitted SQL-typed queries reach the rejected case. `FlagSqlExpressions = \"sqlExpressions\"` (`pkg/services/featuremgmt/toggles_gen.go:584`) is a registered, documented toggle (\"Enables using SQL and DuckDB functions as Expressions\", docs feature-toggles index:187, default false) whose value is read and discarded. Grep of `pkg/expr/*_test.go` for `sqlExpressions`: no matches — the always-false rejection is not pinned by any test.\n_Impact: The documented `sqlExpressions` control is inert (operators who enable it get \"sqlExpressions is not implemented\"), and the inverted discarded computation is a cleanup trap — a naive `return enabled` would return true when the flag is OFF, enabling SQL by default and re-opening the CVE-2024-9264 RCE/LFI path._\n_Queries: read_file(path: pkg/expr/reader.go) · grep(pattern: \"enableSqlExpressions|FlagSqlExpressions|sqlExpressions\", repo: default) · grep(pattern: \"ReadQuery|NewExpressionQueryReader\", glob: \"*.go\", repo: default) · read_file(path: pkg/registry/apis/query/parser.go, offset: 80, limit: 50) · read_file(path: pkg/expr/nodes.go, offset: 95, limit: 80) · grep(pattern: \"sqlExpressions\", path: pkg/expr, glob: \"*_test.go\") · grep(pattern: \"not implemented\", path: pkg/expr/sql) · discover(kind: \"guards_before\", args: {file: \"pkg/expr/reader.go\", line: \"132\"}) · discover(kind: \"callers_of\", args: {name: \"ReadQuery\"}) · read_file(path: pkg/services/featuremgmt/toggles_gen.go, offset: 575, limit: 15)_\n\n> **Fix** — If gating was intended, return h.features.IsEnabledGlobally(featuremgmt.FlagSqlExpressions). If hard-disable was intended, delete the flag consult and the negated variable entirely.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. enableSqlExpressions is constant-false; the FlagSqlExpressions toggle is dead and its computation is inverted**\n\nThe function reads a feature flag but its value cannot influence the result: both branches return false, and the 'enabled' variable is computed as the negation of the flag. The caller therefore always rejects QueryTypeSQL regardless of flag state.\n\nIf the intent was a flag-gated disable, the toggle can never re-enable SQL; at minimum the inverted computation and discarded result make the control unverifiable. The safe side (SQL rejected) holds, so this is low severity.This comment also covers: enableSqlExpressions always returns false, making FlagSqlExpressions inert\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR94942__20260821/blob/f3317b329b4eb8fd96f99dd86525bc4a22d20248/pkg/expr/reader.go#L192-L196\n\n
\n", + "created_at": "2026-08-21T22:35:32Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | The exact vulnerable sink (SELECT json_serialize_sql('%s') with '-escaping) and its AST machinery remain in the tree, now dead, with the disable anchored only by a stub error and no comment/test | `pkg/expr/sql/parser.go:24` |\n| 🔵 Low | enableSqlExpressions unconditionally returns false; the sqlExpressions flag is dead and inverted | `pkg/expr/reader.go:195` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 65 (C) | 65 (C) | -0.0 |\n| Runtime | 100 (A+) | 100 (A+) | -0.3 |\n| **Overall** | **87 (A-)** | **87 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:32Z" + } + ] } ] }, @@ -34108,6 +34954,24 @@ "created_at": "2026-06-29T00:05:52Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/90939", + "review_comments": [ + { + "path": "pkg/api/webassets/webassets.go", + "line": 49, + "body": "### 🔵 Low · Webassets cache lock held across blocking I/O serializes all callers and lacks post-lock re-check, causing redundant re-reads on concurrent misses\n\ndefer entryPointAssetsCacheMu.Unlock() (line 49) is held across readWebAssetsFromFile (line 60) — os.Open + full JSON decode — and across cfg.GetContentDeliveryURL/license.ContentDeliveryPrefix() (line 62). While the writer holds the lock, every other caller blocks at line 41's RLock(), including fast-path cache readers.\n\nIn dev mode, every request takes the exclusive lock and does a full disk re-read serially, converting previously parallel reads into a per-request global bottleneck. This may be intentional to ensure consistency, but it causes measurable latency on a hot path.\n\n> **Fix** — Read the manifest outside the lock and publish only the pointer under it.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Missing double-checked-locking re-check after Lock() causes serialized redundant re-reads on concurrent misses**\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR90939__20260821/blob/b1613e320acff00623e6efc59f00ee68c7684a97/pkg/api/webassets/webassets.go#L46-L50\n\n**2. Package-global exclusive mutex held across disk I/O and CDN fetch serializes dev-mode requests**\n\nThe exclusive lock is held via defer across the entire slow path, including os.Open + JSON decode and the latent CDN HTTP fetch. In dev mode the fast path is skipped whenever cfg.Env == setting.Dev, so every call to GetWebAssets from setIndexViewData, GetFrontendAssets, and Recovery takes the global mutex and performs a full disk read under it.\n\nThe pre-change code had no lock at all in these paths, so this PR converts dev-mode re-reads from concurrent to globally serialized, and any N concurrent first requests that miss the fast path also serialize on this one mutex while doing file I/O. No deadlock occurs, so this is a performance regression rather than a crash. This may be intentional to ensure consistency, but the serialization cost is a real behavioral change introduced by this PR.\n\n
\n", + "created_at": "2026-08-21T22:35:29Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Webassets cache lock held across blocking I/O serializes all callers and lacks post-lock re-check, causing redundant re-reads on concurrent misses | `pkg/api/webassets/webassets.go:49` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.3 |\n| **Overall** | **87 (A-)** | **87 (A-)** | **-0.0** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:35:28Z" + } + ] } ] }, @@ -35513,6 +36377,42 @@ "created_at": "2026-06-29T00:00:06Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/80329", + "review_comments": [ + { + "path": "pkg/services/annotations/annotationsimpl/xorm_store.go", + "line": 608, + "body": "### 🔵 Low · Parameter-limit guard covers only SQLite; MSSQL/MySQL deployments can hard-fail cleanup\n\nThe new placeholder-based DELETE builds up to batchSize bound parameters, but the guard that caps the batch size only applies to SQLite. MSSQL has a hard 2100-parameter limit and MySQL a 65535-parameter limit; a deployment setting AnnotationCleanupJobBatchSize above those limits now gets a hard failure on every cleanup cycle, where the old zero-placeholder subquery DELETE could not fail.\n\nThis is config-dependent and non-destructive (errors retry next cycle), but it is a real regression for affected deployments. It may be intentional if the configuration is expected to stay within driver limits, but the PR's own test uses 32767, which exceeds MSSQL's limit.\n\n> **Fix** — Extend the parameter-limit guard to also cap the batch size for MSSQL and MySQL, or fall back to the subquery approach for those drivers.\n", + "created_at": "2026-08-22T01:03:34Z" + }, + { + "path": "pkg/services/annotations/annotationsimpl/xorm_store.go", + "line": 609, + "body": "### 🔵 Low · O(n²) string concatenation in SQLite inline DELETE path\n\nEach iteration of the loop re-copies the entire accumulated string into a new allocation, making the construction quadratic in the batch size. For a 32,767-ID batch this amounts to roughly 3.7 GB of cumulative copying.\n\nThis is a performance-only defect on the SQLite inline path, triggered when the configured batch size exceeds 999. It may be intentional if the batch size is expected to stay small in production, but the PR's own test exercises the large-batch path.This comment also covers: O(n²) string re-concatenation in deleteByIDs SQLite inline builder\n\n> **Fix** — Replace the repeated fmt.Sprintf concatenation with a strings.Builder or strconv.AppendInt to build the IN-list in linear time.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR80329__20260821/blob/04cfa3bfd469c035ff8b35f9a66867c4e8d5dcf4/pkg/services/annotations/annotationsimpl/xorm_store.go#L609-L613\n\n
\n", + "created_at": "2026-08-22T01:03:34Z" + }, + { + "path": "pkg/services/cleanup/cleanup.go", + "line": 77, + "body": "### 🔵 Low · CleanupServiceImpl.Run ticker changed from 10 min to 1 min\n\nThe ticker period was reduced from 10 minutes to 1 minute, causing all eight cleanup jobs to run ten times more often. This multiplies DB load and may increase deadlock risk on the annotation table, especially with the new per-batch SELECT+DELETE pattern.\n\nThe change is undocumented and unrelated to the PR's stated purpose of deadlock avoidance. It may be intentional for more frequent cleanup, but the lack of documentation and the 10x load increase make it a real concern.\n\n> **Fix** — Document the frequency change and its rationale, or revert to 10 minutes if not intended.\n", + "created_at": "2026-08-22T01:03:34Z" + }, + { + "path": "pkg/services/annotations/annotationsimpl/xorm_store.go", + "line": 534, + "body": "### 🔵 Low · Error-level logging of routine cleanup batches\n\nSix unconditional r.log.Error calls fire on every successful batch of annotation cleanup, including when zero rows match and err is provably nil (early returns at lines 531-533, 551-553, 573-575 guarantee nil err at lines 534, 554, 576). The cleanup ticker was changed from 10 minutes to 1 minute (cleanup.go:77), amplifying this ERROR-severity telemetry to fire every minute on every instance with cleanup enabled, dumping full ids slices and raw SQL cond at Error level.\n\nThis is a log-hygiene regression — routine successful outcomes are logged as errors, polluting error logs and alerting. It may be intentional if the team wants visibility into cleanup activity, but the nil err keys and Error severity contradict sibling conventions (all other success paths log at Debug).\n\n> **Fix** — Change these six log calls to r.log.Debug or r.log.Info and remove the provably-nil err key from the log arguments.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. r.log.Error used for routine informational logging**\n\nEach batch-work callback logs progress with r.log.Error(...) even when there is zero work, and the first log of each pair always carries err=nil, making the field misleading. These fire every cleanup cycle (now every minute) for each of three annotation types, embedding full SQL conditions and entire ID lists.\n\nThis produces a steady error-level stream that pollutes error logs and trips error-severity alerting, even during normal successful cleanup. The surrounding code uses log.Info/log.Debug for such messages, so this is likely a mistake, though it could be intentional for visibility.\n\n**2. log.Error on every successful cleanup batch (6 sites)**\n\nEach batch of this periodic cleanup job logs at Error level, dumping the entire ids slice and an err field that is structurally always nil (the fetch error returns early, and deleteByIDs returns (0, err) on failure; the success path returns nil). The always-nil err field plus the mirrored 'to clean'/'cleaned' message pair mark these as debugging leftovers.\n\nEffect: every cleanup cycle emits up to six Error-level log lines with hundreds/thousands of IDs each — log spam on a routine success path. The author may have intended Error for visibility, but Error-on-success with always-nil err is objectively off-severity.\n\n**3. Error-level logging of routine cleanup path with unbounded ID dumps**\n\nEvery batch of every cleanup cycle now emits two error-level log lines, one embedding the entire fetched id slice, which can be multi-hundred-KB (batch size up to 32767). This floods logs in HA setups and triggers error alerting in typical Grafana deployments.\n\nIt is likely a deliberate debug artifact, hence low severity and ~60% confidence.\n\n
\n", + "created_at": "2026-08-22T01:03:34Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Parameter-limit guard covers only SQLite; MSSQL/MySQL deployments can hard-fail cleanup | `pkg/services/annotations/annotationsimpl/xorm_store.go:608` |\n| 🔵 Low | O(n²) string concatenation in SQLite inline DELETE path | `pkg/services/annotations/annotationsimpl/xorm_store.go:609` |\n| 🔵 Low | CleanupServiceImpl.Run ticker changed from 10 min to 1 min | `pkg/services/cleanup/cleanup.go:77` |\n| 🔵 Low | Error-level logging of routine cleanup batches | `pkg/services/annotations/annotationsimpl/xorm_store.go:534` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.3 |\n| Code Smell | 96 (A+) | 96 (A+) | -0.0 |\n| Consistency | 97 (A+) | 97 (A+) | -0.0 |\n| **Overall** | **87 (A-)** | **87 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-22T01:03:34Z" + } + ] } ] }, @@ -37355,6 +38255,42 @@ "created_at": "2026-06-28T23:53:28Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/90045", + "review_comments": [ + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 139, + "body": "### 🔵 Low · Legacy Update receives raw objInfo instead of updateWrapper\n\nLegacy Update now receives raw objInfo instead of &updateWrapper{upstream: objInfo, updated: obj} (old - line) — legacy re-invokes objInfo.UpdatedObject against its own old object instead of writing the exact storage result; divergence risk on drift (mode2:298 still uses the wrapper). This may be intentional if the legacy store is expected to re-read the object, but the divergence risk suggests a defect.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). dualwriter_mode3.go:139 passes raw objInfo to d.Legacy.Update with no guard and no wrapper, while mode2:298 passes &updateWrapper{upstream: objInfo, updated: updated}. The four legacy stores (playlist legacy_storage.go:147, folders legacy_storage.go:209, receivers legacy_storage.go:169, timeinterval legacy_storage.go:144) each call objInfo.UpdatedObject(ctx, ), so mode3 re-derives the legacy write from the lossy legacy old state instead of writing the exact storage result; divergence/failure on the legacy path is silent (metrics-only, mode3 has no Compare). Reachable via the operator-configured Mode3 dual-writer installed at builder/helper.go:184.\n_Impact: the legacy SQL mirror silently diverges from the unified store (or its update fails with only a metric recorded) whenever the legacy old object differs from the storage old object — which the documented lossy legacy conversion makes certain after the first mode3 create — so a later rollback to modes 2/1 (reads from legacy) surfaces stale/divergent data to users._\n_Queries: read_file(pkg/apiserver/rest/dualwriter_mode3.go) · discover(guards_before, {file: pkg/apiserver/rest/dualwriter_mode3.go, line: 139}) · read_file(pkg/apiserver/rest/dualwriter_mode2.go) · grep(\"updateWrapper\\\\{upstream: objInfo\") · read_file(pkg/registry/apis/playlist/legacy_storage.go) · read_file(pkg/registry/apis/folders/legacy_storage.go) · grep(\"UpdatedObject\\\\(\") · read_file(pkg/apiserver/rest/dualwriter.go) · grep(\"SetDualWritingMode|NewDualWriter\") + read_file(pkg/services/apiserver/builder/helper.go) · read_file(pkg/services/apiserver/config.go) · read_file(pkg/apiserver/rest/dualwriter_mode3_test.go)_\n\n> **Fix** — Restore the updateWrapper to pass the exact storage result to legacy.\n", + "created_at": "2026-08-21T22:34:54Z" + }, + { + "path": "pkg/tests/apis/playlist/playlist_test.go", + "line": 315, + "body": "### 🔵 Low · Mode 3 subtests race the async legacy write\n\nThe new mode-3 subtests reuse doPlaylistTests, which immediately reads the legacy API after k8s writes and asserts full visibility. The legacy write is an unsynchronized goroutine cancelled by the request lifecycle, so the read can observe missing or stale legacy state, causing flaky or consistently failing assertions.\n\nThis is a test reliability issue, not a product behavior defect.\n\n> **Fix** — Synchronize the legacy write with the test read, or adjust the test to tolerate eventual consistency.\n", + "created_at": "2026-08-21T22:34:54Z" + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 51, + "body": "### 🟠 High · Legacy write goroutines derive context from request, making 10s timeout moot\n\nThe four new goroutines (Create, Delete, Update, DeleteCollection) derive their context from the incoming request, so the 10s timeout is moot — the child context is done by the time the goroutine calls d.Legacy.*, and every legacy write fails immediately. The confirmed mechanism is: the write is aborted at request completion in the common case, and the 10s timeout does not govern the write's lifetime (the request lifetime does). This may be intentional if the legacy write is meant to be best-effort and tied to the request, but the regression from synchronous to fire-and-forget makes the write race against request teardown.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). dualwriter_mode3.go:50-57,108-114,134-141,161-167: all four write paths spawn goroutines deriving ctx from the request-derived parameter via context.WithTimeoutCause(ctx,10s,...) and return immediately with no synchronization; dualwriter.go:117-119 + helper.go:164-185 + config.go:59-61 wire Mode3 into real API groups (playlist register.go:127-131, dashboard register.go:174, folders register.go:145, alerting storage.go:75/79); no reconcil/backfill exists; tests use context.Background()\n_Impact: Legacy write is aborted at request teardown in the common case; the error is discarded (metric only), so the legacy backup store silently diverges from Storage, violating the documented 'write to both' contract with no reconciliation to repair it — operators relying on legacy data during migration/rollback see missing or diverged data._\n_Queries: read_file(dualwriter_mode3.go) · read_file(dualwriter.go) · read_file(dualwriter_mode1.go) · read_file(dualwriter_mode2.go) · read_file(dualwriter_mode3_test.go) · grep(NewDualWriter|SetDualWritingMode|Mode3, pkg/apiserver) · grep(DualWriteBuilder|dualWriteBuilder|unifiedStorage, pkg) · read_file(helper.go:130) · read_file(config.go) · grep(reconcil|backfill|syncLegacy, pkg) · grep(WaitGroup|sync.|chan|select|<-ctx.Done, pkg/apiserver/rest)_\n\n> **Fix** — Parent the goroutine on a request-independent context, e.g. context.WithTimeoutCause(context.WithoutCancel(ctx), time.Second*10, …).\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Mode 3 legacy writes are cancelled by request lifecycle**\n\nAll four write paths spawn a goroutine deriving its context from the request-scoped ctx, then return immediately without synchronizing. The request context is cancelled when ServeHTTP returns, so the legacy write is aborted before or during execution, silently diverging the legacy backup.\n\nThis may be intentional if the legacy write is best-effort, but the documented contract says 'write to both'.This comment also covers: Async legacy writes derive from the parent request context\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR90045__20260821/blob/e369f24665ec70e1e3700f457d25ef83301d931a/pkg/apiserver/rest/dualwriter_mode3.go#L48-L52\n\n
\n", + "created_at": "2026-08-21T22:34:54Z" + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 106, + "body": "### 🟡 Medium · Delete success path passes object name as kind label\n\nDelete success path passes the object name as the kind label; every other call site in the file passes options.Kind, including Delete's own error branch at :103. This mislabels metrics, causing wrong data in monitoring. It may be intentional if the label is considered cosmetic, but the inconsistency with sibling branches suggests a defect.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). dualwriter_mode3.go:106 calls d.recordStorageDuration(false, mode3Str, name, method, startStorage); metrics.go:59-62 maps the third parameter to m.storage.WithLabelValues(strconv.FormatBool(isError), mode, name, method); metrics.go:23 declares labels as []string{\"is_error\", \"mode\", \"kind\", \"method\"}, so name at :106 becomes the kind label. All 23 recordStorageDuration call sites across dualwriter_mode1.go (7), dualwriter_mode2.go (8), dualwriter_mode3.go (8) pass options.Kind except dualwriter_mode3.go:106 which passes name; the error arm at dualwriter_mode3.go:103 passes options.Kind. helper.go:163-184 installs grafanarest.NewDualWriter as storage when StorageType != Legacy and mode transitions to Mode3; dualwriter.go:119 returns newDualWriterMode3; DualWriterMode3 satisfies the Storage interface so DELETE handlers invoke DualWriterMode3.Delete. Mode 3 is operator-selectable via unified_storage_mode ini section (config.go:38,59-60). The mode3 test-file grep found zero assertions on metric labels.\n_Impact: The kind label of grafana_dual_writer_storage_duration_seconds receives per-object names on the delete-success path, corrupting label semantics and causing one new time series per deleted object name (cardinality explosion), yielding wrong telemetry for operators monitoring the dual-writer migration._\n_Queries: read_file path=pkg/apiserver/rest/dualwriter_mode3.go · grep pattern=recordStorageDuration path=pkg/apiserver/rest · read_file path=pkg/apiserver/rest/metrics.go · grep pattern=newDualWriterMode3|DualWriterMode3|DualWriterMode\\{3\\}|NewDualWriter path=pkg · grep pattern=dualWriterMetrics|DualWriterStorageDuration|recordOutcome path=pkg · read_file path=pkg/services/apiserver/builder/helper.go offset=130 limit=80 · grep pattern=DualWriter|GetMode|dual.?writer|DualWriterMode path=pkg/services/apiserver · grep pattern=kind|name|label|DualWriterStorageDuration|recordStorage path=pkg/apiserver/rest/dualwriter_mode3_test.go · grep pattern=unifiedStorageModeCfg|DualWriterMode|MustInt path=pkg/services/apiserver/config.go_\n\n> **Fix** — Pass options.Kind instead of name in the Delete success path.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Delete success arm passes name in the kind slot of recordStorageDuration**\n\nThe success arm passes name in the kind slot: d.recordStorageDuration(false, mode3Str, name, method, startStorage). The error arm at 103 passes options.Kind, and every other call site in modes 1–3 passes options.Kind.\n\nThe object's name lands in the kind label, corrupting both semantics and label cardinality (one series per object name). This is a telemetry corruption.\n\nIt might be intentional if the author wanted to track per-object metrics, but that contradicts the established pattern across all other call sites.\n\n**2. Mode 3 Delete success passes object name as kind label**\n\nThe third positional argument to the recorder, which is the kind-label slot and is options.Kind at every other call site, receives the object name. This mislabels the series and explodes kind-cardinality per object name, corrupting dashboards.\n\nIt may be intentional if the label is meant to be the object name, but the error path at line 103 uses options.Kind.\n\n**3. Delete success path passes name as kind label in storage metric**\n\nOn the Delete success path, the code passes the object name in the kind-label position of the storage metric, corrupting the kind label with the object name. Every other call site passes options.Kind, including line 103 in the same function, so this is an inconsistency that produces wrong metric labels.\n\nThis may be intentional if the metric is meant to track per-object operations, but the sibling call sites use the kind.This comment also covers: Metrics labeling bug: Delete success path passes name as kind label instead of options.Kind\n\n
\n", + "created_at": "2026-08-21T22:34:54Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Legacy Update receives raw objInfo instead of updateWrapper | `pkg/apiserver/rest/dualwriter_mode3.go:139` |\n| 🔵 Low | Mode 3 subtests race the async legacy write | `pkg/tests/apis/playlist/playlist_test.go:315` |\n| 🟠 High | Legacy write goroutines derive context from request, making 10s timeout moot | `pkg/apiserver/rest/dualwriter_mode3.go:51` |\n| 🟡 Medium | Delete success path passes object name as kind label | `pkg/apiserver/rest/dualwriter_mode3.go:106` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -6.4 |\n| Test Coverage | 73 (B-) | 100 (A+) | +26.8 |\n| **Overall** | **87 (A-)** | **91 (A)** | **+3.5** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:54Z" + } + ] } ] }, @@ -38856,6 +39792,36 @@ "created_at": "2026-06-28T23:38:08Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/106778", + "review_comments": [ + { + "path": "public/app/features/alerting/unified/hooks/useAbilities.ts", + "line": 309, + "body": "### 🔵 Low · Grafana-ability branch overrides datasource-scoped check for cloud rules (Duplicate leak)\n\nFor non-Grafana promRules, skipToken leads to useAllGrafanaPromRuleAbilities(undefined), which returns AlwaysSupported for duplicate and grants Duplicate based on Grafana-managed permissions (AlertingRuleCreate) instead of external datasource permissions. The AlertRuleMenu ORs this with the ruler branch, so any user with AlertingRuleCreate sees Duplicate even when the datasource has no ruler API or lacks external create permission.\n\nClicking navigates to a clone flow that fails. This might be intentional if the grafana branch is meant to be a fallback, but it leaks a grant from the wrong permission source.\n\n> **Fix** — Guard the grafana branch to only apply when the rule is actually Grafana-managed, or require ruler availability for cloud rules.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Pause and Delete computed as available in new list but can never render (grafana abilities dead for their intended consumer)**\n\nThe new Grafana list passes only promRule to RuleActionsButtons, so rulerRule is undefined. The grafana branch makes canPause/canDelete true for editable rules, but both menu items additionally require rulerRule (rulerRuleType.grafana.rule(undefined) is false; canDelete && rulerRule is false).\n\nThus Pause and Delete disappear from the dropdown in the new list, while the grafana pause/delete abilities are unreachable. This might be intentional if dropping these actions from the new list is deliberate, but then the grafana computation is confirmed dead code.\n\n**2. Silence menu item in new list is a visible no-op**\n\nIn the new list (rulerRule undefined), canSilence is true for Grafana alerting rules via the grafana branch, so the Silence item renders. However, the click sets showSilenceDrawer, but the drawer only renders when rulerRuleType.grafana.alertingRule(rule) is truthy — with rule undefined it never renders.\n\nClicking Silence does nothing. This might be intentional if the silence action is meant to be hidden, but the menu item is visible, making it a no-op.\n\n
\n", + "created_at": "2026-08-21T22:34:51Z" + }, + { + "path": "public/app/features/alerting/unified/hooks/useAbilities.ts", + "line": 302, + "body": "### 🔵 Low · useAllGrafanaPromRuleAbilities gates Pause/Export/Restore/DeletePermanently on isAlertingRule\n\nThe new hook gates ModifyExport, Pause, Restore, and DeletePermanently on isAlertingRule, which is false for Grafana recording rules. The replaced code used isGrafanaManagedAlertRule (true for both alerting and recording rules), so recording rules lose Export/Pause/Restore/DeletePermanently in the prom-only list view.\n\nThe sibling hook useAllRulerRuleAbilities kept the recording-inclusive check, creating an asymmetry within the same PR. This may be intentional if recording rules should not be managed in the list view, but the asymmetry and the DTO carrying isPaused/provenance suggest an oversight.\n\n> **Fix** — Gate the four actions on prometheusRuleType.grafana.rule(rule) instead, keeping isAlertingRule for Silence.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. grafana.rule to grafana.alertingRule narrowing removes Pause/Restore/Export/DeletePermanently for Grafana recording rules**\n\nThe old predicate isGrafanaManagedAlertRule = rulerRuleType.grafana.rule(rule) was true for both Grafana alerting and recording rules, but the new prometheusRuleType.grafana.alertingRule requires rule.type === PromRuleType.Alerting, so Grafana-managed recording rules lose Pause/Restore/Export/DeletePermanently support. The comment 'All GrafanaPromRuleDTO rules are Grafana-managed by definition' suggests the intended predicate was the Grafana-managed check, not the alerting-only subset.\n\nThis may be intentional if recording rules are meant to be immutable in the new list, but no such intent is documented.This comment also covers: Ability matrix drift: ModifyExport/Pause/Restore/DeletePermanently differ for Grafana recording rules\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR106778__20260821/blob/8df850371034b73dc4dd9908cc30fa09f12a1f97/public/app/features/alerting/unified/hooks/useAbilities.ts#L244-L248\n\n
\n", + "created_at": "2026-08-21T22:34:51Z" + }, + { + "path": "public/app/features/alerting/unified/hooks/useAbilities.ts", + "line": 245, + "body": "### 🟡 Medium · Federated-rule immutability guard dropped by the useAllAlertRuleAbilities refactor\n\nThe old useAllAlertRuleAbilities computed isFederated from isFederatedRuleGroup(rule.group) and fed it into immutableRule, so rules in Mimir cross-tenant federated groups could not be removed or edited. The new deprecated-but-still-used hook hardcodes isFederated = false, so every CombinedRule consumer now reports federated cloud rules as editable/pausable/deletable.\n\nThis is reachable because federated groups are a documented, real feature. It may be intentional if the new list view is meant to allow such actions, but the deleted guard existed precisely to prevent this.\n\n> **Fix** — Restore the federated guard in useAllRulerRuleAbilities by uncommenting the isFederatedRuleGroup call and using its result for immutableRule.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Federated-rule immutability silently dropped in useAllAlertRuleAbilities**\n\nThe old code computed isFederated via isFederatedRuleGroup(rule.group) and treated federated Mimir rules as immutable. The new deprecated wrapper delegates to useAllRulerRuleAbilities which hardcodes isFederated = false with a TODO. Federated rules are now considered mutable, so Update/Delete/Pause become available to users with matching permissions where previously blocked.\n\nThis removes a deliberate guard on changed lines; the server may still reject, but destructive UI actions are exposed on protected rules.\n\n**2. federated-rule immutability guard dropped in useAllRulerRuleAbilities**\n\nThe isFederated check was replaced with a hardcoded false, so Mimir federated-rule-group members are no longer marked immutable. Update/Delete/Pause become supported for rules the previous code deliberately made read-only.\n\nThe TODO shows awareness, but the removal is a real behavior change on an edit/delete path for federated cloud rules, and it contradicts the comment at 298-299 that only justifies isFederated = false for Grafana-managed rules.\n\n**3. Federated-rule immutability check dropped in useAllAlertRuleAbilities refactor**\n\nThe refactor routes useAllAlertRuleAbilities to useAllRulerRuleAbilities, which hardcodes isFederated = false with a TODO, dropping the previous isFederatedRuleGroup(rule.group) check. Federated Mimir cross-tenant cloud rules lose their immutableRule protection, making Update/Delete/DeletePermanently available where they previously were not.\n\nThis widens destructive predicate scope; it might be intentional if federated rules are no longer considered immutable, but the TODO suggests otherwise.This comment also covers: Federated-rule immutability guard dropped from useAllAlertRuleAbilities (delegation regression)\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/grafana__grafana__corbulo__PR106778__20260821/blob/8df850371034b73dc4dd9908cc30fa09f12a1f97/public/app/features/alerting/unified/hooks/useAbilities.ts#L226-L230\n\n
\n", + "created_at": "2026-08-21T22:34:51Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Grafana-ability branch overrides datasource-scoped check for cloud rules (Duplicate leak) | `public/app/features/alerting/unified/hooks/useAbilities.ts:309` |\n| 🔵 Low | useAllGrafanaPromRuleAbilities gates Pause/Export/Restore/DeletePermanently on isAlertingRule | `public/app/features/alerting/unified/hooks/useAbilities.ts:302` |\n| 🟡 Medium | Federated-rule immutability guard dropped by the useAllAlertRuleAbilities refactor | `public/app/features/alerting/unified/hooks/useAbilities.ts:245` |\n| 🟡 Medium | Silence action is a dead-end in the new list: drawer guarded on a rule the list no longer loads | `public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx:104` (not in the diff) |\n\n---\n\n### 🟡 Medium · Silence action is a dead-end in the new list: drawer guarded on a rule the list no longer loads\n\n`public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx:104`\n\nIn the new list the component is invoked with only `promRule`; `rule` (RulerRuleDTO) is `undefined` — exactly the \"removed all requests to the ruler API in the list view\" case the file's own comment describes. `AlertRuleMenu` offers Silence based on the Prom-based abilities (`useGrafanaPromRuleAbilities` returns `[silenceSupported, canSilenceInFolder && isAlertingRule]`), so a user with silence permission can click it → `showSilenceDrawer` becomes `true` — but the drawer render is guarded by `rulerRuleType.grafana.alertingRule(rule)` with `rule === undefined`, which is false, and the drawer itself requires `rulerRule={rule}` (a `RulerRuleDTO`).\n\nResult: clicking **Silence** in the new list sets state and renders nothing. If the intent is to support silencing from the new list, the guard must accept the `GrafanaPromRuleDTO`; if not, the Silence ability for the prom path is wrongly reported as supported.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -1.4 |\n| **Overall** | **85 (A-)** | **85 (A-)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:50Z" + } + ] } ] }, @@ -39992,6 +40958,18 @@ "created_at": "2026-06-28T23:15:44Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/107534", + "review_comments": [ + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\nNo findings.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -0.5 |\n| **Overall** | **93 (A)** | **93 (A)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:33:18Z" + } + ] } ] }, @@ -41466,6 +42444,36 @@ "created_at": "2026-06-28T22:20:35Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/79265", + "review_comments": [ + { + "path": "pkg/services/anonymous/anonimpl/client.go", + "line": 44, + "body": "### 🔵 Low · Synchronous untimed unrecovered DB work on anonymous auth hot path\n\nTagDevice now runs synchronously on the auth path without a timeout or panic recovery, so a DB stall blocks anonymous login and a panic propagates into auth. This is a deliberate trade-off but a robustness/perf regression.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). Synchronous call, no timeout: client.go:44 `a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI)` is called inline inside `Anonymous.Authenticate` (no goroutine, no `context.WithTimeout`). Resolved chain: impl.go:118 `TagDevice` → impl.go:80 `tagDeviceUI` → impl.go:93 `a.anonStore.CreateOrUpdateDevice(ctx, device)` → database.go:105-155 `INSERT … ON CONFLICT …` via `WithDbSession(ctx, …)`. The ctx is `reqContext.Req.Context()` (contexthandler.go:116), an HTTP request context with no deadline (cancelled only on client disconnect). No timeout exists anywhere in this chain. Reachable: contexthandler.go:116 invokes `h.authnService.Authenticate(reqContext.Req.Context(), …)` for every API request; authnimpl/service.go:197-228 iterates registered clients and calls `c.Authenticate(ctx, r)` at line 228. The anonymous client is registered at impl.go:56-58 when `cfg.AnonymousEnabled` (setting.go:1650, supported `auth.anonymous.enabled` config). Trigger: a DB stall blocks the untimed synchronous call, hanging anonymous auth for cache-missing requests (cache at impl.go:83-87 suppresses DB only on hit, 29-min TTL). DB *errors* are caught at client.go:45-50 (log-and-continue except `ErrDeviceLimitReached`); a *stall* is not. Panic nuance: `middleware.Recovery` IS registered (http_server.go:659; recovery.go:107-113), so a panic is recovered at the HTTP layer → the request fails with a 500 rather than crashing the server. This refutes only the claim's \"no panic recovery\" wording, not its core harm.\n_Impact: During a DB stall, untimed synchronous `CreateOrUpdateDevice` on the anonymous auth path hangs the per-request auth middleware, blocking anonymous logins for all cache-missing requests until client disconnect or driver timeout; a panic in the path fails the request with a 500 — availability damage without an attacker._\n_Queries: read_file(path=\"pkg/services/anonymous/anonimpl/client.go\") · grep(pattern=\"TagDevice\") · read_file(path=\"pkg/services/anonymous/anonimpl/impl.go\") · read_file(path=\"pkg/services/anonymous/anonimpl/anonstore/database.go\", offset=80, limit=80) · read_file(path=\"pkg/services/authn/authnimpl/service.go\", offset=150, limit=90) · grep(pattern=\"authnService\\.Authenticate|authn\\.Service.*Authenticate|service\\.Authenticate\\(\", glob=\"*.go\") · read_file(path=\"pkg/services/contexthandler/contexthandler.go\", offset=80, limit=60) · grep(pattern=\"middleware\\.Recovery|Recovery\\(cfg\\)\", glob=\"*.go\") · read_file(path=\"pkg/middleware/recovery.go\", offset=100, limit=30) · read_file(path=\"pkg/api/http_server.go\", offset=630, limit=60) · grep(pattern=\"AnonymousEnabled\", path=\"pkg/setting\")_\n\n> **Fix** — Restore a detached goroutine with timeout and recover, or add a timeout and recover around the synchronous call.\n", + "created_at": "2026-08-21T22:34:38Z" + }, + { + "path": "pkg/services/anonymous/anonimpl/anonstore/database.go", + "line": 95, + "body": "### 🔵 Low · False lockout of returning devices idle 31–60 days\n\nRows survive cleanup for 61 days but updateDevice's match window is only 30 days. A device idle 31-60 days still has a row, yet when count >= deviceLimit its return hits rowsAffected == 0, causing ErrDeviceLimitReached and denying anonymous auth.\n\nThis may be intentional to expire stale devices, but the asymmetry between row lifetime and match window causes spurious lockouts of legitimate returning devices.This comment also covers: Stale device rejection\n\n> **Fix** — Align the updateDevice match window with the row retention period (keepFor), or fall back to INSERT when rowsAffected == 0.\n", + "created_at": "2026-08-21T22:34:38Z" + }, + { + "path": "pkg/services/anonymous/anonimpl/client.go", + "line": 45, + "body": "### 🔵 Low · Anonymous.Authenticate — device-limit lockout DoS\n\nAn unauthenticated attacker can forge X-Grafana-Device-Id headers to exhaust the device limit, causing all new anonymous devices to be denied authentication instance-globally. The lockout persists up to 30 days.\n\nThis may be intentional as a rate-limiting mechanism, but the denial of service to legitimate new anonymous visitors is a real consequence.\n\n> **Fix** — Consider rate-limiting device creation per IP, or returning a softer failure (e.g., allow anonymous access without device tagging) when the limit is reached.\n", + "created_at": "2026-08-21T22:34:38Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Synchronous untimed unrecovered DB work on anonymous auth hot path | `pkg/services/anonymous/anonimpl/client.go:44` |\n| 🔵 Low | False lockout of returning devices idle 31–60 days | `pkg/services/anonymous/anonimpl/anonstore/database.go:95` |\n| 🔵 Low | Anonymous.Authenticate — device-limit lockout DoS | `pkg/services/anonymous/anonimpl/client.go:45` |\n| 🟡 Medium | Device-limit bypass via local cache | `pkg/services/anonymous/anonimpl/impl.go` (not in the diff) |\n\n---\n\n### 🟡 Medium · Device-limit bypass via local cache\n\n`pkg/services/anonymous/anonimpl/impl.go`\n\nIn tagDeviceUI, the local cache is set before the store operation. If CreateOrUpdateDevice returns ErrDeviceLimitReached, the cache entry persists, causing subsequent requests to short-circuit and bypass the device limit enforcement within the cache window.This comment also covers: Cache-set-before-write causes intermittent anonymous auth failures and bypasses device limitThis comment also covers: Limit enforcement intermittent: same device gets 401 then 200 due to cache-before-store ordering\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 67 (C+) | 67 (C+) | -0.0 |\n| Runtime | 100 (A+) | 99 (A+) | -0.8 |\n| Consistency | 89 (A-) | 89 (A-) | -0.0 |\n| **Overall** | **87 (A-)** | **86 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:38Z" + } + ] } ] }, @@ -42570,6 +43578,24 @@ "created_at": "2026-06-28T23:57:17Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/9", + "review_comments": [ + { + "path": "lib/freedom_patches/translate_accelerator.rb", + "line": 63, + "body": "### 🟡 Medium · I18n.fallbacks assignment not restored after reload!\n\nI18n.fallbacks = FallbackLocaleList.new is one-shot at boot; the accelerator's reload! delegates to the original I18n.reload!, and if the gem resets @fallbacks, the next request 500s at the new application_controller.rb:159 (ensure_loaded! missing on a plain I18n::Locale::Fallbacks). Confidence capped by unreadable gem semantics.\n\nIt might be intentional if the gem does not reset @fallbacks on reload, but the risk of a 500 on the request path is real.\n\n> **Fix** — Re-assign I18n.fallbacks after reload! or ensure the gem preserves the custom fallback list.\n", + "created_at": "2026-08-21T22:34:31Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | I18n.fallbacks assignment not restored after reload! | `lib/freedom_patches/translate_accelerator.rb:63` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 37 (F) | 37 (F) | -0.0 |\n| Runtime | 100 (A+) | 99 (A+) | -1.2 |\n| **Overall** | **71 (B-)** | **70 (B-)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:31Z" + } + ] } ] }, @@ -43733,6 +44759,30 @@ "created_at": "2026-06-28T22:55:56Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/grafana/grafana/pull/76186", + "review_comments": [ + { + "path": "pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go", + "line": 58, + "body": "### 🔵 Low · traceID dropped from plugin request completion log after refactor\n\nThe refactored logging path no longer emits traceID in the 'Plugin Request Completed' log line. Before the change, the code extracted traceID from context and added it to logParams; after the change, logParams contains only status, duration, eventName, time_before_plugin_request, and error, and the replacement contextual attributes (endpoint, pluginId, dsName, dsUID, uname) do not include traceID. This is a lossy transform — ops lose trace correlation on plugin request logs. It might be intentional if the infra-log FromContext auto-injects traceID, but that behavior cannot be confirmed from this checkout, and the old code manually extracted it, suggesting it was not automatic.\n\n**Advisory** — the proof pass could not settle triggerability and harm either way, and the finding arrived below the confirmation band. Nothing here was disproven; it is reported on its investigation evidence alone and will not block a merge. The emitted line at logger_middleware.go:58 is `m.logger.FromContext(ctx).Info(\"Plugin Request Completed\", logParams...)`, and `FromContext` does not return a bare logger. pkg/infra/log/log.go:203-217 (`ConcreteLogger.FromContext`) iterates all registered `ctxLogProviders` and merges their attributes into the returned logger. One registered provider is pkg/infra/tracing/tracing.go:91-97, which returns `[\"traceID\", TraceIDFromContext(ctx, false)]` whenever the context carries a valid span trace ID (tracing.go:113-120 reads `trace.SpanContextFromContext`; `requireSampled=false` includes unsampled traces). That provider is registered at runtime because `tracing.ProvideService` is wired in pkg/server/wire.go:249, and the span reaches the logger because TracingMiddleware is outermost in the production chain (pluginsintegration.go:158-161). Therefore the 'Plugin Request Completed' line automatically includes `traceID` whenever a trace is active — the exact condition under which the old manual extraction would have logged it.\n_Impact: n/a — the claimed harm (lost trace correlation on plugin request logs) does not occur; traceID is auto-injected by the FromContext provider mechanism, so the refactor is lossless with respect to traceID._\n_Queries: read_file(pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go) · read_file(pkg/plugins/log/logger.go) · grep(\"func.*FromContext\", pkg/infra/log) · read_file(pkg/infra/log/log.go:150-270) · grep(\"ctxLogProviders|ContextLogProvider|RegisterContext\", pkg/infra/log) · read_file(pkg/infra/log/log.go:1-150) · grep(\"logParamsContextKey\") · read_file(pkg/infra/log/log.go:270-310) · grep(\"WithContextualAttributes|WithContextualAttrs\") · read_file(pkg/services/pluginsintegration/clientmiddleware/contextual_logger_middleware.go) · grep(\"traceID|trace_id\", pkg/services/pluginsintegration) · grep(\"RegisterContextualLogProvider\") · read_file(pkg/infra/tracing/tracing.go:70-120) · grep(\"tracing\\.ProvideService|ProvideService.*tracing\") · grep(\"NewLoggerMiddleware|NewContextualLoggerMiddleware|NewTracingMiddleware\", pkg/services/pluginsintegration) · read_file(pkg/services/pluginsintegration/pluginsintegration.go:145-185)_\n\n> **Fix** — Re-add traceID extraction from context in the logger middleware, or ensure the contextual logger includes traceID in its attributes.\n", + "created_at": "2026-08-21T22:34:46Z" + }, + { + "path": "pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go", + "line": 26, + "body": "### 🔵 Low · MetricsMiddleware fails plugin requests on metric bookkeeping lookup miss\n\nA registry lookup miss for the plugin ID causes the middleware to return plugins.ErrPluginNotRegistered before invoking the plugin function, failing the entire request. This is a bookkeeping miss for the target metric label, not a plugin failure.\n\nIt may be intentional as a carry-over from the renamed instrumentation middleware, and in the standard path the base client would fail downstream with the same sentinel anyway. However, since this middleware sits before CachingMiddleware, a registry miss can block a cache hit that would otherwise be served without touching the plugin.\n\n> **Fix** — Degrade to a default label (e.g., TargetUnknown) on registry miss instead of failing the request, allowing the plugin call and cache hits to proceed.\n", + "created_at": "2026-08-21T22:34:46Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | traceID dropped from plugin request completion log after refactor | `pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go:58` |\n| 🔵 Low | MetricsMiddleware fails plugin requests on metric bookkeeping lookup miss | `pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go:26` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 100 (A+) | -0.4 |\n| Consistency | 96 (A+) | 96 (A+) | -0.0 |\n| **Overall** | **87 (A-)** | **87 (A-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:46Z" + } + ] } ] }, @@ -45852,6 +46902,48 @@ "created_at": "2026-06-28T23:54:24Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/10", + "review_comments": [ + { + "path": "app/controllers/admin/embeddable_hosts_controller.rb", + "line": 10, + "body": "### 🔵 Low · Admin::EmbeddableHostsController create/update/destroy nil-record deref and unguarded params\n\nupdate and destroy use where(id: params[:id]).first which returns nil for a stale/missing id, then pass nil to save_host which dereferences host.host, raising NoMethodError; destroy similarly calls host.destroy on nil. Also save_host dereferences params[:embeddable_host][:host] and [:category_id] with no presence check, raising NoMethodError on nil[:host] for malformed requests. No rescue_from ActiveRecord::RecordNotFound exists, so requests 500 instead of 404. Guarded only by ensure_staff, reachable by any staff member. Could be intentional as a low-severity UX issue, but the nil deref is a real crash.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). app/controllers/admin/embeddable_hosts_controller.rb:10 — `host = EmbeddableHost.where(id: params[:id]).first` returns **nil** for any id with no matching row; line 11 passes it into `save_host`, whose line 23 `host.host = params[:embeddable_host][:host]` dereferences the nil receiver → `NoMethodError: undefined method 'host=' for nil`. No guard exists between lines 10 and 11. Line 15–16 — `destroy` does `host.destroy` on the nil from `.first` → `NoMethodError: undefined method 'destroy' for nil`. No guard. Line 23–24 — `save_host` dereferences `params[:embeddable_host][:host]` and `[:category_id]` with no presence check; a request omitting the `embeddable_host` key makes `params[:embeddable_host]` nil → `nil[:host]` NoMethodError. Same path is hit by `create` (line 6). ApplicationController (lines 73–118) rescues only RenderEmpty, RateLimiter::LimitExceeded, PG::ReadOnlySqlTransaction, Discourse::NotLoggedIn/NotFound/InvalidAccess/ReadOnly. `grep(RecordNotFound, app)` = 0 hits: **no** `rescue_from ActiveRecord::RecordNotFound`, **no** NoMethodError/Exception catch-all — the exception escapes as HTTP 500. Route reachability: config/routes.rb:153 `resources :embeddable_hosts` sits inside `namespace :admin` (line 42, `StaffConstraint`), so PUT/DELETE `/admin/embeddable_hosts/:id` resolve to `update`/`destroy`. Sole gate is `before_filter :ensure_logged_in, :ensure_staff` (admin_controller.rb:3–4, controller line 3). The `:id` value is caller-supplied and unvalidated; the `embeddable_host` param hash is caller-supplied and unvalidated.\n_Impact: Any staff member issuing PUT/DELETE `/admin/embeddable_hosts/:id` with a stale/nonexistent id, or POST/PUT without the `embeddable_host` params, crashes the request with NoMethodError → HTTP 500 error page plus exception noise, instead of a clean 404 — low severity, repeatable, real._\n_Queries: read_file(app/controllers/admin/embeddable_hosts_controller.rb) · grep(pattern=rescue_from, path=app/controllers) · grep(pattern=RecordNotFound, path=app) · read_file(app/controllers/application_controller.rb, offset=85, limit=40) · read_file(app/controllers/admin/admin_controller.rb) · read_file(app/models/embeddable_host.rb) · grep(pattern=embeddable_hosts, path=config/routes.rb) · read_file(config/routes.rb, offset=145, limit=15) · grep(pattern=namespace|scope, path=config/routes.rb) · grep(pattern=update|destroy|create, path=spec/controllers/admin/embeddable_hosts_controller_spec.rb)_\n\n> **Fix** — Use find_by(id: params[:id]) and return 404 if nil; add presence checks for params[:embeddable_host] before dereferencing.\n", + "created_at": "2026-08-21T22:34:34Z" + }, + { + "path": "db/migrate/20150818190757_create_embeddable_hosts.rb", + "line": 25, + "body": "### 🟠 High · Migration inserts raw old-setting values bypassing model normalization — previously allowed hosts become dead after upgrade\n\nThe migration copies old embeddable_hosts site-setting lines verbatim into embeddable_hosts.host without scheme strip, path strip, or downcase, bypassing the model's before_validation normalization. Old values were full URLs (the old matcher parsed each line with URI(h).host).\n\nAfter migration, rows containing http://example.com or http://example.com/blog are compared via lower(host) = 'example.com' and never match, so previously allowed hosts become dead: ensure_embeddable raises InvalidAccess and TopicRetriever#invalid_host? blocks retrieval. This may be intentional if the old setting format was already bare hosts, but the old matcher's URI(h).host shape suggests otherwise.\n\n> **Fix** — Normalize migrated values in the migration itself (strip scheme, path, downcase) before inserting.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Raw SQL interpolation of legacy setting data in INSERT**\n\nThe host value h is interpolated unparameterized into SQL: VALUES ('#{h}', ...). A single quote in any legacy embeddable_hosts value causes a syntax error that aborts the migration.\n\nSince this is a one-way data migration (line 31 deletes the source setting), a crash here also destroys the old data. The likelihood is low because hosts are normally bare domains, but the mechanism is real on this changed line.\n\n
\n", + "created_at": "2026-08-21T22:34:34Z" + }, + { + "path": "app/models/topic.rb", + "line": 869, + "body": "### 🔵 Low · Dropped embeddable_hosts.present? gate in expandable_first_post? with no replacement\n\nThe PR removed the only host-configuration dependency from expandable_first_post? while deleting the embeddable_hosts site setting and migrating hosts to the EmbeddableHost table, but added no substitute gate (e.g. EmbeddableHost.exists?).\n\nThe method now returns true for any truncated embed topic regardless of host configuration, changing the previously-suppressed state (blank host config → no expand affordance) to expanded. This drives the public expand-embed affordance via TopicViewSerializer#include_expandable_first_post? → GET /posts/:id/expand-embed.\n\nThe mechanism is certain from the diff; the defect-status turns on whether the relaxation was intended, which the diff gives no sign of (no test asserts the new semantics; the spec named for the old gate passes vacuously).\n\n> **Fix** — Restore the configuration gate against the new model, e.g. SiteSetting.embed_truncate? && EmbeddableHost.exists? && has_topic_embed? (or per-topic host check at import), if the pre-change semantics were to be preserved.\n", + "created_at": "2026-08-21T22:34:34Z" + }, + { + "path": "app/models/embeddable_host.rb", + "line": 6, + "body": "### 🟠 High · sub! on nil host raises NoMethodError before validation\n\nself.host.sub!(...) is called on a nil host before validation runs. If a host parameter is missing or blank (e.g., via Admin::EmbeddableHostsController#save_host or a direct EmbeddableHost.new.save), this raises NoMethodError instead of producing a validation error.\n\nThis is a confirmed defect that crashes the save path. It may be intentional if callers are guaranteed to provide a host, but the nil-guard is absent and the crash is reachable.\n\n> **Fix** — Guard against nil host before calling sub!, e.g., self.host&.sub!(...) or validate presence before the sub! call.\n", + "created_at": "2026-08-21T22:34:35Z" + }, + { + "path": "test/javascripts/models/store-test.js.es6", + "line": 111, + "body": "### 🔵 Low · Test requests fruit 2 but asserts fruit 1 data, masked by id-ignoring mock\n\nThe changed line requests fruit 2 (banana, color_ids [3]) but the new assertions describe apple's colors [1,2]. The test only passes because the mock always returns fruits[0] regardless of the requested id. This makes the test brittle and the id change either a no-op or an unachieved intent. It might be intentional if the test author intended to keep asserting apple's data while changing the id for other reasons, but the mismatch is a real inconsistency on changed lines.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). test/javascripts/models/store-test.js.es6:111 — store.find('fruit', 2) requests id 2. test/javascripts/helpers/create-pretender.js.es6:42-44 — fruit 1 = apple (color_ids: [1,2]), fruit 2 = banana (color_ids: [3]). test/javascripts/helpers/create-pretender.js.es6:226-228 — /fruits/:id handler returns fruits[0] unconditionally; request.params.id is never read. Contrast with /widgets/:widget_id at line 236, which does resolve the id — so the id-ignoring fruit mock is a real anomaly, not the house style. test/javascripts/models/store-test.js.es6:115-117 — asserts fruitCols.length === 2 and color ids [1, 2] — apple's data; banana would yield length 1, color id 3. test/javascripts/test_helper.js:45 (require_tree .) loads the test; test_helper.js:89 (createPretendServer()) installs the mock per test — the test executes in the QUnit suite and passes only because the mock returns fruits[0] for any id.\n_Impact: The id argument at store-test.js.es6:111 is a no-op against the id-ignoring mock (create-pretender.js.es6:227); per-id find regressions are masked and the assertion set describes the record never requested._\n_Queries: read_file(test/javascripts/models/store-test.js.es6, offset=80, limit=80) · read_file(test/javascripts/models/store-test.js.es6, offset=1, limit=80) · grep(pattern=\"fruit\", path=test/javascripts/models/store-test.js.es6) · read_file(test/javascripts/helpers/create-store.js.es6) · read_file(test/javascripts/helpers/create-pretender.js.es6, offset=30, limit=80) · read_file(test/javascripts/helpers/create-pretender.js.es6, offset=200, limit=60) · grep(pattern=\"fruit|Fruit\", path=test) · read_file(test/javascripts/test_helper.js)_\n\n> **Fix** — Keep find('fruit', 1) to match the assertions, or assert banana's shape (length === 1, [0].id === 3) and make the mock honor the id.\n", + "created_at": "2026-08-21T22:34:35Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Admin::EmbeddableHostsController create/update/destroy nil-record deref and unguarded params | `app/controllers/admin/embeddable_hosts_controller.rb:10` |\n| 🟠 High | Migration inserts raw old-setting values bypassing model normalization — previously allowed hosts become dead after upgrade | `db/migrate/20150818190757_create_embeddable_hosts.rb:25` |\n| 🔵 Low | Dropped embeddable_hosts.present? gate in expandable_first_post? with no replacement | `app/models/topic.rb:869` |\n| 🟠 High | sub! on nil host raises NoMethodError before validation | `app/models/embeddable_host.rb:6` |\n| 🟠 High | Subdomain matching regression in embeddable host validation | `app/models/embeddable_host.rb` (not in the diff) |\n| 🟡 Medium | Feed-imported topics lose the default category assignment | `app/models/topic_embed.rb` (not in the diff) |\n| 🔵 Low | Test requests fruit 2 but asserts fruit 1 data, masked by id-ignoring mock | `test/javascripts/models/store-test.js.es6:111` |\n\n---\n\n### 🟠 High · Subdomain matching regression in embeddable host validation\n\n`app/models/embeddable_host.rb`\n\nThe `record_for_host` method uses an exact-match query `where(\"lower(host) = ?\", host)`, whereas the removed `SiteSetting.allows_embeddable_host?` performed subdomain matching via `host.ends_with?(\".#{h}\")`. This affects `EmbedController#ensure_embeddable` (embed_controller.rb:61) and `TopicRetriever#invalid_host?` (topic_retriever.rb:16), causing subdomain referers to be rejected or silently skipped.\n\n---\n\n### 🟡 Medium · Feed-imported topics lose the default category assignment\n\n`app/models/topic_embed.rb`\n\n`TopicEmbed.import` uses `eh.try(:category_id)` where `eh = EmbeddableHost.record_for_host(url)`. When no host matches, `record_for_host` returns `false`, so `false.try(:category_id)` yields `nil`.\n\nThe old `embed_category` setting was removed from `config/site_settings.yml` (verified — the embedding section no longer contains it). Feed-imported topics via `Jobs::PollFeed#import_topic` (poll_feed.rb:37) that previously landed in the configured `embed_category` now silently route to uncategorized.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 83 (B+) | -16.8 |\n| Test Coverage | 32 (F) | 100 (A+) | +67.6 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **86 (A-)** | **95 (A)** | **+8.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:35Z" + } + ] } ] }, @@ -47379,6 +48471,48 @@ "created_at": "2026-06-28T23:37:06Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/7", + "review_comments": [ + { + "path": "app/assets/stylesheets/desktop/topic-post.scss", + "line": 291, + "body": "### 🔵 Low · Swapped complement arms in dark-light-choose for topic-meta-data link\n\nThe light arm of dark-light-choose uses 70% lightness instead of the original 30%, changing the light theme color; the dark arm uses 30% instead of the complement 70%, causing low contrast on dark backgrounds. This violates the transform's convention seen in all other hunks.\n\nIt may be intentional if the color change was desired, but the report confirms it as a defect.\n\n> **Fix** — Change to: dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%))\n", + "created_at": "2026-08-21T22:34:22Z" + }, + { + "path": "app/assets/stylesheets/mobile/topic-post.scss", + "line": 182, + "body": "### 🔵 Low · .topic-map h3 light-theme value regressed from 20% to 50% lightness\n\nThe light-theme value for the h3 rule was changed from 20% to 50% lightness, violating the mechanical-wrap rule that the new light arm must equal the '-' line value. This is the sole exception among eight transform sites, all of which follow the complementary pattern (50→50, 20→80, 75→25, 70→30).\n\nThe h3 rule should have been 20%/80% but got h4's values instead, causing the heading to lose its visual hierarchy with the h4 sub-heading. It may be intentional, but the uniform pattern at the other seven sites refutes that.\n\n> **Fix** — Change the h3 rule to use scale-color($primary, $lightness: 20%) for the light arm and scale-color($secondary, $lightness: 80%) for the dark arm, matching the complementary pattern.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Light branch lightness changed from 20% to 50% in .participants h3**\n\nThe light-theme branch of dark-light-choose uses $lightness: 50% while the original - line was $lightness: 20%, darkening the heading in light themes. This may be intentional if the author wanted a different light-theme look, but it is outside the stated dark-only scope.\n\n**2. Light-theme arm regression in .topic-map h3 hunk — 50% instead of 20%**\n\nThe .topic-map h3 hunk is the single site where the mechanical transform breaks: the - line was scale-color($primary, $lightness: 20%) but the + light arm is scale-color($primary, $lightness: 50%). All 12 other transformed sites preserve the light value exactly (75→75, 50→50, 20→20, 70→70).\n\nIn light themes this visibly changes the topic-map section headings from a dark emphasized gray to mid-gray, making h3 identical to the h4 sub-heading directly below (:190, also a 50/50 pair — the h3 line was evidently copy-pasted from it); the dark arm should have been 80% to match the 20/80 pairing used for .number, i at :247. This change is outside the PR's stated dark-theme-only scope.\n\n
\n", + "created_at": "2026-08-21T22:34:22Z" + }, + { + "path": "app/assets/stylesheets/mobile/modal.scss", + "line": 102, + "body": "### 🔵 Low · Light branch lightness changed from 70% to 30% in .custom-message-length\n\nThe light-theme branch of dark-light-choose uses $lightness: 30% while the original - line used $lightness: 70%, so light themes render the character counter darker than before, contradicting the PR's dark-only scope. This may be intentional if the author meant to adjust light-theme styling, but the PR framing suggests otherwise.\n\n> **Fix** — Restore the light branch to scale-color($primary, $lightness: 70%) and keep the dark branch at scale-color($secondary, $lightness: 30%).\n", + "created_at": "2026-08-21T22:34:22Z" + }, + { + "path": "app/assets/stylesheets/desktop/user.scss", + "line": 522, + "body": "### 🔵 Low · Drifted lightness value in dark-light-choose for group member name\n\nThe light arm uses 50% lightness instead of the original 30%, changing the light theme color; the dark arm uses 50% instead of the complement 70%. This violates the transform's convention seen in all other hunks. It may be intentional if the color change was desired, but the report confirms it as a defect.\n\n> **Fix** — Change to: dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%))\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Light-theme color drift in .group-member-info .name from 30% to 50% lightness**\n\nThe mechanical transform applied at the other five sites preserves the '-' line's lightness in the light arm; at this site the light arm was silently changed 30% → 50% (the entire line is a copy of the adjacent .title rule at line 527). This changes light-theme rendering of .name from a darker 30%-lightness shade to mid-gray 50%, making it indistinguishable from .title — a light-theme appearance change outside the PR's stated dark-only scope.\n\nThe dark arm 50% also deviates from the repo's sum-to-100 pairing convention (30% light pairs with 70% dark). This may be intentional normalization, but nothing in the PR supports that.\n\n
\n", + "created_at": "2026-08-21T22:34:22Z" + }, + { + "path": "app/assets/stylesheets/mobile/user.scss", + "line": 497, + "body": "### 🔵 Low · Copy-paste defect: .name light-theme lightness changed from 30% to 50%\n\nThe diff wraps scale-color($primary, $lightness: 30%) in dark-light-choose, but the light-theme arm uses 50% instead of the original 30%. This changes the light-theme color of the group-member name from darker gray to lighter gray, reducing contrast against the $secondary background. The PR intent was only to add $secondary for dark themes, not alter the light-theme color.\n\nThe other four sites preserve their original lightness, so this is a single-site copy-paste defect. It might be intentional if the lightness bump was deliberate, but the PR title and uniformity of other sites argue against that.\n\n> **Fix** — Change the light arm of dark-light-choose at line 497 from 50% to 30% to match the original value.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Light branch lightness changed from 30% to 50% in .group-member-info .name**\n\nThe light-theme branch of dark-light-choose uses $lightness: 50% while the original - line was $lightness: 30%, changing the name color in light themes. This may be intentional if the author wanted a different light-theme look, but it is outside the stated dark-only scope.\n\n
\n", + "created_at": "2026-08-21T22:34:22Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Incomplete migration: category badge-count text still uses unconverted scale-color($primary) at line 115 | `app/assets/stylesheets/common/base/_topic-list.scss:115` (not in the diff) |\n| 🔵 Low | Swapped complement arms in dark-light-choose for topic-meta-data link | `app/assets/stylesheets/desktop/topic-post.scss:291` |\n| 🔵 Low | .topic-map h3 light-theme value regressed from 20% to 50% lightness | `app/assets/stylesheets/mobile/topic-post.scss:182` |\n| 🔵 Low | Light branch lightness changed from 70% to 30% in .custom-message-length | `app/assets/stylesheets/mobile/modal.scss:102` |\n| 🔵 Low | Drifted lightness value in dark-light-choose for group member name | `app/assets/stylesheets/desktop/user.scss:522` |\n| 🟡 Medium | dark-light-choose arms swapped for .topic-meta-data h5 a (30% site became 70%/30%) | `app/assets/stylesheets/common/base/topic-post.scss:291` (not in the diff) |\n| 🔵 Low | Copy-paste defect: .name light-theme lightness changed from 30% to 50% | `app/assets/stylesheets/mobile/user.scss:497` |\n\n---\n\n### 🔵 Low · Incomplete migration: category badge-count text still uses unconverted scale-color($primary) at line 115\n\n`app/assets/stylesheets/common/base/_topic-list.scss:115`\n\nThe change converts all other muted-text sites in the file to dark-light-choose with the dark arm derived from $secondary, but line 115 remains a bare scale-color($primary, $lightness: 50%). In dark themes, $primary is light text, so this produces near-white instead of the intended muted mid-gray, making the badge-count text inconsistent with converted siblings.\n\nThis may be intentional if the category badge was deliberately left unchanged, but the PR's stated purpose covers this exact pattern.\n\n---\n\n### 🟡 Medium · dark-light-choose arms swapped for .topic-meta-data h5 a (30% site became 70%/30%)\n\n`app/assets/stylesheets/common/base/topic-post.scss:291`\n\nThe light arm renders scale-color($primary, $lightness: 70%) instead of the original 30%, causing a visible light-theme regression (near-white gray link). The dark arm renders scale-color($secondary, $lightness: 30%), a dark gray on dark background — near-invisible link, the exact failure class the PR was meant to fix.\n\nThis may be intentional if the author deliberately changed the color, but the invariant followed by all sibling sites and the PR's stated purpose make that unlikely.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -1.0 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **71 (B-)** | **71 (B-)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:22Z" + } + ] } ] }, @@ -49374,6 +50508,60 @@ "created_at": "2026-06-28T23:44:35Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/8", + "review_comments": [ + { + "path": "app/assets/javascripts/discourse/models/group.js", + "line": 21, + "body": "### 🔵 Low · findMembers returns undefined on empty-name branch\n\nThe empty-name branch was changed from returning a resolved promise to returning undefined, breaking the method's promise contract. No current caller chains .then on this path, so no crash occurs today, but any future caller would dereference undefined.\n\nThis may be intentional defensive code, but it is a silent contract regression on a public model method.\n\n> **Fix** — Restore `return Ember.RSVP.resolve([])` on the empty-name branch.\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "app/assets/javascripts/discourse/templates/group/members.hbs", + "line": 6, + "body": "### 🟡 Medium · Public group members page binds `members` that no controller ever receives\n\nThe template iterates `members`, but the route sets `model` to the group and never sets `members` on the controller. The generated default controller does not proxy model keys in Ember 1.x, so the list renders empty. This may be intentional if a controller is expected to be defined elsewhere, but none exists.\n\n**Advisory** — the proof pass refuted the claim as stated on triggerability, but established reachability and harm. Shown for a second look; it will not block a merge. No GroupMembersController / group-members controller exists anywhere in the repo (grep across app/assets/javascripts returns only templates/group/members.hbs itself; controllers/group/ contains only index.js.es6 and post.js.es6). The route therefore uses Ember's generated default controller. The route group-members.js.es6:4-11 sets controller.set(\"model\", model) where model() returns this.modelFor('group') (the Discourse.Group from routes/group.js.es6:3-4), then calls model.findMembers(). models/group.js:31-36 — findMembers() runs self.setProperties({ ... members: result.members.map(...) }), i.e. the members array is set on the Group model, not on a controller. The pinned framework (Gemfile.lock:466, ember-source 1.9.0.beta.4) generates Ember.ObjectController for a route whose model is a non-array object. The documented contract of Ember.ObjectController in Ember 1.x is precisely to proxy missing property lookups to its model (unknownProperty → get(model, key)). The finding's stated premise — \"the generated default controller does not proxy model keys in Ember 1.x\" — contradicts that documented framework contract; ObjectController's defining behavior was exactly this delegation, so the template's members at members.hbs:6 resolves to Group.members after findMembers() populates it, and the list renders (reactively, once the AJAX resolves). The app's own code confirms this convention is in active use (controllers/object.js.es6:1 extends Ember.ObjectController; numerous controllers throughout the app rely on it), and the group route itself only ever sets model/counts on its controller — never members — yet the sibling group/index.hbs also consumes model-published data, consistent with proxy-based binding.\n_Impact: An always-empty public group members list would misrepresent group membership to all visitors — but this cannot occur because the binding works through the model-proxying ObjectController; listing members is the page's entire purpose, so the defect is not real._\n_Queries: read_file(app/assets/javascripts/discourse/templates/group/members.hbs) · glob(app/assets/javascripts/discourse/routes/group*) · glob(app/assets/javascripts/discourse/controllers/group*) · grep(\"GroupRoute|GroupController\", app/assets/javascripts) · grep(\"members\", app/assets/javascripts/discourse/routes) · list_dir(app/assets/javascripts/discourse/controllers/group) · grep(\"GroupMembersController|group-members\", app/assets/javascripts) · read_file(routes/group-members.js.es6) · read_file(routes/group.js.es6) · read_file(routes/application_routes.js) · read_file(models/group.js) · read_file(controllers/group.js.es6) · read_file(controllers/object.js.es6) · read_file(controllers/group/index.js.es6) · grep(\"ember\", Gemfile.lock) · grep(\"ObjectController|ArrayController\", app/assets/javascripts/discourse)_\n\n> **Fix** — Set `members` on the controller from the route's `findMembers().then(...)`, or bind the template to `model.members`, or define a `GroupMembersController`.\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "app/assets/javascripts/admin/templates/group_member.hbs", + "line": 1, + "body": "### 🔵 Low · Admin group templates trigger wrong actions: mis-scoped automatic discriminator always shows remove button, form wrapper Enter-key submits wrong action\n\nThe template is an item view rendered for each member, so `automatic` resolves against the member (a Discourse.User) which has no such property, making `{{#unless automatic}}` always true. Thus automatic groups' members get a remove link that, when clicked, hits the server guard and fails silently because the JS has no error handler.\n\nThis may be intentional if the server guard is the intended UX, but the sibling gates in group.hbs show the design intent was to hide the control.\n\n> **Fix** — Pass the group's `automatic` flag into the item view context (e.g., via `itemViewOptions` or a property on the controller) and use that in the template.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/app/assets/javascripts/admin/templates/group.hbs#L1-L3\n\n
\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "app/controllers/groups_controller.rb", + "line": 22, + "body": "### 🔵 Low · GroupsController#members response contract: shape changed to {members, meta} and silently truncates at 50 with no pagination\n\nThe limit is now applied unconditionally (previously only for automatic groups with default 200), and the public members.hbs has no pagination controls while the JS always sends limit:50. Members beyond the first 50 are unreachable on the public page — a silent data-visibility regression for every public group with more than 50 members.\n\n> **Fix** — Either add pagination controls to the public members page, or only apply the limit for automatic groups as before.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/app/controllers/groups_controller.rb#L26-L30\n\n
\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "spec/controllers/admin/groups_controller_spec.rb", + "line": 115, + "body": "### 🔵 Low · Spec masks the :id vs :group_id contract mismatch via direct dispatch, verb mislabel, and hardcoded seed ids\n\nThe spec dispatches directly by action name with hand-built params (spec:94, 103, 115, 125), never going through the route table, so it cannot detect that the routes supply :id while the controller requires :group_id. Additionally, spec:115 uses xhr :put for remove_member which is routed as delete, and spec:52/94/115 hardcode id:1/group_id:1 instead of fabricating an automatic group, coupling tests to seed data.\n\nThis makes the production defect CI-invisible.\n\n> **Fix** — Use request specs or route-aware integration tests, fix the verb to :delete, and fabricate automatic groups instead of hardcoding id 1.\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "app/controllers/admin/groups_controller.rb", + "line": 66, + "body": "### 🟠 High · add_members/remove_member param contract broken: wrong key fails real routes, split(\",\") crashes on array input, spec bypasses routing and masks both\n\nThe member routes produce /admin/groups/:id/members with param key :id, but the controller requires :group_id. Every real request raises ActionController::ParameterMissing (400), so the admin add/remove-member feature is non-functional in production. Specs pass only because they bypass the router.\n\n> **Fix** — Change params.require(:group_id) to params.require(:id) in both add_members and remove_member actions.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Spec bypasses routing, masking dead add_members/remove_member endpoints**\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/spec/controllers/admin/groups_controller_spec.rb#L92-L96\n\n**2. Admin add_members/remove_member 400 on every real request due to :id vs :group_id mismatch**\n\nThe member routes at config/routes.rb:49-50 generate /admin/groups/:id/members with params[:id], but the actions read params.require(:group_id) (lines 66 and 85), causing ActionController::ParameterMissing and a 400 on every real request. The automatic-group guards are dead because the require raises first.\n\nThis may be intentional if the routes were meant to be changed, but as written the endpoints are broken.\n\n**3. ParameterMissing on member routes: params.require(:group_id) vs routes binding params[:id]**\n\nThe new actions add_members and remove_member call params.require(:group_id), but the member routes PUT/DELETE /admin/groups/:id/members bind params[:id], and the client sends no group_id. Every real request raises ActionController::ParameterMissing → 400.\n\nThe specs pass only because they invoke the actions directly with group_id:, bypassing the router. This may be intentional if the actions were meant to be called only internally, but the routes and client indicate otherwise.This comment also covers: Admin group member endpoints 400 on every request (route/param mismatch)This comment also covers: Add/remove member endpoints can never receive the group id — feature is non-functional\n\n
\n\n
This same fix applies at 3 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/config/routes.rb#L47-L51\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/app/assets/javascripts/discourse/models/group.js#L40-L44\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/app/controllers/admin/groups_controller.rb#L69-L73\n\n
\n", + "created_at": "2026-08-21T22:34:26Z" + }, + { + "path": "app/assets/javascripts/admin/controllers/admin-group.js.es6", + "line": 13, + "body": "### 🔵 Low · totalPages off-by-one when user_count is an exact multiple of limit\n\nWhen user_count is an exact multiple of limit, Math.floor(user_count/limit)+1 yields one extra page. The next action then drives offset to user_count, the server returns an empty members array, and the admin lands on a permanently empty page.\n\nThis is a genuine arithmetic error on a new line, though admin-only and display-level.\n\n> **Fix** — Use Math.ceil(user_count / limit) instead of Math.floor(user_count / limit) + 1.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Phantom empty last page in admin group pagination (off-by-one)**\n\ntotalPages uses floor(user_count/limit)+1, which over-counts by one when user_count is an exact multiple of limit (e.g., 100/50 → 3 instead of 2). The next action's only guard is showingLast = currentPage == totalPages, so on the last real page Next stays enabled and navigates to an empty page.\n\nThis may be intentional if the UI is meant to show a trailing empty page, but the correct formula is Math.ceil(user_count/limit).\n\n**2. Pagination totalPages off-by-one on exact multiples of limit**\n\nMath.floor(user_count / limit) + 1 overcounts by one page whenever user_count is an exact multiple of limit, causing a phantom empty page and an enabled 'next' link on the last full page. This is a real UX defect, though minor.\n\n**3. Pagination off-by-one when user_count is exact multiple of limit**\n\ntotalPages is computed as Math.floor(user_count / limit) + 1, which yields one extra page when user_count is an exact positive multiple of limit. This enables a phantom empty last page via the next action and the server's unclamped offset.\n\nReachable for any group with exactly 50, 100, 150... members. This is a real defect introduced by the new code.This comment also covers: Off-by-one pagination produces an extra empty page when member count is an exact multiple of the limit\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR8__20260821/blob/060cda77729cb1c4a827560e09e89a7b22078ba9/app/assets/javascripts/admin/controllers/admin-group.js.es6#L9-L13\n\n
\n", + "created_at": "2026-08-21T22:34:27Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | findMembers returns undefined on empty-name branch | `app/assets/javascripts/discourse/models/group.js:21` |\n| 🟡 Medium | Public group members page binds `members` that no controller ever receives | `app/assets/javascripts/discourse/templates/group/members.hbs:6` |\n| 🔵 Low | Admin group templates trigger wrong actions: mis-scoped automatic discriminator always shows remove button, form wrapper Enter-key submits wrong action | `app/assets/javascripts/admin/templates/group_member.hbs:1` |\n| 🔵 Low | GroupsController#members response contract: shape changed to {members, meta} and silently truncates at 50 with no pagination | `app/controllers/groups_controller.rb:22` |\n| 🔵 Low | Spec masks the :id vs :group_id contract mismatch via direct dispatch, verb mislabel, and hardcoded seed ids | `spec/controllers/admin/groups_controller_spec.rb:115` |\n| 🟠 High | add_members/remove_member param contract broken: wrong key fails real routes, split(\",\") crashes on array input, spec bypasses routing and masks both | `app/controllers/admin/groups_controller.rb:66` |\n| 🔵 Low | totalPages off-by-one when user_count is an exact multiple of limit | `app/assets/javascripts/admin/controllers/admin-group.js.es6:13` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -6.3 |\n| Test Coverage | 32 (F) | 100 (A+) | +67.5 |\n| Consistency | 99 (A+) | 99 (A+) | -0.0 |\n| **Overall** | **71 (B-)** | **82 (B+)** | **+10.4** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:27Z" + } + ] } ] }, @@ -50865,6 +52053,54 @@ "created_at": "2026-06-28T22:47:11Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/3", + "review_comments": [ + { + "path": "lib/validators/email_validator.rb", + "line": 13, + "body": "### 🔵 Low · Short-circuit guard suppresses block-attempt tracking and makes error priority order-dependent\n\nThe `and` short-circuits, so `should_block?` — and with it the `match_count`/`last_match_at` tracking — runs only when domain-restriction checks passed. A blocked email that also fails the whitelist/blacklist is never recorded, defeating the tracking's purpose, and the surfaced error depends on statement order.\n\nThis may be deliberate if the guard is intentional, but the tracking-write suppression is a confirmed behavioral consequence.\n\n> **Fix** — Evaluate `should_block?` independently of the restriction errors, or at least don't gate the tracking write on the restriction result.\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": "lib/validators/email_validator.rb", + "line": 20, + "body": "### 🟡 Medium · Case-insensitive domain check vs. case-sensitive block lookup (block bypass on case-sensitive collations)\n\nThe validator builds a case-insensitive regex (IGNORECASE) for domain restrictions, but the blocked-email lookup uses an exact case-sensitive match on the same unnormalized value. On a case-sensitive collation (e.g., PostgreSQL), an attacker can bypass the block by changing letter case.\n\nThis may be intentional if the system relies on case-insensitive collations, but the code-level asymmetry is unconditional.\n\n> **Fix** — Normalize the email (e.g., downcase) before both the regex check and the blocked-email lookup, or make the blocked-email lookup case-insensitive.\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": "app/controllers/users_controller.rb", + "line": 198, + "body": "### 🔵 Low · Client-side rejectedEmails permanently rejects addresses after any email error\n\nThe new response fields (errors and values) activate the client-side rejectedEmails push. Once an email is pushed, it is never cleared, so the user cannot resubmit the same address even after fixing other fields.\n\nThe generic error reason masks which failure class occurred. This changes UX behavior compared to pre-PR where only a flash message was shown.\n\nIt may be intentional to prevent repeated attempts, but it permanently blocks valid resubmissions.\n\n> **Fix** — Clear rejectedEmails when the user edits the email field, or only push for truly permanent rejections (e.g. blocked) rather than transient ones like uniqueness.\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": "app/models/blocked_email.rb", + "line": 14, + "body": "### 🔵 Low · Non-atomic match_count read-modify-write causes lost updates\n\nThe read-modify-write of match_count is not atomic: two concurrent requests can both read the same value, increment in memory, and write back, losing one increment. This corrupts statistics integrity. It may be intentional if exact counts are not critical, but the mechanism is clearly a lost-update race.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). The resolved call chain is `users_controller.rb:157 create` → `user.valid?` (:168) and `user.save` (:172) → `user.rb:47` email validation → `email_validator.rb:13` → `BlockedEmail.should_block?` → `blocked_email.rb:12` SELECT → `:14` in-memory `+= 1` → `:16` `record.save` UPDATE, with no `lock: true`, no `lock_version` column, and no `update_counters`/`increment!` anywhere in app/ (zero-hit grep). Two interleaved executions of this SELECT→UPDATE pair lose one increment, and the :168 + :172 double validation makes the increment run twice per attempt deterministically. Rails 3.2.12 provides atomic alternatives precisely because this pattern is non-atomic.\n_Impact: Operators get wrong blocked-email attempt counts — undercounting under concurrency (lost updates) and overcounting by one per attempt (double validation) — corrupting the feature's statistics with no crash or bypass._\n_Queries: read_file(app/models/blocked_email.rb) · grep(pattern=\"should_block\\?\", path=\".\") · grep(pattern=\"match_count\", path=\".\") · read_file(lib/validators/email_validator.rb) · grep(pattern=\"email: true|:email => true|email_validator\", glob=\"**/*.rb\") · read_file(app/models/user.rb, offset=40, limit=15) · read_file(app/controllers/users_controller.rb, offset=100, limit=90) · read_file(db/migrate/20130724201552_create_blocked_emails.rb) · grep(pattern=\"lock_version|with_lock|transaction|update_counters|increment!\", glob=\"app/**/*.rb\") · grep(pattern=\"rails|activerecord\", glob=\"Gemfile*\") · read_file(spec/models/blocked_email_spec.rb)_\n\n> **Fix** — Use an atomic update: `record.update_counters(match_count: 1)` or `UPDATE blocked_emails SET match_count = match_count + 1 WHERE id = ?`.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Double-execution of should_block? per registration attempt**\n\nshould_block? runs twice per create attempt: once via user.valid? in the controller and once via user.save re-running validations. For :do_nothing records both increments commit, so match_count is incremented by 2 per attempt; for :block records the second write is rolled back.\n\nThis makes the statistic inconsistent across action types and fragile — a short-circuit reorder could leave the counter permanently at 0. It may be intentional if the double count is acceptable, but the mechanism is confirmed.\n\n
\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": "app/models/blocked_email.rb", + "line": 12, + "body": "### 🟠 High · Blocked-email control bypassable with case variants / whitespace\n\nThe blocked-email lookup uses a case-sensitive exact match on un-normalized input, allowing an attacker to bypass the block by registering with a case variant or whitespace. The repo's own sibling code canonicalizes email before comparing, confirming the assumption that PostgreSQL text comparison is case-sensitive. This nullifies the intended control. It might be intentional if the system only ever stores normalized emails, but the create path assigns the raw value verbatim.This comment also covers: Case-insensitive domain check vs. case-sensitive block lookup (block bypass on case-sensitive collations)\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). `BlockedEmail.should_block?(email)` at `app/models/blocked_email.rb:12` performs `BlockedEmail.where(email: email).first` — an exact, case-sensitive equality match against the stored `email` column. The signup flow at `app/controllers/users_controller.rb:160` calls `User.new_from_params(params)`, which at `app/models/user.rb:87` assigns `user.email = params[:email]` verbatim — no downcasing, no stripping. The validator `lib/validators/email_validator.rb:13` passes that raw value directly into `BlockedEmail.should_block?(value)`. Sibling paths in the same codebase canonicalize before comparing: `app/controllers/users_controller.rb:269` uses `Email.downcase(params[:email]).strip`, and `app/controllers/session_controller.rb:17` uses `Email.downcase(login)`. `lib/email.rb:19-22` defines `Email.downcase` as the project's canonicalization helper (downcases the domain part; spec at `spec/components/email/email_spec.rb:29-30` confirms `'SAM@GMAIL.COM'` → `'SAM@gmail.com'`, and `'sam@GMAIL.COM'` stays distinct from `'SAM@gmail.com'` — the local part can legitimately keep mixed case). The boolean short-circuit at `email_validator.rb:13` (`record.errors[attribute].blank? and ...`) means `should_block?` only runs when the format checks already passed, so the value reaching the block lookup is the same raw user-supplied value, un-normalized. No `Email.downcase`/`strip` is applied anywhere on the `UsersController#create` → `User.new_from_params` → `validate` → `EmailValidator#validate_each` → `BlockedEmail.should_block?` path.\n_Impact: An attacker can bypass the admin-configured blocked-email control and register an account whose email differs only by case (e.g., blocked `blockeduser@example.com` vs. registered `BlockedUser@example.com`), defeating an intended abuse/spam-control measure._\n_Queries: read_file(app/models/blocked_email.rb) · read_file(lib/validators/email_validator.rb) · read_file(app/controllers/users_controller.rb @ 145-189) · read_file(lib/email.rb) · read_file(app/models/user.rb @ 84-91) · grep(users_controller.rb: \"downcase|strip|email\") · grep(lib/email.rb: \"def self.downcase\") · grep(Gemfile: \"email\") · read_file(spec/components/email/email_spec.rb)_\n\n> **Fix** — Normalize before comparing — use Email.downcase(value).strip on the lookup and store normalized in BlockedEmail, matching how login/change_email/find_by_username_or_email resolve email identity.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR3__20260821/blob/5f8a130277dbddc95d133cd2832be639baf89213/lib/validators/email_validator.rb#L18-L22\n\n
\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": "app/models/blocked_email.rb", + "line": 16, + "body": "### 🟡 Medium · Predicate method `should_block?` writes to the database (`save`)\n\n`should_block?` is named as a query (Ruby's `?` predicate convention) but calls the persistence mutator `save`, writing to the database. A query-named method that mutates persisted state violates command-query separation — callers that only mean to CHECK a condition unknowingly trigger a write.\n\n> **Fix** — Move the write to a command method (a non-`?` name like `block!`), and keep `should_block?` read-only.\n", + "created_at": "2026-08-21T21:32:50Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Short-circuit guard suppresses block-attempt tracking and makes error priority order-dependent | `lib/validators/email_validator.rb:13` |\n| 🔵 Low | Double-invocation of should_block? DB write per registration attempt | `app/controllers/users_controller.rb:168` (not in the diff) |\n| 🟡 Medium | Case-insensitive domain check vs. case-sensitive block lookup (block bypass on case-sensitive collations) | `lib/validators/email_validator.rb:20` |\n| 🔵 Low | Client-side rejectedEmails permanently rejects addresses after any email error | `app/controllers/users_controller.rb:198` |\n| 🔵 Low | Non-atomic match_count read-modify-write causes lost updates | `app/models/blocked_email.rb:14` |\n| 🟠 High | Blocked-email control bypassable with case variants / whitespace | `app/models/blocked_email.rb:12` |\n| 🟡 Medium | Predicate method `should_block?` writes to the database (`save`) | `app/models/blocked_email.rb:16` |\n\n---\n\n### 🔵 Low · Double-invocation of should_block? DB write per registration attempt\n\n`app/controllers/users_controller.rb:168`\n\n`user.valid?` at line 168 and `user.save` at line 172 both trigger the email validator (registered at app/models/user.rb:47), so `should_block?` and its DB write execute twice per attempt. The second execution sits inside `save`'s transaction and is rolled back when the validator's own 'blocked' error fails the save, while the first persists — and `save`-only flows (invite path) never persist it at all.\n\nThis may be intentional if the double-write is acceptable, but it is a confirmed behavioral consequence.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 89 (A-) | 86 (A-) | -3.2 |\n| Runtime | 100 (A+) | 99 (A+) | -0.9 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **86 (A-)** | **85 (B+)** | **-0.9** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:50Z" + } + ] } ] }, @@ -52089,6 +53325,30 @@ "created_at": "2026-06-28T23:08:26Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/5", + "review_comments": [ + { + "path": "app/assets/stylesheets/common/base/header.scss", + "line": 37, + "body": "### 🔵 Low · Panel loses right-edge position in 2009 box-model browsers\n\nBrowsers that match the `-webkit-box`/`-moz-box` display arms (Safari ≤ 5.1, Chrome ≤ 20, Android ≤ 4, Firefox ≤ 18) lay out `.contents` per the 2009 box model, in which child `margin-left: auto` does not absorb free space (main-axis distribution is `box-pack`/`box-flex` only). The panel therefore sits immediately after the title instead of at the right edge — a regression from the removed `float: right`, which worked in every browser.\n\nCorroborating asymmetry in the same new mixin block: `align-items` emits `-webkit-box-align` and `-ms-flex-align` but omits `-moz-box-align`, while `flexbox()` emits `display: -moz-box` and `order()` emits `-moz-box-ordinal-group` — so Firefox ≤ 18 gets a `-moz-box` container whose cross-axis alignment silently defaults to `stretch`. This is the same 2009-model browser set failing to honor the new layout's intent.\n\nThe IE10 sub-claim is not confirmed (IE10's `-ms-flexbox` implements the 2012 CR, in which auto margins do absorb free space).\n\n> **Fix** — Add a fallback for 2009 box-model browsers, e.g., keep `float: right` on `.panel` alongside the flex properties, or use `box-pack: justify` / `-moz-box-pack` for the 2009 model.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Flexbox conversion removes float-based right alignment from .d-header .panel in noscript header**\n\nThe change from float: right to margin-left: auto with order(3) only works when .panel is a direct flex item of .contents. In the non-Ember/noscript header, .panel is nested inside .row (a non-flex wrapper), so margin-left: auto computes to 0 and order(3) is inert, causing the login panel to lose right alignment and stack left under the title.\n\nThis may be intentional if the noscript header is deprecated, but no such deprecation is evident.\n\n
\n", + "created_at": "2026-08-21T22:34:15Z" + }, + { + "path": "app/assets/stylesheets/common/foundation/mixins.scss", + "line": 121, + "body": "### 🟡 Medium · This declares a vendor-prefixed CSS property that no browser ever shipped (e.g. `-ms-align-items`, whose only real IE/Edge form is `-ms-flex-align`). The fabricated prefix is dead: the intended flexbox alignment silently does nothing — a tell-tale of a hand-written or copy-pasted flexbox mixin. Use the correct legacy property (`-ms-flex-align`, `-ms-flex-pack`, `-ms-flex-order`, `-ms-flex-item-align`, `-ms-flex-line-pack`) or drop the prefix.\n\n\nThis declares a vendor-prefixed CSS property that no browser ever shipped (e.g. `-ms-align-items`, whose only real IE/Edge form is `-ms-flex-align`). The fabricated prefix is dead: the intended flexbox alignment silently does nothing — a tell-tale of a hand-written or copy-pasted flexbox mixin. Use the correct legacy property (`-ms-flex-align`, `-ms-flex-pack`, `-ms-flex-order`, `-ms-flex-item-align`, `-ms-flex-line-pack`) or drop the prefix.\n\n\n**Advisory** — harm could not be settled mechanically; the finding stands on its investigation evidence and will not block a merge. app/assets/stylesheets/common/foundation/mixins.scss:121 declares `-ms-align-items: $alignment;` — a vendor prefix no browser ever shipped (the real IE/Edge legacy form is `-ms-flex-align`). The same mixin, one line above at mixins.scss:120, declares `-ms-flex-align: $alignment;` — the correct IE/Edge property — plus `-webkit-box-align` (:118), `-webkit-align-items` (:119), and standard `align-items` (:122). The mixin is reachable: mixins.scss is imported by common.scss:6 (main stylesheet manifest) and the mixin is included at header.scss:18, badges.css.scss:57, topic-post.scss:265; a single `@mixin align-items` definition exists (no shadowing). So the fabricated declaration ships in compiled CSS, but it is inert.\n_Impact: The claimed harm — \"the intended flexbox alignment silently does nothing\" — cannot occur: IE/Edge are covered by :120, all other browsers by :118–119/:122. The fabricated prefix is real dead code, so this is at most an advisory (reachable + triggerable, harm refuted); as a rendering defect it is refuted._\n_Queries: read_file(path=\"app/assets/stylesheets/common/foundation/mixins.scss\") · grep(pattern=\"@include align-items\", path=\"app/assets/stylesheets\") · grep(pattern=\"mixins\\.scss|@import .*mixins|@use .*mixins\", path=\"app/assets/stylesheets\") · grep(pattern=\"@mixin align-items\", path=\"app/assets/stylesheets\") · read_file(path=\"app/assets/stylesheets/common/base/header.scss\", offset=1, limit=40)_\n\n", + "created_at": "2026-08-21T22:34:15Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Panel loses right-edge position in 2009 box-model browsers | `app/assets/stylesheets/common/base/header.scss:37` |\n| 🟡 Medium | This declares a vendor-prefixed CSS property that no browser ever shipped (e.g. `-ms-align-items`, whose only real IE/Edge form is `-ms-flex-align`). The fabricated prefix is dead: the intended flexbox alignment silently does nothing — a tell-tale of a hand-written or copy-pasted flexbox mixin. Use the correct legacy property (`-ms-flex-align`, `-ms-flex-pack`, `-ms-flex-order`, `-ms-flex-item-align`, `-ms-flex-line-pack`) or drop the prefix. | `app/assets/stylesheets/common/foundation/mixins.scss:121` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -0.7 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **71 (B-)** | **71 (B-)** | **-0.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:15Z" + } + ] } ] }, @@ -53315,6 +54575,24 @@ "created_at": "2026-06-28T23:23:17Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/6", + "review_comments": [ + { + "path": "app/serializers/user_serializer.rb", + "line": 149, + "body": "### 🔵 Low · Domain-match logic is direction-dependent / misses www subdomain / asymmetric\n\nSame-domain detection only recognizes exact equality, equal-length sibling subdomains, and instance-is-subdomain-of-website. The mirror — website is a subdomain of the instance (e.g. www.example.com on example.com) — falls to the else arm's false branch, returning website_host with the path dropped.\n\nThis contradicts the PR's stated intent of showing complete URL path when domains match, and the case-sensitivity of == degrades it further. It might be intentional if only one direction was considered, but the spec's third test asserts the symmetric convention.\n\n> **Fix** — Add a check for website_host.ends_with?(\".\" << discourse_host) to handle the mirror case, and downcase both hosts before comparison.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Mirror-asymmetry / normalization-fragile domain matching (requeued)**\n\nThe requeued verdict confirms the same mechanism as Finding 2: branch 3 declares instance-is-a-subdomain-of-website as same domain, but the mirror — website-is-a-subdomain-of-instance (www.example.com on example.com, or forums.example.com website on example.com instance) — falls through all three branches to host-only display, contradicting the PR's own convention and stated intent. Line 141's equality is additionally case-sensitive (Example.com vs example.com → host-only).\n\n**2. website_name drops URL path when website host is a proper subdomain of the instance host (writer-side confirmation)**\n\nSame mechanism and trace as the first finding — the method drops the path when the website host is a proper subdomain of the instance host, asymmetric with its own branches 2/3 which preserve the path for the sibling and reverse parent/child relationships of the same registrable domain. A valid stored website's path text silently disappears from the link rendered at user.hbs:69.\n\nThis may be intentional if the author only considered the listed directions, but the asymmetry with the sibling branches suggests an oversight.This comment also covers: website_name drops URL path when website host is a proper subdomain of the instance host\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR6__20260821/blob/267d8be1f556ed59639ced396c885bb44586da19/app/serializers/user_serializer.rb#L142-L146\n\n
\n", + "created_at": "2026-08-21T22:34:19Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Domain-match logic is direction-dependent / misses www subdomain / asymmetric | `app/serializers/user_serializer.rb:149` |\n| 🔵 Low | Only two of three branches exercised; leak unasserted | `spec/serializers/user_serializer_spec.rb:11` (not in the diff) |\n\n---\n\n### 🔵 Low · Only two of three branches exercised; leak unasserted\n\n`spec/serializers/user_serializer_spec.rb:11`\n\nThe three added tests exercise only branch 1 (example.com instance → full path) and branch 3 (discourse.org → host-only; forums.example.com → full path via ends_with?(\".example.com\")). The www == forum sibling branch (line 144–146 of the serializer) is never reached by any input in the spec — it ships with zero coverage, and it is precisely the branch whose [1..-1]-suffix math is the most error-prone.\n\nAdditionally, the untrusted-attributes list in the TL0-anonymous context still enumerates only pre-existing fields — website_name is absent — so that test would not flag the L1 leak even if a TL0 user had a website set; and no test sets a website on a TL0 user seen anonymously. The changed spec therefore cannot detect either defect the PR should have guarded.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 39 (F) | 39 (F) | -0.0 |\n| Runtime | 100 (A+) | 99 (A+) | -1.1 |\n| Test Coverage | 32 (F) | 100 (A+) | +67.6 |\n| **Overall** | **71 (B-)** | **82 (B+)** | **+11.3** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:19Z" + } + ] } ] }, @@ -55740,6 +57018,84 @@ "created_at": "2026-06-28T23:03:36Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/4", + "review_comments": [ + { + "path": "app/models/topic_embed.rb", + "line": 13, + "body": "### 🟠 High · TopicEmbed URL handling: unescaped interpolation in generated HTML and absolutize_urls mangling protocol-relative URLs\n\nThe `#{url}` interpolation at line 13 places an attacker-influenced URL (from `params.require(:embed_url)` via `embed_controller.rb:9`) into an HTML attribute without escaping. The generated HTML is rendered unescaped via `cook_method: Post.cook_methods[:raw_html]` (topic_embed.rb:22, post.rb:133), enabling XSS. This is a confirmed deterministic finding for this PR.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). topic_embed.rb:13 interpolates url raw into a single-quoted attribute with no escaping (Rails auto-escaping does not apply to Ruby string interpolation, only ERB). Reachability chain established: routes.rb:245 unauthenticated get 'embed/best' → embed_controller.rb:9 params.require(:embed_url) → :15 Jobs.enqueue(:retrieve_topic, ..., embed_url: embed_url) → retrieve_topic.rb:17 → topic_retriever.rb:52 TopicEmbed.import_remote(user, @embed_url) → topic_embed.rb:52 import → line 13. post.rb:130-133 returns raw verbatim for raw_html; post_creator.rb:105 persists it as cooked; rendered unescaped at topics/show.html.erb:12, embed/best.html.erb:19, topics/plain.html.erb:14 (post.cooked.html_safe). Trigger preconditions are supported embed-feature configuration, not refutations: embeddable_host set + referer host match (embed_controller.rb:24-31, spoofable by a non-browser client); URI(@embed_url).host == embeddable_host (topic_retriever.rb:14-19 — attacker controls path/query); embed_by_username set; URL fetchable; 60s per-URL throttle. ' is a valid RFC 3986 sub-delim in a URL query, closes the single-quoted href, and the trailing onmouseover='...' token survives Nokogiri's parse/re-serialize in absolutize_urls (topic_embed.rb:56-76) into the stored cooked HTML. Sub-claim: poll_feed.rb:36 passes a String by construction (nil would crash at .scrub before import); import_remote's doc.content comes from ruby-readability 0.5.7 (Gemfile_rails4.lock:313) which is NOT vendored (glob returns nothing) — nil behavior unverifiable.\n_Impact: Stored XSS: a crafted embed_url (host = embeddable_host, ' breakout payload in path/query) submitted to the unauthenticated embed/best endpoint is cooked as raw HTML and persisted as cooked; any user viewing the topic executes the attacker's script (session theft/account takeover). Secondary: if doc.content is nil, line 13 NoMethodError kills the background job — triggerability of that arm is unknown (unvendored gem), but the XSS arm stands._\n_Queries: read_file(app/models/topic_embed.rb) · read_file(app/controllers/embed_controller.rb) · grep(TopicEmbed\\.(import|import_remote)) · grep(import_remote|poll_feed|retrieve_topic) · read_file(lib/topic_retriever.rb) · read_file(app/jobs/regular/retrieve_topic.rb) · read_file(app/jobs/scheduled/poll_feed.rb) · grep(raw_html) · read_file(app/models/post.rb, offset:55) · read_file(config/routes.rb, offset:238) · grep(ruby-readability, glob:Gemfile*) · glob(vendor/**/*) · read_file(lib/post_creator.rb) · grep(cooked, app/views) · grep(cooked, app/serializers)_\n\n> **Fix** — Escape the URL before interpolation, e.g., use `CGI.escapeHTML(url)` or use Rails' `h` helper.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. absolutize_urls mangles protocol-relative URLs into broken host paths**\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR4__20260821/blob/4f8aed295a29954023b2849c060ef4fb299d1b5d/app/models/topic_embed.rb#L62-L66\n\n**2. NoMethodError on nil contents in TopicEmbed#import**\n\nThe `contents <<` operation at line 13 will raise NoMethodError if `contents` is nil. The only guard is the URL regex check at line 11, and there is no nil check on `contents`.\n\nWhile the in-scope caller `poll_feed` always supplies a String, the `import_remote` path passes `doc.content` from the external ruby-readability gem whose nil-return behavior cannot be verified from this checkout. This may be intentional if the gem is guaranteed to return a String, but the lack of a nil guard makes it a latent crash.\n\n**3. NoMethodError when contents is nil (Mechanism A)**\n\nThe `contents <<` operation at line 13 will raise NoMethodError if `contents` is nil. The only guard is the URL regex check at line 11, and there is no nil check on `contents`.\n\nWhile the in-scope caller `poll_feed` always supplies a String, the `import_remote` path passes `doc.content` from the external ruby-readability gem whose nil-return behavior cannot be verified from this checkout. This may be intentional if the gem is guaranteed to return a String, but the lack of a nil guard makes it a latent crash.This comment also covers: raw_html cook method bypasses sanitization, enabling stored XSS via feed/embed content\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR4__20260821/blob/4f8aed295a29954023b2849c060ef4fb299d1b5d/app/models/post.rb#L131-L135\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/views/embed/loading.html.erb", + "line": 10, + "body": "### 🔵 Low · 30s reload defeated by 60s cache on loading render\n\nThe loading page reloads after 30 seconds (`setTimeout(…, 30000)`), but the controller sets a 60-second public cache (`discourse_expires_in 1.minute`) on the loading render. The 30s reload lands inside the cache window, so it is served from cache; the effective refresh cadence is 60s.\n\nIf the fetch permanently fails, the page loops on `loading` forever with no error state. This may be intentional if the 30s is meant as a minimum and the cache is meant to reduce load, but the mismatch means the reload never actually triggers a fresh fetch.\n\n> **Fix** — Align the reload timeout with the cache duration (e.g., set reload to 60s or set cache to 30s), or add an error state after a failed fetch.\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "lib/tasks/disqus.thor", + "line": 148, + "body": "### 🟠 High · disqus.thor import drops thread created_at and category, inverting reply chronology\n\nThe '-' line passed created_at and category to PostCreator; the '+' line is only TopicEmbed.import_remote(user, t[:link], title: t[:title]), and import/import_remote/PostCreator accept no creation date or category — the topic gets Time.now. Meanwhile the replies loop still passes created_at, so every reply is dated before its thread: chronology is inverted for any historical export. The -c category option is silently deleted, so existing users' category routing is lost with no warning. Both are regressions introduced by this hunk. It might be intentional if the migration is meant to re-import everything as 'now', but the replies still carry their original dates, making the inversion clearly unintended.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). lib/tasks/disqus.thor:148 — `post = TopicEmbed.import_remote(user, t[:link], title: t[:title])`; the import loop (143–180) has no rescue; replies pass `created_at: Date.parse(p[:created_at])` (line 164); options (113–116) contain no category. app/models/topic_embed.rb:48 — `Readability::Document.new(open(url).read, ...)` fetches server-side before the only scheme guard (topic_embed.rb:11, inside `TopicEmbed.import`, reached only after the fetch). PostCreator at topic_embed.rb:22 gets no `created_at`/`category` → thread topic timestamp = `Time.now`. lib/post_creator.rb:218 — PostCreator honors `created_at` when passed, confirming the omission (not a framework limitation) is the mechanism. lib/topic_retriever.rb:14-19,52 — the other `import_remote` caller applies `invalid_host?` (URI parse/host check); the disqus path bypasses it. grep(open-uri) — `open-uri` required only in `poll_feed.rb:7` and oneboxer files, not on the disqus.thor load path; `open()` on a dead link (`OpenURI::HTTPError`), nil (`TypeError`), or relative/`http://` string without open-uri (`Errno::ENOENT`) raises uncaught. classify/pr_analysis.md — disqus.thor changed −8 lines in this embed PR. .git/logs/HEAD/packed-refs — shallow single-commit clone (`4f8aed2…`), so pre-change '-' line values are mechanically unretrievable; nothing in HEAD contradicts the finding's quoted pre-change values, and every HEAD-side mechanism it predicts is verified.\n_Impact: Replies dated before their own thread (corrupted chronology and last-posted/bumped stats in migrated content); one dead link aborts the entire import mid-way, silently losing all remaining threads; scheme-failing links drop their comments with no warning._\n_Queries: read_file(lib/tasks/disqus.thor) · read_file(app/models/topic_embed.rb) · read_file(lib/post_creator.rb) · read_file(lib/topic_retriever.rb) · grep(pattern=\"open-uri|require 'open_uri'|require \\\"open-uri\\\"\", glob=\"**/*.rb\") · grep(pattern=\"disqus\", glob=\"**/*\") · grep(pattern=\"Readability|import_remote|readability\", glob=\"**/*.rb\") · grep(pattern=\"readability|open-uri\", path=Gemfile) · read_file(classify/pr_analysis.md) · read_file(classify/.agent-analysis.md) · list_dir(.git) · read_file(.git/logs/HEAD) · read_file(.git/packed-refs) · read_file(spec/models/topic_embed_spec.rb)_\n\n> **Fix** — Pass created_at and category through to TopicEmbed.import_remote and PostCreator, or at minimum warn when they are dropped.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. disqus.thor import now fetches every thread URL server-side, crashing on dead or relative links**\n\nThe new path fetches every thread URL server-side (open(url).read in topic_embed.rb:48). A dead/moved thread link now raises an uncaught OpenURI::HTTPError/Errno::* that aborts the entire import, and a nil or non-http(s) t[:link] crashes at open(nil)/open('relative/path') where the old code harmlessly rendered [Permalink]().\n\nWhen import's return unless url =~ /^https?\\:\\/\\// yields nil, if post.present? silently drops the thread's comments with no warning. The old code needed no fetch at all.\n\nIt might be intentional to validate links, but the crash-on-dead-link behavior is clearly a regression.\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/models/topic_embed.rb", + "line": 15, + "body": "### 🟡 Medium · TopicEmbed.import find-then-create race can duplicate/crash and update path never propagates feed title changes\n\nThe find at line 15 and TopicEmbed.create! at line 25 are non-atomic. Overlapping runs (PollFeed's hourly scheduler in one process while RetrieveTopic triggers Jobs::PollFeed.new.execute({}) in another, or a concurrent disqus import) can both pass the embed.blank? check and both create.\n\nWhether the loser raises ActiveRecord::RecordNotUnique (killing the job — PollFeed has retry: false, and import has no rescue) or silently duplicates the embed depends on a unique index on embed_url whose migration could not be read. The race itself is confirmed by the code; the failure mode is unresolved.\n\nIt might be intentional if the scheduler guarantees single execution, but no such guarantee is visible.\n\n> **Fix** — Use find_or_create_by! or a database unique constraint with a rescue to handle the race deterministically.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR4__20260821/blob/4f8aed295a29954023b2849c060ef4fb299d1b5d/app/models/topic_embed.rb#L14-L18\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/jobs/scheduled/poll_feed.rb", + "line": 21, + "body": "### 🔵 Low · Feed-modification gating never wired in and throttling flawed: feed_key dead code, unconditional enqueue, inline poll bypasses throttle, non-atomic setnx+expire can permanently throttle a URL\n\nfeed_key defines and memoizes @feed_key but no caller exists anywhere in the repo; the intended 'skip polling when the feed is unchanged' gating was never wired into execute/poll_feed, so every hourly run unconditionally re-fetches and re-imports the entire feed. This may be intentional if the gating was deliberately deferred, but as written it is dead code with a real operational consequence.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). 1. Dead code confirmed by exhaustive search: repo-wide grep for `feed_key` matches exactly two code lines — `app/jobs/scheduled/poll_feed.rb:20` (`def feed_key`) and `:21` (`@feed_key ||= \"feed-modified:#{Digest::SHA1.hexdigest(...)}\"`) — plus two markdown files. `feed-modified` matches only `poll_feed.rb:21`. The spec (`spec/jobs/poll_feed_spec.rb`, 41 lines) never mentions `feed_key`. No `send`/`public_send`/`method(:…)`/metaprogramming references it anywhere. 2. Gating absent on the live path: `execute` (poll_feed.rb:14-18) calls `poll_feed` at line 15 guarded only by `feed_polling_enabled?`, `feed_polling_url.present?`, `embed_by_username.present?`. `poll_feed` (24-38) unconditionally `open(SiteSetting.feed_polling_url)` at line 29 and `TopicEmbed.import(user, url, i.title, content)` for every feed item at line 36. No branch consults `feed_key` or any unchanged-feed check. 3. Reachable entry point: `app/jobs/base.rb:149` (`class Scheduled < Base`), `app/jobs/base.rb:89` and `:128` (`execute(opts)` called from `perform`), and `poll_feed.rb:11` (`recurrence { hourly }`) establish the Sidekiq hourly scheduled path: scheduler → `perform` → `execute` → `poll_feed`. 4. Harm characterization: `app/models/topic_embed.rb:15` (lookup by `embed_url`) and `:34` (revise only if `content_sha1 != embed.content_sha1`) show re-import of unchanged content is a no-op — no duplicates, no data corruption. The actual harm is the unconditional full feed download + per-item SHA1/DB processing every hour, 24×/day, with no skip when the feed is unchanged.\n_Impact: Standing bandwidth/CPU waste and 24×/day load on the remote feed provider; throttling risk degrades legitimate polling._\n_Queries: read_file(path=\"app/jobs/scheduled/poll_feed.rb\") · grep(pattern=\"feed_key\", path=\".\") · grep(pattern=\"feed-modified\", path=\".\") · grep(pattern=\"PollFeed|poll_feed\", path=\"app\") · grep(pattern=\"feed_key|feed_polling_url\", glob=\"*.rb\", path=\".\") · read_file(path=\"spec/jobs/poll_feed_spec.rb\") · read_file(path=\"app/models/topic_embed.rb\") · grep(pattern=\"class Scheduled|def execute|recurrence\", path=\"app/jobs\") · glob(pattern=\"**/jobs/base.rb\")_\n\n> **Fix** — Either wire feed_key into execute/poll_feed to skip unchanged feeds, or remove the dead method and its memoized value.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR4__20260821/blob/4f8aed295a29954023b2849c060ef4fb299d1b5d/lib/topic_retriever.rb#L25-L29\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "lib/topic_retriever.rb", + "line": 15, + "body": "### 🟠 High · invalid_host? allowlist bypassed by HTTP redirects enabling SSRF\n\ninvalid_host? validates only the initial URL's host against SiteSetting.embeddable_host. open-uri follows HTTP redirects by default without re-checking the redirect target, so a URL on the trusted host that redirects can land the fetch on an arbitrary internal host/IP, and the retrieved body is imported into a post, exfiltrating it. This may be intentional if redirects are trusted, but the host-validation control does not stop the SSRF.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). lib/topic_retriever.rb:15 — host-only equality; port and redirect target are outside the allowlist control. app/models/topic_embed.rb:48 — `open(url).read` with open-uri defaults (Ruby stdlib contract: follows HTTP redirects cross-host up to 10 hops unless `redirect: false`; `URI#host` excludes the port, so `http://:8080/` equals `embeddable_host` while the connection targets port 8080). Reachable chain: config/routes.rb:245 → embed_controller.rb:15 → retrieve_topic.rb:17 → topic_retriever.rb:9 → topic_embed.rb:48. The referer gate (embed_controller.rb:24-31) is bypassable by forging the `Referer` header; login gate (application_controller.rb:281) is off by default.\n_Impact: SSRF read primitive: server-side fetch of arbitrary ports on the trusted host and, via redirect, internal hosts/cloud metadata; body persisted into a post readable via `/embed/best?embed_url=…`. Concrete harm: internal port scanning and exfiltration of internal service responses from the Discourse server's network position._\n_Queries: read_file(lib/topic_retriever.rb) · read_file(app/models/topic_embed.rb) · read_file(app/controllers/embed_controller.rb) · read_file(app/jobs/regular/retrieve_topic.rb) · read_file(config/routes.rb, offset=235, limit=25) · read_file(app/controllers/application_controller.rb, offset=275, limit=15) · read_file(app/jobs/scheduled/poll_feed.rb) · grep(pattern=\"TopicRetriever\", path=\".\") · grep(pattern=\"embeddable_host\", path=\".\") · grep(pattern=\"open\\(|open-uri|URI\\.open\", glob=\"*.rb\") · discover(kind=\"enclosing_function\", args={file:\"lib/topic_retriever.rb\", line:\"15\"}) · discover(kind=\"guards_before\", args={file:\"lib/topic_retriever.rb\", line:\"15\"}) · ast_call_sites(symbol=\"retrieve\")_\n\n> **Fix** — Disable redirects or re-validate the host after each redirect before following it.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Host allowlist does not restrict port, allowing fetches to arbitrary ports on trusted host**\n\nThe host allowlist check `SiteSetting.embeddable_host != URI(@embed_url).host` compares only the host, excluding the port. Thus `http://:8080/…` passes the check, and `open(url)` (in topic_embed.rb:48) connects to any port on the trusted host.\n\nAdditionally, open-uri follows redirects, so a validated URL could redirect to an internal or arbitrary host. This may be intentional if the trusted host is fully trusted for all ports, but it is a residual SSRF gap.\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/jobs/scheduled/poll_feed.rb", + "line": 35, + "body": "### 🟠 High · NoMethodError on nil content in PollFeed job\n\nAt line 35, `content = CGI.unescapeHTML(i.content.scrub)` runs unguarded for every `rss.items` element. The sibling accessors in the same loop are blank-guarded (`url = i.link; url = i.id if url.blank?`), but `content` is not.\n\nA feed item without `` (standard RSS 2.0 uses ``) makes `i.content` nil, causing `nil.scrub` to raise NoMethodError, aborting the entire `rss.items.each` loop. This kills the hourly PollFeed job (retry: false), so imports stop entirely.\n\nThis file is new in this PR, so the defect is introduced here.\n\n> **Fix** — Use `(i.content || i.description || '').scrub` to handle missing content fields.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Stored XSS: feed content imported as unsanitized raw_html and rendered with raw**\n\nThe full chain — CGI.unescapeHTML(i.content.scrub) reactivates escaped markup, TopicEmbed.import stores it as raw_html, post.cook returns raw for raw_html bypassing the only sanitizer (PrettyText.cook with sanitize: true), and best.html.erb renders post.cooked with raw — delivers stored script to every visitor of the embedded topic on the Discourse origin. The feed publisher is a third party, so a compromised or hostile feed can execute script in the Discourse origin.\n\nThis may be intentional if the admin fully trusts the configured feed, but the absence of any sanitization step makes it a real vulnerability.\n\n**2. Unhandled nil content and invalid URI in poll_feed loop kills hourly import**\n\ni.content can be nil per SimpleRSS behavior for absent elements, and nil.scrub raises NoMethodError with no rescue in the loop, aborting the entire import run. Similarly, a bare-scheme link like http:// passes the regex guard and later raises URI::InvalidURIError at topic_embed.rb:57.\n\nSince retry is disabled, the job dies each hour at the same malformed item, leaving all subsequent items unimported. This may be intentional if the feed is trusted to always provide content and valid links, but no such guarantee exists.\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/models/post.rb", + "line": 133, + "body": "### 🟠 High · raw_html cook method bypasses sanitization, enabling stored XSS via feed/embed content\n\nThe raw_html cook method short-circuits the entire sanitization pipeline (PrettyText.cook with Sanitize and Plugin::Filter chain). New code wires third-party feed HTML or Readability output into PostCreator with cook_method: raw_html, and the cooked result is persisted unsanitized and rendered with raw in best.html.erb and the topic stream.\n\nThis allows publisher-controlled script/event-handler content to execute at the Discourse origin for every viewer. This may be intentional if the feature is only enabled for trusted admin-configured feeds, but the embed path's trust boundary is only invalid_host? on the embeddable host, which is exactly the crossing this feature enables.\n\n> **Fix** — Do not use raw_html for external content; sanitize the HTML before persisting, or at minimum run it through Sanitize before storing; consider disallowing script/event-handler tags explicitly.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Unsanitized external HTML stored as cooked via raw_html path**\n\nThe cook method now returns raw content unprocessed when cook_method is raw_html, bypassing the markdown/render pipeline. External HTML from topic embeds and feeds is stored verbatim into the cooked column and served to all topic viewers without server-side sanitization.\n\nThis is a security-posture regression, though exploitability depends on the unverifiable client-side sanitizer.\n\n
\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/assets/javascripts/embed.js", + "line": 17, + "body": "### 🟡 Medium · A message-event origin is validated with a substring / prefix / regex test (`.indexOf()`, `.includes()`, `.startsWith()`, `RegExp.test()`) instead of strict equality. A substring check on `event.origin` accepts any origin that merely contains the trusted string (e.g. `https://trusted.example.com.evil.com` or `https://evil-trusted.example.com`), and testing whether the origin appears INSIDE a trusted URL string (`trustedUrl.indexOf(e.origin)`) accepts any origin that is a substring of that URL, so a hostile page can post messages that pass the check. Compare the origin with `===` / `!==` against an exact allowed origin, or test membership in an array of exact origins.\n\n\n[CWE-346: Origin Validation Error] A message-event origin is validated with a substring / prefix / regex test (`.indexOf()`, `.includes()`, `.startsWith()`, `RegExp.test()`) instead of strict equality. A substring check on `event.origin` accepts any origin that merely contains the trusted string (e.g. `https://trusted.example.com.evil.com` or `https://evil-trusted.example.com`), and testing whether the origin appears INSIDE a trusted URL string (`trustedUrl.indexOf(e.origin)`) accepts any origin that is a substring of that URL, so a hostile page can post messages that pass the check. Compare the origin with `===` / `!==` against an exact allowed origin, or test membership in an array of exact origins.\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). embed.js:17: `if (discourseUrl.indexOf(e.origin) === -1) { return; }` — the handler is reached iff `e.origin` is a substring of `discourseUrl` (`trustedUrl.indexOf(e.origin)` variant, verbatim). No equality comparison exists in the handler. grep for `postMessageReceived|addEventListener('message'` returns only embed.js:15 and embed.js:25. The handler at line 25 (`window.addEventListener('message', postMessageReceived, false)`) is registered unconditionally in a public standalone asset (`list_dir` → `app/assets/javascripts/embed.js`, Rails pipeline). grep for `discourseUrl` returns only 3 hits, all inside embed.js — the host-page global is not re-validated anywhere. `read_file(app/controllers/embed_controller.rb)` shows `ensure_embeddable` (lines 24–31) validates only the HTTP referer host for the iframe *content*; it adds no protection to the client-side message origin check.\n_Impact: Origin-validation control (CWE-346) bypassed; a hostile page can deliberately forge `discourse-resize` messages and set the embed iframe's height on victim pages._\n_Queries: read_file(path=\"app/assets/javascripts/embed.js\") · grep(pattern=\"discourseUrl\", max_results=60) · grep(pattern=\"embed\\.js\", max_results=40) · list_dir(path=\"app/assets/javascripts\") · glob(pattern=\"**/*embed*\") · read_file(path=\"app/controllers/embed_controller.rb\") · grep(pattern=\"postMessageReceived|addEventListener\\('message'\", max_results=30)_\n\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/models/topic_embed.rb", + "line": 48, + "body": "### 🟠 High · A non-literal URL is fetched with open(url) / URI.open(url). open-uri opens the argument server-side with no scheme/host/IP allowlist, so a request-influenced URL is a server-side request forgery sink (SSRF, CWE-918) — an attacker can reach internal services or the cloud metadata endpoint — and Kernel#open additionally executes a leading \"|\" as a shell command (command injection). Validate the URL against a host allowlist, or fetch with a dedicated HTTP client (Net::HTTP / Faraday) bound to a fixed host.\n\n\n[CWE-918: Server-Side Request Forgery (SSRF)] A non-literal URL is fetched with open(url) / URI.open(url). open-uri opens the argument server-side with no scheme/host/IP allowlist, so a request-influenced URL is a server-side request forgery sink (SSRF, CWE-918) — an attacker can reach internal services or the cloud metadata endpoint — and Kernel#open additionally executes a leading \"|\" as a shell command (command injection). Validate the URL against a host allowlist, or fetch with a dedicated HTTP client (Net::HTTP / Faraday) bound to a fixed host.\n\n", + "created_at": "2026-08-21T22:34:10Z" + }, + { + "path": "app/controllers/embed_controller.rb", + "line": 28, + "body": "### 🟡 Medium · The X-Frame-Options response header is set to a non-standard value (ALLOWALL, '*', or ALLOW-FROM ...). Browsers only honor DENY and SAMEORIGIN; any other token is treated as no protection, so the page can be framed by any origin and clickjacking defenses are silently disabled. Set the header to 'DENY' or 'SAMEORIGIN' (or use a Content-Security-Policy frame-ancestors directive) instead.\n\n[CWE-1021: Improper Restriction of Rendered UI Layers or Frames] The X-Frame-Options response header is set to a non-standard value (ALLOWALL, '*', or ALLOW-FROM ...). Browsers only honor DENY and SAMEORIGIN; any other token is treated as no protection, so the page can be framed by any origin and clickjacking defenses are silently disabled.\n\nSet the header to 'DENY' or 'SAMEORIGIN' (or use a Content-Security-Policy frame-ancestors directive) instead.\n\n", + "created_at": "2026-08-21T22:34:11Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟠 High | TopicEmbed URL handling: unescaped interpolation in generated HTML and absolutize_urls mangling protocol-relative URLs | `app/models/topic_embed.rb:13` |\n| 🔵 Low | 30s reload defeated by 60s cache on loading render | `app/views/embed/loading.html.erb:10` |\n| 🟠 High | disqus.thor import drops thread created_at and category, inverting reply chronology | `lib/tasks/disqus.thor:148` |\n| 🟡 Medium | TopicEmbed.import find-then-create race can duplicate/crash and update path never propagates feed title changes | `app/models/topic_embed.rb:15` |\n| 🔵 Low | Feed-modification gating never wired in and throttling flawed: feed_key dead code, unconditional enqueue, inline poll bypasses throttle, non-atomic setnx+expire can permanently throttle a URL | `app/jobs/scheduled/poll_feed.rb:21` |\n| 🟠 High | invalid_host? allowlist bypassed by HTTP redirects enabling SSRF | `lib/topic_retriever.rb:15` |\n| 🟠 High | NoMethodError on nil content in PollFeed job | `app/jobs/scheduled/poll_feed.rb:35` |\n| 🟠 High | raw_html cook method bypasses sanitization, enabling stored XSS via feed/embed content | `app/models/post.rb:133` |\n| 🟡 Medium | A message-event origin is validated with a substring / prefix / regex test (`.indexOf()`, `.includes()`, `.startsWith()`, `RegExp.test()`) instead of strict equality. A substring check on `event.origin` accepts any origin that merely contains the trusted string (e.g. `https://trusted.example.com.evil.com` or `https://evil-trusted.example.com`), and testing whether the origin appears INSIDE a trusted URL string (`trustedUrl.indexOf(e.origin)`) accepts any origin that is a substring of that URL, so a hostile page can post messages that pass the check. Compare the origin with `===` / `!==` against an exact allowed origin, or test membership in an array of exact origins. | `app/assets/javascripts/embed.js:17` |\n| 🟠 High | A non-literal URL is fetched with open(url) / URI.open(url). open-uri opens the argument server-side with no scheme/host/IP allowlist, so a request-influenced URL is a server-side request forgery sink (SSRF, CWE-918) — an attacker can reach internal services or the cloud metadata endpoint — and Kernel#open additionally executes a leading \"\\|\" as a shell command (command injection). Validate the URL against a host allowlist, or fetch with a dedicated HTTP client (Net::HTTP / Faraday) bound to a fixed host. | `app/models/topic_embed.rb:48` |\n| 🟡 Medium | The X-Frame-Options response header is set to a non-standard value (ALLOWALL, '*', or ALLOW-FROM ...). Browsers only honor DENY and SAMEORIGIN; any other token is treated as no protection, so the page can be framed by any origin and clickjacking defenses are silently disabled. Set the header to 'DENY' or 'SAMEORIGIN' (or use a Content-Security-Policy frame-ancestors directive) instead. | `app/controllers/embed_controller.rb:28` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 84 (B+) | 78 (B) | -5.5 |\n| Runtime | 100 (A+) | 90 (A) | -9.7 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **84 (B+)** | **81 (B+)** | **-2.9** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T22:34:11Z" + } + ] } ] }, @@ -57329,6 +58685,42 @@ "created_at": "2026-06-28T22:28:25Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/1", + "review_comments": [ + { + "path": "app/assets/javascripts/discourse/lib/utilities.js", + "line": 182, + "body": "### 🔵 Low · Hardcoded 10MB client upload limit drifts from per-type site settings and mis-states the limit\n\nThe client-side upload size gate was changed from a per-type site setting lookup (max_image_size_kb or max_attachment_size_kb) to a hardcoded constant of 10 * 1024 KB. This affects both image and attachment types. For non-image uploads, the server-side downsize compensation is image-only, so a 5MB .zip on a site with max_attachment_size_kb = 1MB now passes the client and reaches the server with no downsize path.\n\nSites with a configured limit above 10MB are silently tightened, and the error message now always states 10MB regardless of configuration. This may be intentional as a simplification, but it breaks the per-type configuration contract.\n\n> **Fix** — Restore the per-type site setting lookup (SiteSettings['max_' + type + '_size_kb']) or add a server-side check for non-image uploads that enforces max_attachment_size_kb.\n", + "created_at": "2026-08-21T21:32:43Z" + }, + { + "path": "app/controllers/uploads_controller.rb", + "line": 67, + "body": "### 🔵 Low · Downsize-loop failure is swallowed and non-convergence stores an over-limit file\n\nThe loop discards the return value of `OptimizedImage.downsize(...)`. `convert_with` returns false on failure, so a failed pass leaves `tempfile.size` unchanged while the loop still burns all 5 attempts; then line 72 hands the still-oversized tempfile to `Upload.create_for`.\n\nLikewise, a file needing more than 5 passes is stored at whatever size remains. The user sees either an over-limit stored upload or a validator rejection whose cause is invisible.\n\nThis is a concrete and reachable runtime data-integrity issue.\n\n> **Fix** — Check the return value of `downsize` in the loop; on failure or non-convergence, abort the upload with a clear error instead of storing the oversized file.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. In-place downsize of an oversized animated GIF flattens it to frame 0 when allow_animated_thumbnails is false**\n\nLine 67 writes the downsize result back over `tempfile.path` — the file that becomes the stored upload. When the GIF path is taken with `allow_animation: SiteSetting.allow_animated_thumbnails` false, `optimize` selects `downsize_instructions` whose `convert #{from}[0]` extracts only the first frame, permanently destroying the original animation in the stored upload.\n\nThe thumbnail-animation toggle is repurposed to decide whether the original upload's animation survives — a scope mismatch on a data-mutating path. Confidence is capped because the default of the setting could not be confirmed from this checkout.\n\n
\n", + "created_at": "2026-08-21T21:32:43Z" + }, + { + "path": "app/models/optimized_image.rb", + "line": 155, + "body": "### 🟡 Medium · Animated GIF flattening due to unreachable animated branch\n\nThe `optimize` method selects `downsize_instructions_animated` only when `from =~ /\\.GIF$/i`, but `from` is always `tempfile.path` — a Rack multipart tempfile or a `discourse-upload-image`-basename Tempfile, neither of which carries the `.gif` suffix. The `allow_animation:` option passed at `uploads_controller.rb:67` is unreachable, so every animated GIF over the limit goes through `convert …[0]` and is permanently flattened to frame 0. This may be intentional if the site prefers static images, but the option suggests otherwise.\n\n**Advisory** — the proof pass refuted the claim as stated on triggerability and harm, but established reachability. Shown for a second look; it will not block a merge. The URL-branch premise is false by direct repo code: file_helper.rb:13-14 appends File.extname(uri.path) to the download tempfile basename, so a .gif URL produces a .gif-suffixed tempfile path. The Rack-branch premise is false by the pinned platform contract: rack 1.5.5 (Gemfile.lock:231) creates multipart upload tempfiles via Tempfile.new([\"RackMultipart\", ::File.extname(filename)]), so file.tempfile.path ends with the uploaded file's extension, not extension-less RackMultipart... The guard fires exactly when it matters: the downsize loop (uploads_controller.rb:64-69) runs only when FileHelper.is_image?(filename) is true, and the tempfile suffix derives from that same filename (Rack: original_filename; URL: uri.path). Therefore any GIF reaching optimize has a .gif-suffixed from; the regex at optimized_image.rb:155 matches; the gifsicle downsize_instructions_animated branch is selected; convert …[0] flattening of the original never executes for GIFs on this path.\n_Impact: none — animated GIFs over the size limit are resized by gifsicle with animation preserved when allow_animated_thumbnails is enabled; the claimed silent flattening to frame 0 does not occur._\n_Queries: read_file(path=app/models/optimized_image.rb) · read_file(path=app/controllers/uploads_controller.rb) · read_file(path=lib/file_helper.rb) · read_file(path=Gemfile.lock) · grep(pattern=\"OptimizedImage\\.(downsize|resize)\", path=app) · read_file(path=findings.md) · grep(pattern=\"OptimizedImage.downsize|allow_animation|tempfile.path\", path=spec)_\n\n> **Fix** — Pass the original filename or extension to `optimize` so the animated branch can be selected, or check the content type instead of the tempfile path.\n", + "created_at": "2026-08-21T21:32:43Z" + }, + { + "path": "app/models/optimized_image.rb", + "line": 149, + "body": "### 🟠 High · OptimizedImage.downsize redefined with a narrower signature; existing caller raises ArgumentError\n\nThe new 4-arg downsize definition at lines 149-151 shadows the retained 5-arg definition at lines 145-147 due to Ruby's last-definition-wins semantics. The existing caller ResizeEmoji#execute at app/jobs/regular/resize_emoji.rb:14 passes 5 positional arguments, which now raises ArgumentError on every emoji upload/resize.\n\nThe 5-arg definition becomes dead code. This may be intentional if the author intended to replace the old signature but forgot to update the caller, but the crash is real.\n\n> **Fix** — Delete the retained 5-arg downsize (lines 145-147) and update ResizeEmoji#execute to OptimizedImage.downsize(path, path, \"100x100\", opts), or keep a single downsize(from, to, dimensions, opts) contract and migrate the caller.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. OptimizedImage.downsize shadowing causes ArgumentError in ResizeEmoji**\n\nThe effective `OptimizedImage.downsize` is the 4-parameter form at line 149, which shadows the 5-parameter form at line 145. `Jobs::ResizeEmoji` passes 5 positional args → `ArgumentError` whenever the job runs. This may be intentional if the 5-parameter form is never used, but the job's call suggests otherwise.\n\n**2. New 4-arg downsize shadows 5-arg version and crashes ResizeEmoji**\n\nThe diff inserts a second `def self.downsize(from, to, dimensions, opts={})` after the existing 5-arg definition at lines 145-147. In Ruby the second definition replaces the first, so at runtime only the 3-required/1-optional-arg method exists.\n\nThe unchanged caller `app/jobs/regular/resize_emoji.rb:14` still calls with 5 positional arguments, causing an ArgumentError on every normal emoji-resize run. This is a deterministic runtime crash introduced by this PR.\n\n
\n", + "created_at": "2026-08-21T21:32:43Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Hardcoded 10MB client upload limit drifts from per-type site settings and mis-states the limit | `app/assets/javascripts/discourse/lib/utilities.js:182` |\n| 🔵 Low | Downsize-loop failure is swallowed and non-convergence stores an over-limit file | `app/controllers/uploads_controller.rb:67` |\n| 🟡 Medium | Animated GIF flattening due to unreachable animated branch | `app/models/optimized_image.rb:155` |\n| 🟠 High | OptimizedImage.downsize redefined with a narrower signature; existing caller raises ArgumentError | `app/models/optimized_image.rb:149` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 73 (B-) | -27.3 |\n| **Overall** | **86 (A-)** | **82 (B+)** | **-4.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:43Z" + } + ] } ] }, @@ -58860,6 +60252,42 @@ "created_at": "2026-06-28T22:28:38Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/ai-code-review-evaluation/discourse-graphite/pull/2", + "review_comments": [ + { + "path": "app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6", + "line": 5, + "body": "### 🔵 Low · Identifier \"stopNotificiationsText\" misspells \"notifications\"\n\n\"stopNotificiationsText\" contains \"notificiations\", a misspelling of \"notifications\" — Misspelling of 'notifications' with an extra 'i' after 'notifici'..\n\n", + "created_at": "2026-08-21T21:32:47Z" + }, + { + "path": "app/controllers/topics_controller.rb", + "line": 101, + "body": "### 🔵 Low · infinite 301 redirect loop for blank canonical slug\n\nThe guard uses params[:slug].blank?, while its sibling show uses params[:slug].nil?. When the topic's real slug is the empty string, slugs_do_not_match is false (both are \"\"), but params[:slug].blank? is true (for \"\", unlike nil?) → 301 to @topic_view.topic.unsubscribe_url, which re-encodes the same empty slug → the request hits unsubscribe again with the same state → permanent 301 loop. The divergence blank? vs nil? is introduced on this changed line; the sibling show branch proves the intent was .nil?, and the loop follows mechanically from the new guard plus unsubscribe_url's appending \"/unsubscribe\" to the slug-bearing url.\n\nReachability depends on a topic whose slug is recorded as empty — achievable for non-ASCII/edge-case titles depending on Slug.for behavior, so real-world reachability is low.\n\n> **Fix** — Change blank? to nil? to match the sibling show guard.\n", + "created_at": "2026-08-21T21:32:47Z" + }, + { + "path": "app/models/topic_user.rb", + "line": 125, + "body": "### 🔴 Critical · TopicUser.track_visit! misattributes every visit to the wrong user (copy-paste: `: topic` instead of `: user`)\n\nThe line `user_id = user.is_a?(User) ? user.id : topic` uses `topic` instead of `user`, so when called with integer arguments (as the sole production caller does), `user_id` is assigned the topic's id. This causes the real user's TopicUser row to never be created/updated (their visit is silently lost) and a bogus row with `user_id == topic_id` to be inserted for every logged-in visit, or the wrong user's last_visited_at to be updated. This is a confirmed copy-paste bug. It might be intentional only if the caller always passed model objects, but it does not.This comment also covers: TopicUser.track_visit! uses topic instead of user in fallback assignment\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). app/models/topic_user.rb:122: `user_id = user.is_a?(User) ? user.id : topic` — the fallback branch returns `topic`, exactly as the claim quotes. Structural fact: when `user` is not a `User` object, `user_id` is assigned the topic's id. app/controllers/topics_controller.rb:483: `TopicUser.track_visit! topic_id, user_id` — the sole production caller. `topic_id = @topic_view.topic.id` (Integer, line 463), `user_id = (current_user.id if current_user)` (Integer, line 465). No production caller ever passes a `User` model object; the only model-object callers are specs (spec/models/topic_user_spec.rb:91,105,115), where the ternary takes the `user.id` branch and the defect is masked. Reachable path (resolved): `TopicsController#show` (topics_controller.rb:41, public Rails route) → `track_visit_to_topic` (line 75) → `TopicUser.track_visit!` (line 483). Guard: `should_track_visit_to_topic?` (lines 489-491) requires `current_user`, so the call fires on every logged-in non-JSON topic view. spec/controllers/topics_controller_spec.rb:542 confirms the integer contract: `TopicUser.expects(:track_visit!).with(topic.id, current_user.id)`.\n_Impact: The real user's `TopicUser` row is never created/updated (their `last_visited_at`/`first_visited_at` visit tracking is silently lost), and a bogus row with `user_id == topic_id` is inserted (lines 125-127) or an unrelated user whose id equals the topic id has their `last_visited_at` updated (lines 125-129). Wrong data written to the `topic_users` table in normal operation._\n_Queries: read_file(path=\"app/models/topic_user.rb\") · grep(pattern=\"track_visit!\", path=\"app\") · grep(pattern=\"track_visit\", path=\".\") · read_file(path=\"app/controllers/topics_controller.rb\", offset=440, limit=70) · read_file(path=\"app/controllers/topics_controller.rb\", offset=1, limit=120) · read_file(path=\"spec/models/topic_user_spec.rb\", offset=80, limit=40)_\n\n> **Fix** — Change line 122 to `user_id = user.is_a?(User) ? user.id : user`.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/discourse__discourse-graphite__corbulo__PR2__20260821/blob/6669a2d94d76eea3b99b8c476d12b1eb66726b07/app/models/topic_user.rb#L120-L124\n\n
\n", + "created_at": "2026-08-21T21:32:47Z" + }, + { + "path": "app/controllers/topics_controller.rb", + "line": 105, + "body": "### 🟠 High · Nil-dereference crash on `tu` in unsubscribe action\n\nThe `unsubscribe` action calls `TopicUser.find_by` which returns nil for any logged-in user without a TopicUser row for the topic (e.g., mailing-list subscribers who never visited the topic). The subsequent `tu.notification_level` dereferences nil, raising NoMethodError and causing an HTTP 500.\n\nThis is reachable because the mailing-list job emails every activated, non-muted user with mailing_list_mode:true for every post, and the NOT EXISTS clause only excludes muted topic_users rows, not missing rows. The mailer unconditionally sets add_unsubscribe_link:true, embedding the unsubscribe URL. The house convention `TopicUser.change` would upsert safely, confirming the divergence is the root cause.\n\nThis could be intentional if the action were only reachable by users who had previously interacted with the topic, but the mailing-list path makes it reachable by users who never have.\n\n> **Fix** — Use `TopicUser.change` (which upserts) instead of `find_by` + attribute write + `save!`, or guard with `if tu && tu.notification_level > ...` before dereferencing.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. TopicsController#unsubscribe dereferences a possibly-nil TopicUser**\n\nThe new unsubscribe action calls `tu.notification_level` without a nil guard. A logged-in user who manually visits the unsubscribe URL for a topic with no topic_users row (never visited, never emailed) will trigger a NoMethodError and a 500.\n\nThe email path always creates the row, so this only affects manual URL construction, but it is a plausible state.\n\n**2. unsubscribe dereferences tu with no nil guard → NoMethodError/500**\n\nfind_by returns nil whenever the current user has no TopicUser row for the topic. Nothing in the controller flow creates that row, so any logged-in user can fetch /t/:topic_id/unsubscribe for a topic they have never visited, producing tu == nil → NoMethodError → HTTP 500.\n\nThe same path is reachable from notification emails for users who receive notifications but have no per-topic TopicUser row. This may be intentional if the email-sending path guarantees a row exists, but the action is directly reachable by URL regardless of mailer behavior.\n\n
\n", + "created_at": "2026-08-21T21:32:47Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Identifier \"stopNotificiationsText\" misspells \"notifications\" | `app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6:5` |\n| 🔵 Low | infinite 301 redirect loop for blank canonical slug | `app/controllers/topics_controller.rb:101` |\n| 🔴 Critical | TopicUser.track_visit! misattributes every visit to the wrong user (copy-paste: `: topic` instead of `: user`) | `app/models/topic_user.rb:125` |\n| 🟠 High | Nil-dereference crash on `tu` in unsubscribe action | `app/controllers/topics_controller.rb:105` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 93 (A) | 93 (A) | -0.5 |\n| Runtime | 100 (A+) | 66 (C+) | -34.3 |\n| Test Coverage | 32 (F) | 100 (A+) | +67.6 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **86 (A-)** | **92 (A)** | **+5.5** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:47Z" + } + ] } ] }, @@ -60473,6 +61901,48 @@ "created_at": "2026-06-28T23:46:15Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/22532", + "review_comments": [ + { + "path": "packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts", + "line": 28, + "body": "### 🟡 Medium · Direct construction of CalendarCacheRepository bypasses feature flag, leaving mock dead code and flag-off queries/deletes active — Calendar cache treated as unconditionally active/fresh: flag bypasses construction and migration backfills updatedAt, …\n\nThe handler does new CalendarCacheRepository() directly and never routes through CalendarCache.init, the only site that consults checkIfFeatureIsEnabledGlobally('calendar-cache') and swaps in CalendarCacheRepositoryMock. The new mock method getCacheStatusByCredentialIds is therefore unreachable on the only real path. With the flag OFF, every connectedCalendars fetch still executes prisma.calendarCache.groupBy against the live table, and the delete-cache mutation still deletes real rows, so the 'disabled' state no longer suppresses cache work on the settings page. Feature-flag contract broken on changed lines, with an unreachable mock method shipped alongside it.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). Direct, ungated construction at connectedCalendars.handler.ts:28; sole call site of getCacheStatusByCredentialIds (handler:29) binds to the real repository, making the mock method (calendar-cache.repository.mock.ts:27) unreachable. The only flag check for 'calendar-cache' is inside CalendarCache.init (calendar-cache.ts:24), which the handler never routes through. repository.ts:174 prisma.calendarCache.groupBy and deleteCache.handler.ts:28 deleteMany execute regardless of flag state; flag defaults to false (migration seed line 22, hooks default line 7). Path resolved: _app.ts:14 → viewer/_router.tsx:52 → calendars/_router.tsx:14 → handler → repository.ts:174.\n_Impact: With 'calendar-cache' disabled (default), connectedCalendars fetches still execute prisma.calendarCache.groupBy and deleteCache still deletes real rows — the disable contract is broken on the settings page, and the mock's getCacheStatusByCredentialIds is unreachable._\n_Queries: read_file(packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts) · read_file(packages/features/calendar-cache/calendar-cache.ts) · read_file(packages/features/calendar-cache/calendar-cache.repository.ts) · read_file(packages/features/calendar-cache/calendar-cache.repository.mock.ts) · grep(\"new CalendarCacheRepository\\(\", packages) · grep(\"getCacheStatusByCredentialIds\", packages) · grep(\"checkIfFeatureIsEnabledGlobally\", packages) · read_file(packages/prisma/migrations/20230907002853_add_calendar_cache/migration.sql) · read_file(packages/features/flags/hooks/index.ts) · grep(\"calendarsRouter|calendars:\", packages/trpc) · grep(\"viewerRouter|viewer:\", packages/trpc/server) · read_file(packages/trpc/server/routers/viewer/calendars/_router.tsx) · read_file(packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts)_\n\n> **Fix** — Route the handler through CalendarCache.init/initFromCredentialId so the mock is used when the flag is off, and gate deleteCacheHandler on the same flag.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. Delete-cache success leaves connectedCalendars query stale on hosts without onChanged**\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR22532__20260821/blob/5fd11f9faa79c4aefd39975d1b0e963e5034f793/packages/features/apps/components/CredentialActionsDropdown.tsx#L43-L47\n\n**2. CalendarCacheRepository instantiated directly, bypassing feature-flag gate**\n\nThe handler instantiates CalendarCacheRepository directly instead of going through CalendarCache.init(), which is the designed gate that checks the 'calendar-cache' feature flag and returns a mock when disabled. This means the DB query at calendar-cache.repository.ts:174 (prisma.calendarCache.groupBy) runs even when the flag is off, wasting a query and potentially failing if the calendarCache table doesn't exist in that environment.\n\nThe mock's getCacheStatusByCredentialIds is never reachable from any production caller. This may be intentional if the flag is always enabled in practice, but the code explicitly routes all other callers through the flag check.\n\n
\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR22532__20260821/blob/5fd11f9faa79c4aefd39975d1b0e963e5034f793/packages/prisma/migrations/20250715160635_add_calendar_cache_updated_at/migration.sql#L7-L11\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR22532__20260821/blob/5fd11f9faa79c4aefd39975d1b0e963e5034f793/packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts#L22-L26\n\n
\n", + "created_at": "2026-08-21T21:32:40Z" + }, + { + "path": "apps/web/package.json", + "line": 11, + "body": "### 🔵 Low · dev:cron switched from ts-node to npx tsx — tsx undeclared\n\nThe dev:cron script now invokes npx tsx, but tsx is not declared in the package manifest (dependencies or devDependencies), and a repo-wide grep confirms no tsx entry in any package.json or yarn.lock. The previous tool ts-node remains declared at line 196. When tsx is not resolvable locally, npx fetches an unpinned latest version from the registry, which in non-interactive shells prompts interactively and can hang or abort, and the toolchain version becomes non-deterministic. This may be intentional if maintainers accept unpinned dev tooling, but the mechanism is real and sits on a changed line.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). apps/web/package.json:11 declares `\"dev:cron\": \"npx tsx cron-tester.ts\"` while `ts-node` remains declared at apps/web/package.json:196. Grep of every **/package.json for \"tsx\" returned only `.tsx` file-extension patterns and the dev:cron line itself — no `tsx` package declaration exists in the repo. yarn.lock (single lockfile, confirmed by glob) contains zero `tsx` entries (`^tsx@`, `tsx@npm`, `\"tsx\"` all empty) while `ts-node` is resolved in it (lines 2584, 3648, 3799, 4171, 9441, 9513, 31655, 38374); the only esbuild entries are platform binaries, not tsx. apps/web/cron-tester.ts exists — a local CronJob tool hitting `http://localhost:3000/api` cron endpoints. No CI workflow and no turbo.json pipeline references dev:cron (grep of **/*.yml and read of turbo.json both empty for it) — it is a manually-invoked developer script. .yarnrc.yml pins yarn 3.4.1 with nodeLinker node-modules; npx comes from PATH and, with tsx absent from the entire dependency tree, must fetch from the registry on every run.\n_Impact: Every `yarn dev:cron` run makes npx fetch an unpinned latest tsx from the registry — the dev tool's runtime version is non-deterministic and can silently break on a future tsx release — and in non-interactive shells npx may prompt/hang or abort, leaving the cron-testing tool unable to run for the developer who invokes it._\n_Queries: read_file(apps/web/package.json) · grep(\"tsx\", glob=**/package.json) · grep(\"^tsx@|^ts-node@|^ tsx|tsx@\", glob=yarn.lock) · grep(\"tsx\", glob=**/yarn.lock) · glob(\"**/yarn.lock\") · grep(\"ts-node\", glob=yarn.lock) · grep('\"tsx\"', glob=**/package.json) · grep(\"^tsx[^a-zA-Z]\", glob=yarn.lock) · grep(\"esbuild|tsx@npm\", glob=yarn.lock) · grep(\"dev:cron\") · grep(\"dev:cron|cron-tester\", glob=**/*.yml) · read_file(turbo.json) · glob(\"**/cron-tester*\") · read_file(apps/web/cron-tester.ts) · read_file(.yarnrc.yml)_\n\n> **Fix** — Add tsx to devDependencies with a pinned version, or use yarn dlx tsx@ to keep the toolchain reproducible.\n", + "created_at": "2026-08-21T21:32:40Z" + }, + { + "path": "scripts/test-gcal-webhooks.sh", + "line": 17, + "body": "### 🔵 Low · test-gcal-webhooks.sh: paths resolve against caller CWD, cleanup always exits 0 masking failures, and reuse branch reads a tunnel log it never created — script needs self-contained path resolution, real exit codes, and owned-log handling\n\nThe cleanup function always exits 0, so every failure path (rate limit, extract timeout/failure, reuse with missing log) returns success. A caller or CI sees a green run for a run that did nothing. The error is intentional, but the success status is not.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). scripts/test-gcal-webhooks.sh:17 — `exit 0` is the unconditional final statement of `cleanup()`. All failure paths terminate through it: line 49 (rate-limit branch calls `cleanup`), line 58 (URL-extract-failure branch calls `cleanup`), lines 41–52 (10s wait loop times out, `TUNNEL_URL` empty → line 56 → `cleanup`), line 30 (reuse path, `extract_url_from_log` empty on stale/missing log → line 56 → `cleanup`). No failure branch carries a non-zero status. grep over `.github` and `package.json` found no automated invocation of the script; only engine-tuning-analysis.md:14 and classify/pr-analysis.md:93 reference it, as a manual bash test script for verifying GCal webhooks. On every failure path the script exits before the env-file update at lines 61–71, so GOOGLE_WEBHOOK_URL is left stale/absent.\n_Impact: Every failure path returns exit status 0, so any automation or CI gating on this webhook-verification script's exit code receives a false green after a run that established no tunnel; downstream steps then run against the stale/absent GOOGLE_WEBHOOK_URL (env file is never updated on failure), so webhook testing silently does nothing while reporting success — the test-cannot-fail harm class. The ❌ messages (lines 47–48, 57) are visible only to a human watching the terminal._\n_Queries: read_file(path=\"scripts/test-gcal-webhooks.sh\") · grep(pattern=\"test-gcal-webhooks\") · grep(pattern=\"test-gcal-webhooks|gcal-webhooks\", path=\".github\") · grep(pattern=\"test-gcal|gcal|tmole|tunnelmole\", path=\"package.json\") · grep(pattern=\"tunnelmole|tmole\", max_results=40) · read_file(path=\"engine-tuning-analysis.md\") · read_file(path=\"classify/pr-analysis.md\", offset=80, limit=30)_\n\n> **Fix** — Change cleanup to propagate the actual exit status, or explicitly exit non-zero on error paths.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. reuse branch extracts tunnel URL from a log it did not create or truncate**\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR22532__20260821/blob/5fd11f9faa79c4aefd39975d1b0e963e5034f793/scripts/test-gcal-webhooks.sh#L28-L32\n\n**2. ENV_FILE resolves against the caller's CWD, not the script's location**\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR22532__20260821/blob/5fd11f9faa79c4aefd39975d1b0e963e5034f793/scripts/test-gcal-webhooks.sh#L3-L7\n\n
\n", + "created_at": "2026-08-21T21:32:40Z" + }, + { + "path": "packages/features/apps/components/CredentialActionsDropdown.tsx", + "line": 89, + "body": "### 🟡 Medium · A locale-sensitive date/time formatter is given a hardcoded locale string (e.g. 'en-US'), so every user sees the same fixed formatting regardless of their own locale (cal.com-22532). Pass the requester's locale, or omit the locale argument to use the runtime default.\n\n\nA locale-sensitive date/time formatter is given a hardcoded locale string (e.g. 'en-US'), so every user sees the same fixed formatting regardless of their own locale (cal.com-22532). Pass the requester's locale, or omit the locale argument to use the runtime default.\n\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). CredentialActionsDropdown.tsx:89-92 executes `new Intl.DateTimeFormat(\"en-US\", { dateStyle:\"short\", timeStyle:\"short\" }).format(new Date(cacheUpdatedAt))` — a locale-sensitive formatter with the locale hardcoded to 'en-US'. The component already calls `useLocale()` at :37, and useLocale.ts:10-14/:39-41 returns the per-user `i18n` instance (user's `language`), so the user's locale is in scope and ignored by the formatter. The formatter path is guarded only by `hasCache = isGoogleCalendar && cacheUpdatedAt` (:69), i.e., the normal Google-Calendar cache-status flow, not a dead branch. The component is mounted at SelectedCalendarsSettingsWebWrapper.tsx:71 and :124, which is itself mounted at apps/web/components/apps/CalendarListContainer.tsx:112 (web Calendar Settings) and packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx:372 (event-type advanced tab) — both live client-side settings entry points.\n_Impact: Users whose locale is not en-US see the \"cache last updated\" timestamp in fixed US format (mm/dd/yy, 12-hour AM/PM) instead of their own locale — a wrong, user-visible display result (ambiguous date reading); no attacker needed, low severity._\n_Queries: read_file(packages/features/apps/components/CredentialActionsDropdown.tsx) · grep(CredentialActionsDropdown) · grep(cacheUpdatedAt, path=packages/features/apps) · read_file(packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx, offset=55, limit=90) · grep(SelectedCalendarsSettingsWebWrapper) · read_file(packages/lib/hooks/useLocale.ts)_\n\n", + "created_at": "2026-08-21T21:32:40Z" + }, + { + "path": "packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts", + "line": 25, + "body": "### 🔵 Low · deleteCache handler throws plain Error causing tRPC 500 instead of 403/404\n\nThe handler throws a plain Error for the expected not-found/not-owned condition. tRPC maps uncaught non-TRPCError to INTERNAL_SERVER_ERROR (500), making the client-expected condition indistinguishable from a server fault. Triggered by any stale or non-owned credentialId (e.g., credential disconnected in another tab, or credential removed leaving orphaned calendarCache rows so the UI still offers the action).\n\nConfidence ~60.\n\n> **Fix** — Throw TRPCError with code NOT_FOUND or FORBIDDEN instead of plain Error.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Plain Error thrown for authz failure instead of TRPCError**\n\nThe handler throws a plain Error for a client-elicitable condition (credentialId not owned or stale). In tRPC, a non-TRPCError is formatted as INTERNAL_SERVER_ERROR (HTTP 500) rather than NOT_FOUND/UNAUTHORIZED, diverging from sibling handler convention.\n\nThe denial itself is correct — the ownership check properly scopes by userId — so this is an error-code/observability defect, not a security hole. This may be intentional if the exact status code is not considered important, but it degrades API semantics for clients.\n\n
\n", + "created_at": "2026-08-21T21:32:40Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Direct construction of CalendarCacheRepository bypasses feature flag, leaving mock dead code and flag-off queries/deletes active — Calendar cache treated as unconditionally active/fresh: flag bypasses construction and migration backfills updatedAt, … | `packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts:28` |\n| 🔵 Low | dev:cron switched from ts-node to npx tsx — tsx undeclared | `apps/web/package.json:11` |\n| 🔵 Low | test-gcal-webhooks.sh: paths resolve against caller CWD, cleanup always exits 0 masking failures, and reuse branch reads a tunnel log it never created — script needs self-contained path resolution, real exit codes, and owned-log handling | `scripts/test-gcal-webhooks.sh:17` |\n| 🟡 Medium | A locale-sensitive date/time formatter is given a hardcoded locale string (e.g. 'en-US'), so every user sees the same fixed formatting regardless of their own locale (cal.com-22532). Pass the requester's locale, or omit the locale argument to use the runtime default. | `packages/features/apps/components/CredentialActionsDropdown.tsx:89` |\n| 🔵 Low | deleteCache handler throws plain Error causing tRPC 500 instead of 403/404 | `packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts:25` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 98 (A+) | -1.9 |\n| Consistency | 97 (A+) | 97 (A+) | -0.0 |\n| **Overall** | **75 (B-)** | **74 (B-)** | **-0.3** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:40Z" + } + ] } ] }, @@ -61993,6 +63463,42 @@ "created_at": "2026-06-28T23:11:51Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/8330", + "review_comments": [ + { + "path": "packages/trpc/server/routers/viewer/slots.ts", + "line": 133, + "body": "### 🟡 Medium · Override-day range/overlap logic flawed: conversion formulas disagree between layers, straddling slots accepted, disjoint same-day overrides reject valid slots, and busy check bypassed on override days\n\nThe new override block returns true before the busy.every check, so existing calendar/accepted bookings inside the override window no longer block the slot. This is a regression versus the pre-PR code where busy.every was the only gate. The override is about availability windows, not about ignoring bookings; nothing indicates this bypass is intended.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). In checkIfIsAvailable (packages/trpc/server/routers/viewer/slots.ts), `if (dateOverrideExist) { return true; }` at slots.ts:133-135 executes for any slot on a date-override day inside the override window; the `busy.every(...)` check at slots.ts:153 is unreachable on that path. The override block (102-135) references no `busy` value. `busy` comes from ACCEPTED bookings + connected-calendar busy times (packages/core/getBusyTimes.ts:75-149, `status: in [ACCEPTED]` at :70-73); `dateOverrides` come from availability rows with a `date` (packages/core/getUserAvailability.ts:215-224; eventType-level availability at slots.ts:269-276). Nothing correlates or excludes them. Override semantics confirmed as working-hours replacement, not conflict clearing: packages/lib/slots.ts:205-246 splices out normal computed availability, pushes the override window; never touches busy. No guard or test asserts the bypass is intended: the only override test (apps/web/test/lib/getSchedule.test.ts:742-805, `IstWorkHoursWithDateOverride`) uses an override day with no bookings. Reachability: `checkIfIsAvailable` ← `getSchedule` (slots.ts:488, :513, :581) ← public tRPC procedure `slotsRouter.getSchedule` (slots.ts:186) ← public booking widget `trpc.viewer.public.slots.getSchedule.useQuery` (apps/web/components/booking/SlotPicker.tsx:46).\n_Impact: Slots overlapping existing accepted bookings/calendar events are presented as available on override days → double-booking / failed booking attempt; wrong availability in normal operation._\n_Queries: read_file(packages/trpc/server/routers/viewer/slots.ts) · glob(\"**/getUserAvailability*\") · read_file(packages/core/getUserAvailability.ts) · read_file(packages/core/getBusyTimes.ts) · read_file(packages/lib/slots.ts) · grep(\"dateOverride|checkIfIsAvailable\", \"*.test.ts\") · read_file(apps/web/test/lib/getSchedule.test.ts, offset=740) · grep(\"slotsRouter|getSchedule\", apps/web) · read_file(apps/web/components/booking/SlotPicker.tsx, offset=40) · read_file(findings-date-override-fixes.md) · read_file(findings.md)_\n\n> **Fix** — Move the busy.every check before the override return, or ensure busy events still block slots even when an override matches.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR8330__20260821/blob/ee38fd295fd294b9fc787eba482bde24bbfea69b/packages/trpc/server/routers/viewer/slots.ts#L116-L120\n\n
\n", + "created_at": "2026-08-21T21:32:23Z" + }, + { + "path": "packages/trpc/server/routers/viewer/slots.ts", + "line": 141, + "body": "### 🟠 High · Working-hours re-check compares the slot's UTC clock against host-local working hours (no timezone normalization)\n\nThe slot clock is UTC while working hours are host-local, so any non-UTC host gets their morning or afternoon wrongly blocked. This breaks ordinary bookings for any non-UTC organizer. It might be intentional if all hosts were UTC, but the code supports arbitrary timezones.\n\n> **Fix** — Normalize the slot time to the organizer's timezone before comparing against working hours, similar to the date-override check.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Working-hours filter compares UTC slot time against schedule-local minutes**\n\nThe slot's UTC clock (slotStartTime = time.utc()) is compared against workingHour.startTime/endTime which are schedule-local minutes. No conversion on either side, so valid slots are falsely rejected whenever the schedule timezone differs from UTC. This is a regression vs the pre-PR code, which had no such filter.\n\nIt might be intentional if the schedule timezone is always UTC, but the sibling conversion at packages/lib/slots.ts:163-176 establishes the schedule-local convention.\n\n
\n", + "created_at": "2026-08-21T21:32:23Z" + }, + { + "path": "packages/trpc/server/routers/viewer/slots.ts", + "line": 142, + "body": "### 🟡 Medium · Working-hours gate never validates slot end\n\nThe `end` variable is a verbatim copy of `start` (`slotStartTime.hour() * 60 + slotStartTime.minute()`), so `end > workingHour.endTime` is identical to `start > workingHour.endTime`. The gate never validates the slot's end, allowing slots that start inside working hours but end after them to pass. Trigger: start 16:50, eventLength 30, workingHour.endTime 1020 (17:00) → start/end = 1010 ≤ 1020 → no reject → a team-event slot books this host 16:50-17:20, past clock-out.\n\n**Advisory** — the proof pass refuted the claim as stated on triggerability, but established reachability and harm. Shown for a second look; it will not block a merge. slots.ts:141–142 compute identical values (start and end both from slotStartTime), so line 143's `end > workingHour.endTime` re-tests the slot start; the slot end is never validated. However, packages/lib/slots.ts:90 caps every generated slot start at `endTime + 1 - eventLength`; for the claimed trigger (endTime 1020, eventLength 30), the last generated slot starts at 990 and ends exactly at 1020, so no slot ending after workingHour.endTime is ever produced. The reachable path is publicProcedure.query `getSchedule` (slots.ts:185, mounted viewer.tsx:162) → `checkIfIsAvailable` (slots.ts:488/513/581).\n_Impact: A host offered/booked outside their working hours — incorrect availability data for invitees and hosts. (Non-blocking, since triggerability is refuted.)_\n_Queries: read_file(packages/trpc/server/routers/viewer/slots.ts) · read_file(packages/lib/slots.ts) · grep(\"checkIfIsAvailable\", packages) · grep(\"slotsRouter\", packages) · read_file(packages/core/getUserAvailability.ts) line 210 + read_file(packages/lib/availability.ts) lines 80–85 + read_file(packages/core/getAggregateWorkingHours.ts) lines 17–19_\n\n> **Fix** — Compute `end` from `slotStartTime` plus the event length (e.g., `slotStartTime.add(eventLength, 'minute')`), then compare that against `workingHour.endTime`.\n\n---\n\n
2 related findings reported here, same root cause\n\n**1. `end` duplicates `start`, so the slot's duration is ignored in the working-hours end check**\n\nBoth lines compute the identical value from slotStartTime, so the end check tests the slot start against the end boundary. A slot that begins inside working hours but extends past endTime is not rejected.\n\nThis is a copy-paste error; it might be intentional if duration was meant to be ignored, but the variable name and the presence of slotEndTime indicate otherwise.\n\n**2. Copy-paste error: end variable is identical to start, making slot-end validation dead**\n\nThe `end` variable is byte-identical to `start`, so the condition `end > workingHour.endTime` at line 143 is dead — the slot end is never validated. This means slots that start within working hours but end after them are incorrectly accepted.\n\nIt might be intentional if slot durations are always within working hours, but the code clearly intends to check both bounds.\n\n
\n", + "created_at": "2026-08-21T21:32:23Z" + }, + { + "path": "packages/trpc/server/routers/viewer/slots.ts", + "line": 114, + "body": "### 🟡 Medium · `===` between two freshly-constructed objects is always false\n\nBoth sides of this `===` are new object instances (`dayjs(date.start).add(utcOff…` and `dayjs(date.end).add(utcOffse…`). Reference equality on two distinct fresh objects (dayjs/moment/new Date/new URL) is never true, so this comparison is a constant and the guarded branch is dead.\n\n> **Fix** — Compare values, not references — e.g. `a.isSame(b)` / `a.getTime() === b.getTime()` / `a.toString() === b.toString()`.\n", + "created_at": "2026-08-21T21:32:23Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Override-day range/overlap logic flawed: conversion formulas disagree between layers, straddling slots accepted, disjoint same-day overrides reject valid slots, and busy check bypassed on override days | `packages/trpc/server/routers/viewer/slots.ts:133` |\n| 🟠 High | Working-hours re-check compares the slot's UTC clock against host-local working hours (no timezone normalization) | `packages/trpc/server/routers/viewer/slots.ts:141` |\n| 🟡 Medium | Working-hours gate never validates slot end | `packages/trpc/server/routers/viewer/slots.ts:142` |\n| 🟡 Medium | `===` between two freshly-constructed objects is always false | `packages/trpc/server/routers/viewer/slots.ts:114` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 92 (A) | -7.5 |\n| Consistency | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **78 (B)** | **77 (B)** | **-1.3** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:23Z" + } + ] } ] }, @@ -63231,6 +64737,30 @@ "created_at": "2026-06-28T23:31:37Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/14943", + "review_comments": [ + { + "path": "packages/features/ee/workflows/api/scheduleSMSReminders.ts", + "line": 195, + "body": "### 🔵 Low · catch increments retryCount for non-twilio errors\n\nThe catch at lines 189-198 wraps the entire try block, so retryCount + 1 fires for bookingMetadataSchema.parse, template rendering, profile.findFirst, getBookerBaseUrl, and getCalEventResponses — none of which is an SMS send failure. Two such errors permanently delete the reminder via the retryCount > 1 branch even though twilio was never attempted, e.g., a permanent template defect drops the attendee's reminder after two cron runs.\n\nIt may be intentional if 'any failed attempt to schedule' is the intended trigger, but the counter's stated purpose is SMS retries.This comment also covers: twilio queued before DB records referenceId; catch counts queued send as failure\n\n> **Fix** — Move the retryCount increment to only wrap the twilio.scheduleSMS call, or check the error type before incrementing.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR14943__20260821/blob/c790227e0cda780e6fea9bb03af27948d9e286b9/packages/features/ee/workflows/api/scheduleSMSReminders.ts#L157-L161\n\n
\n", + "created_at": "2026-08-21T21:32:32Z" + }, + { + "path": "packages/features/ee/workflows/api/scheduleSMSReminders.ts", + "line": 179, + "body": "### 🟠 High · Non-atomic increment of retryCount — lost updates under concurrency\n\nThe update payload sets `retryCount` from a previously read value (`….retryCount + 1`). Two concurrent executions read the same value and both write the same result — one increment is lost. This is a read-modify-write race on persisted state.\n\n> **Fix** — Use the ORM's atomic form (Prisma: `retryCount: { increment: 1 }`; SQL: `SET retryCount = retryCount + 1`) or wrap read+write in a transaction with row locking.\n", + "created_at": "2026-08-21T21:32:32Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | catch increments retryCount for non-twilio errors | `packages/features/ee/workflows/api/scheduleSMSReminders.ts:195` |\n| 🟠 High | Non-atomic increment of retryCount — lost updates under concurrency | `packages/features/ee/workflows/api/scheduleSMSReminders.ts:179` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 94 (A) | -6.4 |\n| **Overall** | **80 (B+)** | **79 (B)** | **-1.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:33Z" + } + ] } ] }, @@ -64407,6 +65937,36 @@ "created_at": "2026-06-28T22:40:20Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/22345", + "review_comments": [ + { + "path": "packages/lib/server/service/insightsBooking.ts", + "line": 40, + "body": "### 🔵 Low · Org-scope insights exclude personal bookings when org has no child teams\n\nWhen teamsFromOrg.length === 0, userIdsFromOrg is forced to [] even though teamIds already includes options.orgId and the membership query would return org-level members. This suppresses the personal-booking filter arm, so org-scope insights omit org members' personal bookings (including the admin's own) for orgs without child teams, while the same bookings appear once a child team exists.\n\nThis is a data under-reporting inconsistency. It might be intentional if the guard predates the conversion PR, but the rewritten-function metrics (cyclo 3→4, lines 40→31) and the untested org-scope path make that unlikely.\n\n> **Fix** — Remove the early assignment of userIdsFromOrg to [] when teamsFromOrg.length === 0, or restructure the condition so the personal-booking arm is evaluated whenever teamIds includes the orgId. Add an integration test covering the teamsFromOrg.length === 0 path.\n", + "created_at": "2026-08-21T21:32:16Z" + }, + { + "path": "packages/lib/server/service/insightsBooking.ts", + "line": 119, + "body": "### 🟡 Medium · New toEqual assertions compare nested Prisma.Sql against flat Prisma.Sql\n\nThe service builds multi-arm conditions via conditions.reduce(...) which nests Sql instances as values of an outer Sql (e.g., Prisma.sql`(${acc}) AND (${condition})` at lines 119-122, 171-174, 204-207). The new test expectations use flat template literals (e.g., test lines 295-299, 332-343, 429-431).\n\nVitest's toEqual performs deep structural comparison of own enumerable properties; two Sql instances with different texts/values arrays (one holding nested Sql objects, the other holding raw numbers) are not equal under the standard @prisma/client runtime where flattening happens only at query execution. This causes the three multi-arm assertions to fail.\n\nIt might be intentional if the runtime flattens at construction, but the structural asymmetry is demonstrated by the changed code itself.\n\n> **Fix** — Verify the @prisma/client runtime behavior; if it does not flatten at construction, update the test expectations to match the nested Sql structure or use a SQL-normalizing comparison.\n", + "created_at": "2026-08-21T21:32:16Z" + }, + { + "path": "packages/lib/server/service/insightsBooking.ts", + "line": 74, + "body": "### 🟡 Medium · Condition `authConditions` is always truthy — arms below are unreachable\n\n`authConditions` is bound to `getAuthorizationConditions()`, whose declared return type is a non-nullable object type: it can never be null/undefined, and a non-nullish object is always truthy. This branch always takes, so every else/else-if arm after it is dead code — if those arms encode real fallbacks, the guard (or the callee's return type) is wrong.\n\n> **Fix** — If `getAuthorizationConditions()` can genuinely return a nullish value, declare it (`| null`) so the narrowing is real; otherwise delete the dead arms or replace the truthiness test with the intended emptiness check.\n", + "created_at": "2026-08-21T21:32:16Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Org-scope insights exclude personal bookings when org has no child teams | `packages/lib/server/service/insightsBooking.ts:40` |\n| 🟡 Medium | New toEqual assertions compare nested Prisma.Sql against flat Prisma.Sql | `packages/lib/server/service/insightsBooking.ts:119` |\n| 🟡 Medium | Condition `authConditions` is always truthy — arms below are unreachable | `packages/lib/server/service/insightsBooking.ts:74` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 99 (A+) | -0.8 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.1 |\n| Consistency | 97 (A+) | 97 (A+) | -0.0 |\n| **Overall** | **75 (B-)** | **75 (B-)** | **-0.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:16Z" + } + ] } ] }, @@ -66706,6 +68266,78 @@ "created_at": "2026-06-28T23:17:49Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/11059", + "review_comments": [ + { + "path": "apps/web/pages/api/webhook/app-credential.ts", + "line": 31, + "body": "### 🔵 Low · Webhook app-credential handler is fundamentally flawed: case-sensitive secret-header lookup, unvalidated JSON overwrites credential key, uncaught throws on GET/malformed payloads cause 500s, and non-atomic findFirst→create TOCTOU on concurrent deliv…\n\nThe handler performs three throw-prone operations on network-supplied input with no try/catch and no HTTP-method gate. Next.js leaves req.body undefined on GET/PUT/DELETE or non-JSON bodies; z.object().parse(undefined) throws an uncaught ZodError → bare 500.\n\nJSON.parse(symmetricDecrypt(reqBody.keys, ...)) throws SyntaxError on non-JSON plaintext; tampered ciphertext or wrong key throws inside decrypt. Both uncaught → 500.\n\nBecause the sender-side sync can push garbage, these 500s are not merely adversarial — a permanent 500 on malformed input makes webhook senders retry a non-transient error indefinitely. This may be intentional if the sender is trusted to always send valid data, but the lack of a method gate and error containment is a defect.\n\n> **Fix** — Add an HTTP method check (e.g., only allow POST) and wrap the parse/decrypt operations in try/catch, returning a 400 on malformed input.\n\n---\n\n
This same fix applies at 3 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/apps/web/pages/api/webhook/app-credential.ts#L23-L27\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/apps/web/pages/api/webhook/app-credential.ts#L70-L74\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/apps/web/pages/api/webhook/app-credential.ts#L60-L64\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/salesforce/lib/CalendarService.ts", + "line": 75, + "body": "### 🔵 Low · salesforce: unconditional refresh on every construction is a new network/DB dependency at construction time\n\nthis.conn = this.getClient(credential) runs the new fetch POST + prisma.credential.update on every SalesforceCalendarService construction, regardless of token validity (no expiry check), and throws if the endpoint is unreachable — where previously construction was purely local and jsforce refreshed lazily. Additionally the jsforce.Connection is built from the old in-memory credentialKey, so freshly refreshed tokens are only picked up on the next construction.\n\nThis may be intentional to ensure fresh tokens, but it introduces a network/DB dependency at construction time.\n\n> **Fix** — Add an expiry check before refreshing, and build the Connection from the refreshed tokens.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Salesforce refresh bypasses refreshOAuthTokens helper**\n\nThe new salesforce refresh path performs a direct fetch to the OAuth token endpoint and never calls the PR's own refreshOAuthTokens helper, unlike the sibling apps (webex, zoho-bigin) modified in the same PR. In deployments with APP_CREDENTIAL_SHARING_ENABLED and CALCOM_CREDENTIAL_SYNC_ENDPOINT set, this breaks: the direct fetch requires self-hosted consumer keys (which synced deployments lack, causing HttpError 400), and parseRefreshTokenResponse switches to minimumTokenResponseSchema when sync env vars are set, which either rejects the response or strips instance_url, scope, token_type, id, issued_at, signature from the stored credential, breaking subsequent jsforce connections. The asymmetry may be deliberate, but nothing in the code or diff signals that, and the PR explicitly modified salesforce as part of this feature.\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/hubspot/lib/CalendarService.ts", + "line": 177, + "body": "### 🔴 Critical · Calendar-service refresh mishandles fetch Response as parsed token: HubSpot destroys stored credentials by treating Response as HubspotToken, Lark throws on every refresh in sync mode\n\nThe refreshOAuthTokens helper returns a raw fetch Response in its sync branch, but the HubSpot call site assigns it to a HubspotToken and dereferences expiresIn, accessToken, and persists the whole object into the credential key. When the sync feature is enabled, this yields NaN expiry, overwrites the stored token with {}, and sets an undefined access token, breaking all subsequent HubSpot API calls.\n\nThis may be intentional only if the sync branch is never expected to run, but the feature configuration this PR enables makes that state the normal one.This comment also covers: Hubspot sync-mode shape breakage (confirmed)This comment also covers: HubSpot CalendarService token corruption due to refreshOAuthTokens raw ResponseThis comment also covers: Office365 Video token corruption due to refreshOAuthTokens raw ResponseThis comment also covers: Webex token refresh fails due to refreshOAuthTokens raw ResponseThis comment also covers: Zoho Bigin CalendarService TypeError crash due to refreshOAuthTokens raw Response\n\n> **Fix** — In the HubSpot call site, check whether the return value is a Response (e.g., via instanceof Response) and handle it by parsing the body or calling the appropriate error handler, matching the sibling call sites. Alternatively, change the helper to always return a parsed token shape.\n\n---\n\n
This same fix applies at 5 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/hubspot/lib/CalendarService.ts#L190-L194\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/office365video/lib/VideoApiAdapter.ts#L59-L63\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/webex/lib/VideoApiAdapter.ts#L60-L64\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/zoho-bigin/lib/CalendarService.ts#L83-L87\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/larkcalendar/lib/CalendarService.ts#L82-L86\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/salesforce/lib/CalendarService.ts", + "line": 96, + "body": "### 🟠 High · Salesforce CalendarService is broken: statusText branched on as success test (empty on HTTP/2/3) and undefined `prisma` binding from missing import\n\nThe new refresh block calls `prisma.credential.update(...)` but the file has no `prisma` import. Every sibling service imports it explicitly. On any Salesforce calendar operation, the constructor runs `getClient`, which reaches line 96 and throws `ReferenceError: prisma is not defined`, rejecting `this.conn` and failing the operation. This is new code added by the diff and also a TS2304 compile error. It could be intentional only if a global `prisma` were expected, but no such global exists in this codebase.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). Missing prisma import: CalendarService.ts:96 uses bare `prisma.credential.update` but file imports (lines 1-20) contain no @calcom/prisma import; sibling services import it (googlecalendar:11, basecamp3:2, zoho-bigin:6). Runtime ReferenceError in production: packages/prisma/index.ts:56-60 assigns globalThis.prisma only when NODE_ENV !== \"production\". Stale token: lines 96-99 persist fresh token, lines 101-108 use pre-refresh credentialKey.*. Reachable via _utils/getCalendar.ts:26-47 -> app-store/index.ts:18 -> salesforce/lib/index.ts:1; callers at EventManager.ts:581, getCalendarsEvents.ts:18, CalendarManager.ts:143/224/279/330.\n_Impact: Every production Salesforce calendar booking/availability operation fails with ReferenceError: prisma is not defined, and the refreshed token never reaches the database; the stale-token connection construction is a real secondary defect whose standalone auth impact is masked in dev by jsforce auto-refresh and is unreachable in production behind the line-96 crash._\n_Queries: read_file(path=packages/app-store/salesforce/lib/CalendarService.ts) · grep(pattern=prisma, path=packages/app-store/salesforce) · grep(pattern=import prisma, path=packages/app-store/salesforce) · grep(pattern=prisma\\.credential, glob=**/*.ts, max_results=40) · grep(pattern=declare (global|const|var) prisma|globalThis\\.prisma, glob=**/*.ts, max_results=30) · read_file(path=packages/prisma/index.ts) · grep(pattern=declare global, glob=**/*.ts, max_results=40) · read_file(path=packages/app-store/googlecalendar/lib/CalendarService.ts, limit=60) · read_file(path=packages/app-store/basecamp3/lib/CalendarService.ts, limit=30) · read_file(path=packages/app-store/zoho-bigin/lib/CalendarService.ts, limit=25) · grep(pattern=SalesforceCalendarService, glob=**/*.ts, max_results=30) · read_file(path=packages/app-store/_utils/getCalendar.ts) · read_file(path=packages/app-store/index.ts) · read_file(path=packages/app-store/salesforce/lib/index.ts) · read_file(path=packages/app-store/salesforce/api/add.ts)_\n\n> **Fix** — Add `import prisma from \"@calcom/prisma\";` to the import block at the top of the file.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. A response's statusText is branched on as the success test. statusText is a non-normative reason phrase: HTTP/2 and HTTP/3 removed it from the wire entirely, so fetch() reports it as the empty string on those connections and a server may send any phrase it likes on HTTP/1.1. The branch then takes the wrong arm on a perfectly good response. Test response.ok, or compare response.status numerically; statusText is fine to log, only not to decide on (cal.com-11059).**\n\n**2. (packages/app-store/salesforce/lib/CalendarService.ts:86)**\n\n**3. Salesforce connection built from stale pre-refresh token**\n\nLines 96-99 persist the freshly refreshed token payload, but the live `jsforce.Connection` at lines 101-108 is constructed with `accessToken: credentialKey.access_token` and `instanceUrl: credentialKey.instance_url` — the pre-refresh values from `credential.key`. When the stored token is expired (the case that motivates a refresh), every subsequent Salesforce API call authenticates with the expired token, causing authentication failures.\n\nThis is a distinct mechanism on new lines added by the diff. It could be intentional only if the refresh were meant to be used later, but the connection is built immediately and uses the old values.\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR11059__20260821/blob/9fde0e906897cc0f4f71793f647dd629faba3317/packages/app-store/salesforce/lib/CalendarService.ts#L84-L88\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts", + "line": 8, + "body": "### 🟠 High · minimumTokenResponseSchema computed-key schema is broken: no numeric-expiry validation, and its output drops every field except access_token\n\nBoth computed keys evaluate to the same constant string, so the object has one duplicate key and the intended pattern matching never works. The effective schema only validates access_token and strips all other keys from the output.\n\nCallers that persist the parsed output in sync mode get NaN expiry (zoom, office365calendar) or lose expiry_date/client_id/client_secret (googlecalendar). This may be intentional if sync mode is never used, but the code path is reachable.\n\n> **Fix** — Use a proper zod schema with explicit fields and validation for the expiry.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Intended numeric-expiry validation is dead in parseRefreshTokenResponse minimum schema**\n\nThe computed keys [z.string().toString()] and [z.string().optional().toString()] resolve to class-constant strings (e.g., \"[object Object]\" or \"ZodString\") that no real token response property matches, and z.object strips unknown keys. Thus the z.number() requirement never applies to a real response, so expiry is never validated as a number, contradicting the comment's stated intent.\n\nThis may be intentional if the validation was meant to be lenient, but the comment indicates otherwise.\n\n**2. Sync-mode minimumTokenResponseSchema is not a catch-all — refresh validation broken**\n\nThe schema's 'catch-all' keys are computed object keys: [z.string().toString()]: z.number() and [z.string().optional().toString()]: z.unknown().optional(). In JS, a computed key evaluates to one fixed literal string, so it cannot match 'any property with a number' as the comment claims.\n\nWhen APP_CREDENTIAL_SHARING_ENABLED && CALCOM_CREDENTIAL_SYNC_ENDPOINT are set, a real token response is parsed against a schema demanding a literal key equal to zod's toString() output — either safeParse fails and line 22 throws, breaking zoom's refresh path, or the two computed keys collide and expires_in is left unvalidated (undefined → NaN expiry, token never refreshed). Both outcomes are broken relative to the comment's intent.\n\nThe exact manifestation depends on zod's toString() which is not vendored, so the sub-mode cannot be pinned. It might be intentional if the sync mode is not yet used in production, but the comment claims catch-all behavior that the code does not deliver.\n\n**3. minimumTokenResponseSchema requires a literal zod-type-named key, so the sync channel always throws**\n\nThe schema uses computed object keys whose names are the string form of the zod type (e.g. \"ZodString\"), creating a required literal key in the parsed object instead of the intended catch-all.\n\nAny realistic token response from the sync endpoint lacks that key, so safeParse fails and the code throws \"Invalid refreshed tokens were returned\". This breaks the new feature in any deployment with APP_CREDENTIAL_SHARING_ENABLED and CALCOM_CREDENTIAL_SYNC_ENDPOINT set, including the direct-Zoom fallback.\n\nIt might be intentional if the sync endpoint is guaranteed to return such a key, but the comment at refreshOAuthTokens.ts:7 says the response \"should only contain the access token and expiry date\", which would not include that key.\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/zoho-bigin/lib/CalendarService.ts", + "line": 93, + "body": "### 🟡 Medium · zoho-bigin passes Credential.id instead of cal.com user id to credential sync helper\n\nThe helper's third parameter is a cal.com user id (named userId, serialized as calcomUserId, and consumed as a User.id by the webhook). The zoho-bigin call site passes credentialId (the Credential record's DB primary key) while its sibling caller webex correctly passes credential.userId.\n\nWhen APP_CREDENTIAL_SHARING_ENABLED and CALCOM_CREDENTIAL_SYNC_ENDPOINT are set and a zoho-bigin token expires, the sync endpoint receives a Credential.id as calcomUserId; prisma.user.findUnique returns null (404 User not found) so the sync silently fails, or in a degenerate case writes refreshed keys to the wrong user's credential. This may be intentional if the endpoint were changed to accept Credential.id, but the schema and webex caller indicate it expects User.id.\n\n> **Fix** — Change the third argument at this call site from credentialId to credential.userId, matching the webex caller and the helper's documented parameter.\n\n---\n\n
3 related findings reported here, same root cause\n\n**1. Zoho-bigin sends credentialId as calcomUserId to sync endpoint**\n\nThe code passes `credentialId` (captured from `credential.id`) as the third argument to `refreshOAuthTokens`, whereas webex/zoom/zohocrm all pass `credential.userId`. In sync mode, `calcomUserId: userId.toString()` therefore sends the credential row id, not the user id, to the sync endpoint.\n\nThis is a distinct wrong-identifier mechanism on a changed line. It could be intentional only if the sync endpoint expected the credential id, but the other three migrated callers all use the user id, indicating the intended contract.\n\n**2. zoho-bigin passes credential id where a user id belongs**\n\nrefreshOAuthTokens' third parameter is userId, sent to the sync endpoint as calcomUserId. Bigin passes credentialId (defined as credential.id, the credential id) while webex correctly passes credential.userId.\n\nIn sync mode the endpoint receives a credential id in the calcomUserId field, so any user lookup keyed on it resolves to the wrong record or 404s. The impact is currently masked by the already-established tokenInfo.data failure on the same path, but it is a distinct wrong-value argument on a changed line.\n\n**3. zoho-bigin passes credential.id as calcomUserId instead of credential.userId**\n\nThe sync refresh call passes credentialId (credential.id, a credential primary key) as the third argument, which is named userId and serialized as calcomUserId. The receiving webhook validates this field as a user id via prisma.user.findUnique.\n\nWith the feature enabled, the platform cannot resolve the user's credential, so the sync refresh for zoho-bigin is broken. This may be intentional if the endpoint expects a credential id, but all sibling sites pass credential.userId and the parameter name and webhook schema indicate a user id is expected.\n\n
\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/googlecalendar/lib/CalendarService.ts", + "line": 94, + "body": "### 🟠 High · `.data` read on fetch Response `res` — always undefined\n\n`res` is a WHATWG fetch `Response`, which has no `data` property (that is axios's envelope). `res.data` is always `undefined`, so every consumer sees an empty result.\n\n> **Fix** — Parse the body first: `const body = await res.json()` and read fields from `body`.\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/zoho-bigin/lib/CalendarService.ts", + "line": 96, + "body": "### 🟠 High · `.data` read on fetch Response `tokenInfo` — always undefined\n\n`tokenInfo` is a WHATWG fetch `Response`, which has no `data` property (that is axios's envelope). `tokenInfo.data` is always `undefined`, so every consumer sees an empty result.\n\n> **Fix** — Parse the body first: `const body = await tokenInfo.json()` and read fields from `body`.\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/zohocrm/lib/CalendarService.ts", + "line": 217, + "body": "### 🟠 High · `.data` read on fetch Response `zohoCrmTokenInfo` — always undefined\n\n`zohoCrmTokenInfo` is a WHATWG fetch `Response`, which has no `data` property (that is axios's envelope). `zohoCrmTokenInfo.data` is always `undefined`, so every consumer sees an empty result.\n\n> **Fix** — Parse the body first: `const body = await zohoCrmTokenInfo.json()` and read fields from `body`.\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": "packages/app-store/_utils/oauth/refreshOAuthTokens.ts", + "line": 8, + "body": "### 🔵 Low · Sync-channel refresh request carries no authentication and its unverified response is persisted as the user's live credential\n\nThe sync-channel refresh POST sends no authentication header or secret, and the response body is trusted and written into prisma.credential.key without verifying the origin. A compromised or MITM'd sync endpoint could inject arbitrary access_token/refresh_token values that become the user's live tokens.\n\nThis may be intentional per the feature's documented trust model (operator-configured endpoint), but it is a security-posture gap on a new credential-handling channel.\n\n> **Fix** — Add a shared-secret header to the sync request and verify the response origin before persisting any token values.\n", + "created_at": "2026-08-21T21:32:27Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | Webhook app-credential handler is fundamentally flawed: case-sensitive secret-header lookup, unvalidated JSON overwrites credential key, uncaught throws on GET/malformed payloads cause 500s, and non-atomic findFirst→create TOCTOU on concurrent deliv… | `apps/web/pages/api/webhook/app-credential.ts:31` |\n| 🔵 Low | Freshly refreshed token is discarded; connection built with stale token | `packages/app-store/salesforce/lib/CalendarService.ts:105` (not in the diff) |\n| 🔵 Low | salesforce: unconditional refresh on every construction is a new network/DB dependency at construction time | `packages/app-store/salesforce/lib/CalendarService.ts:75` |\n| 🔴 Critical | Calendar-service refresh mishandles fetch Response as parsed token: HubSpot destroys stored credentials by treating Response as HubspotToken, Lark throws on every refresh in sync mode | `packages/app-store/hubspot/lib/CalendarService.ts:177` |\n| 🟠 High | Salesforce CalendarService is broken: statusText branched on as success test (empty on HTTP/2/3) and undefined `prisma` binding from missing import | `packages/app-store/salesforce/lib/CalendarService.ts:96` |\n| 🟠 High | minimumTokenResponseSchema computed-key schema is broken: no numeric-expiry validation, and its output drops every field except access_token | `packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts:8` |\n| 🟡 Medium | zoho-bigin passes Credential.id instead of cal.com user id to credential sync helper | `packages/app-store/zoho-bigin/lib/CalendarService.ts:93` |\n| 🟠 High | `.data` read on fetch Response `res` — always undefined | `packages/app-store/googlecalendar/lib/CalendarService.ts:94` |\n| 🟠 High | `.data` read on fetch Response `tokenInfo` — always undefined | `packages/app-store/zoho-bigin/lib/CalendarService.ts:96` |\n| 🟠 High | `.data` read on fetch Response `zohoCrmTokenInfo` — always undefined | `packages/app-store/zohocrm/lib/CalendarService.ts:217` |\n| 🔵 Low | Sync-channel refresh request carries no authentication and its unverified response is persisted as the user's live credential | `packages/app-store/_utils/oauth/refreshOAuthTokens.ts:8` |\n\n---\n\n### 🔵 Low · Freshly refreshed token is discarded; connection built with stale token\n\n`packages/app-store/salesforce/lib/CalendarService.ts:105`\n\nThe added code fetches new tokens, parses them, and persists them to the DB, but then constructs jsforce.Connection with the old credentialKey.access_token and credentialKey.instance_url. The refresh result is never used for the connection it was fetched for.\n\nIf the old token was expired (the reason to refresh), the connection is handed an expired token and relies on jsforce's lazy 401-refresh; a changed instance_url from the refresh (org migration) is ignored for the connection. This runs on every service instantiation in the common non-sync deployment.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 57 (C-) | 56 (C-) | -1.2 |\n| Runtime | 100 (A+) | 57 (C-) | -42.7 |\n| Consistency | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **79 (B)** | **71 (B-)** | **-7.6** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:28Z" + } + ] } ] }, @@ -68422,6 +70054,42 @@ "created_at": "2026-06-28T23:04:20Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/7232", + "review_comments": [ + { + "path": "packages/features/ee/workflows/api/scheduleEmailReminders.ts", + "line": 53, + "body": "### 🟡 Medium · CRON cancel block: single try around the whole loop; one POST failure aborts every remaining cancel and the deletes\n\nA single rejected POST aborts the loop: reminders before the failure have been cancelled at SendGrid but their DB deletes are never executed (the Promise.all is skipped), and reminders after the failure are neither cancelled nor deleted. Because the deletes only run after the loop completes, a persistently failing batch re-blocks the same set on every CRON run, so the remaining cancelled reminders in the 1-hour window are never cancelled and their emails fire. This may be intentional to keep the code simple, but it creates a partial-failure wedge.This comment also covers: CRON cancel pass is all-or-nothing\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). One try encloses the whole loop and the `Promise.all` at line 74, so any rejection at line 57 skips the deletes (66-70) entirely and aborts cancels for all later reminders. The cancel query (44-51: `cancelled: true`, `scheduledDate <= now+1h`) re-matches the same rows on subsequent runs while they sit inside the 1-hour window, and the only cleanup (`deleteMany` at 34-41, `scheduledDate <= now`) fires only after the email's send time has passed — so un-cancelled batches send. Trigger is real: SendGrid 4xx/5xx (invalid batch, 429, 5xx) and network failures reject per the pinned 7.7.0 contract, and nothing in the code checks or contains the error. Handler is a live entry point (`apps/web/pages/api/cron/workflows/scheduleEmailReminders.ts:1`).\n_Impact: A single failing cancel POST aborts the whole cancel pass each CRON run: later reminders are never cancelled and their emails fire for cancelled bookings, while earlier reminders are cancelled at SendGrid but leave stale DB rows that re-block the same set every run — silently, with only a console.log._\n_Queries: read_file(packages/features/ee/workflows/api/scheduleEmailReminders.ts) · grep(\"scheduleEmailReminders\") · glob(\"**/yarn.lock\") · grep(\"@sendgrid/client@\", yarn.lock) · list_dir(\"apps/web/pages/api/cron\") · grep(\"workflows/\", \"apps/web/pages/api/cron\") · read_file(\"apps/web/pages/api/cron/bookingReminder.ts\")_\n\n> **Fix** — Move the delete execution into the loop (or a finally block) so each reminder's delete runs even if a later POST fails, and consider per-reminder try/catch.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR7232__20260821/blob/6048e2a86b50e81e1e3b1b467dfea5a895add3dc/packages/features/ee/workflows/api/scheduleEmailReminders.ts#L55-L59\n\n
\n", + "created_at": "2026-08-21T21:32:19Z" + }, + { + "path": "packages/features/bookings/lib/handleNewBooking.ts", + "line": 978, + "body": "### 🔵 Low · Old booking reminders cancelled before unguarded reschedule, no rollback on failure\n\nThe handler cancels/deletes the old booking's reminders at lines 963-975 and then runs eventManager.reschedule at line 978 without a try/catch. If reschedule throws, the request fails but the old booking remains ACCEPTED with its reminders already cancelled/deleted — no rollback, so the attendee loses all workflow reminders for a still-valid booking. The partial-failure branch at lines 990-996 logs and continues without throwing, which also leaves the old booking valid with reminders gone. This may be intentional if reschedule is expected to never throw, but the ordering defect is structurally confirmed.This comment also covers: handleNewBooking.ts:963-975: cancellation before reschedule confirmation + dead try/catch\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). The flow is confirmed by direct reads:\n- `apps/web/pages/api/book/event.ts:11` calls `handleNewBooking(req)` — a real public Next.js API route.\n- `handleNewBooking.ts:963-975`: inside `if (originalRescheduledBooking?.uid)`, the handler iterates `originalRescheduledBooking.workflowReminders` and calls `deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true)` (line 968) / `deleteScheduledSMSReminder` (line 970) *without awaiting* the promises (they're inside a forEach callback), wrapped in a try/catch that only logs.\n- `emailReminderManager.ts:213-222`: the `immediateDelete=true` branch POSTs `status:\"cancel\"` to SendGrid and `return`s — no DB row is written, no `cancelled:true` flag, no trace. Its catch at :233-235 only console.logs.\n- `handleNewBooking.ts:978-983`: `await eventManager.reschedule(...)` is called outside the try/catch — an exception propagates up through `event.ts:11` to `defaultResponder.ts:17-21`, which merely returns an error JSON. No re-scheduling of reminders, no rollback.\n- `EventManager.ts:193-297` (`reschedule`): can throw at `throw new Error(\"booking not found\")` (:232-233), at `prisma.booking.update` (:236), at `updateVideoEvent` (:249), at `updateAllCalendarEvents` (:259), at `prisma.payment.updateMany` (:266), and at `Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes])` (:297). The old booking is deleted only at :290-297, *after* all those throw points — a throw before :297 leaves the old booking ACCEPTED in the DB while its SendGrid batch has already been cancelled at :968-971.\n- Sub-claim correction: the finding's secondary mechanism (\"partial-failure branch at 990-996 ... leaves the old booking valid\") is not accurate — when `reschedule` reaches line 990 it has already awaited the old-booking deletion at :297, so the old booking is gone; the surviving-booking-with-reminders-gone state requires a throw before :297. That doesn't undermine the primary mechanism.\n_Impact: If `eventManager.reschedule` throws (e.g. the old booking was concurrently deleted by the organiser — the \"booking not found\" throw at EventManager.ts:232 — or any DB / calendar-integration error), the API returns an error but the old ACCEPTED booking remains; its workflow reminder emails/SMS were already cancelled at SendGrid with no DB trace and no re-scheduling, so the attendee loses their reminders for a meeting that is still on the calendar — real-behavior breakage, no attacker required._\n_Queries: read_file(packages/features/bookings/lib/handleNewBooking.ts:900-1049) · read_file(packages/features/bookings/lib/handleNewBooking.ts:1-120) · grep(handleNewBooking.ts, \"originalRescheduledBooking\") · grep(handleNewBooking.ts, \"rescheduled\") · read_file(packages/features/bookings/lib/handleNewBooking.ts:740-859) · read_file(packages/features/bookings/lib/handleNewBooking.ts:1050-1249) · read_file(packages/features/bookings/lib/handleNewBooking.ts:1249-1323) · grep(\"handleNewBooking\") · grep(\"emailReminderManager\") · read_file(apps/web/pages/api/book/event.ts) · glob(\"**/emailReminderManager.ts\") · read_file(packages/features/ee/workflows/lib/reminders/emailReminderManager.ts:180-236) · glob(\"**/EventManager*\") · read_file(packages/core/EventManager.ts:180-303) · read_file(packages/lib/server/defaultResponder.ts)_\n\n> **Fix** — Wrap the reschedule call in a try/catch and restore the old booking's reminders on failure, or move the cancellation after a successful reschedule.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR7232__20260821/blob/6048e2a86b50e81e1e3b1b467dfea5a895add3dc/packages/features/bookings/lib/handleNewBooking.ts#L961-L965\n\n
\n", + "created_at": "2026-08-21T21:32:19Z" + }, + { + "path": "packages/features/ee/workflows/lib/reminders/smsReminderManager.ts", + "line": 180, + "body": "### 🔵 Low · Reminder deletion couples DB-row state to external-service success: email immediateDelete leaves row neither deleted nor marked (unretryable), SMS deleteScheduledSMSReminder drops row only if twilio.cancelSMS succeeds\n\nIf twilio.cancelSMS throws, the catch swallows the error and both the Twilio cancellation and the DB row deletion are lost. The scheduled SMS still goes out, the row persists, and there is no retry path since SMS reminders are never marked cancelled and no SMS-cancel CRON exists.\n\nThis may be intentional to avoid deleting rows when cancellation fails, but it leaves no cleanup mechanism.\n\n> **Fix** — Delete the DB row unconditionally even if twilio.cancelSMS fails, or add a retry/cleanup mechanism for SMS cancellations.\n\n---\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR7232__20260821/blob/6048e2a86b50e81e1e3b1b467dfea5a895add3dc/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts#L211-L215\n\n
\n", + "created_at": "2026-08-21T21:32:19Z" + }, + { + "path": "packages/features/bookings/lib/handleCancelBooking.ts", + "line": 488, + "body": "### 🟡 Medium · Booking-cancel reminder cancellation deferred/fire-and-forget: un-awaited writes lose error handling, deferred email cancellation creates race window for cancelled bookings\n\ndeleteScheduledEmailReminder is called without the immediateDelete parameter at handleCancelBooking.ts:488, so it only marks the reminder as cancelled:true in the DB instead of immediately calling SendGrid's /v3/user/scheduled_sends cancel endpoint. The actual SendGrid cancellation is deferred to the CRON job (scheduleEmailReminders.ts), which only cancels reminders where scheduledDate <= now + 1 hour. If a booking is cancelled and the reminder is scheduled more than 1 hour out, the SendGrid cancellation is deferred until the CRON runs within the 1-hour window. The CRON runs every 15 minutes, so there is a race window where a cancelled booking's email could still be sent if the CRON hasn't executed before the scheduled send time.This comment also covers: CRON cancellation query only cancels reminders within 1 hour of scheduled dateThis comment also covers: Deferred-cancel window: batch cancellation now depends on CRON timing\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). handleCancelBooking.ts:488 calls deleteScheduledEmailReminder(reminder.id, reminder.referenceId) with immediateDelete omitted; import at :18 resolves to emailReminderManager.ts, whose function at :197-236 executes SendGrid /v3/user/scheduled_sends cancel only inside if (immediateDelete) (:213-223); with flag falsy it runs only prisma.workflowReminder.update({ data: { cancelled: true } }) (:225-232). The forEach at :485-493 iterates booking.workflowReminders (selected at :92 single and :248 recurring), EMAIL-filtered; handler invoked from apps/web/pages/api/cancel.ts:11 (POST/DELETE, session-authenticated). scheduleEmailReminders.ts:44-51 cancels SendGrid batches only for reminders matching cancelled: true AND scheduledDate <= now + 1 hour; earlier query (:34-41) deletes past-due EMAIL rows without SendGrid cancel. emailReminderManager.ts:154-167 pre-submits reminder to SendGrid with sendAt = scheduledDate. handleNewBooking.ts:968 and workflows.tsx:214/521 pass immediateDelete=true for reschedule/delete flows, while handleCancelBooking.ts:488, trpc bookings.tsx:490, and workflows.tsx:378/576 omit it.\n_Impact: A reminder email for a cancelled booking is delivered to attendees; wrong customer-facing communication in real operation (damage, not exploitable)._\n_Queries: read_file(path=\"packages/features/bookings/lib/handleCancelBooking.ts\", offset=430, limit=120) · read_file(path=\"packages/features/bookings/lib/handleCancelBooking.ts\", offset=1, limit=80) · read_file(path=\"packages/features/bookings/lib/handleCancelBooking.ts\", offset=80, limit=120) · read_file(path=\"packages/features/bookings/lib/handleCancelBooking.ts\", offset=199, limit=60) · read_file(path=\"packages/features/ee/workflows/lib/reminders/emailReminderManager.ts\") · read_file(path=\"packages/features/ee/workflows/api/scheduleEmailReminders.ts\") · read_file(path=\"apps/web/pages/api/cancel.ts\") · glob(pattern=\"**/scheduleEmailReminders.ts\") · grep(pattern=\"deleteScheduledEmailReminder\") · grep(pattern=\"handleCancelBooking\") · grep(pattern=\"CRON_API_KEY|every 15|cronjob\", glob=\"*.ts\") · grep(pattern=\"cron\", path=\"apps/web/pages/api/cron\") · ast_find_symbol(name=\"deleteScheduledEmailReminder\")_\n\n> **Fix** — Consider passing immediateDelete=true for the cancellation path, or ensure the CRON job is reliably scheduled and the cancellation window is acceptable. Alternatively, reduce the CRON interval or broaden the cancellation query window.\n\n---\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR7232__20260821/blob/6048e2a86b50e81e1e3b1b467dfea5a895add3dc/packages/features/ee/workflows/api/scheduleEmailReminders.ts#L42-L46\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR7232__20260821/blob/6048e2a86b50e81e1e3b1b467dfea5a895add3dc/packages/features/bookings/lib/handleCancelBooking.ts#L483-L487\n\n
\n", + "created_at": "2026-08-21T21:32:19Z" + }, + { + "path": null, + "line": null, + "body": "## 🟡 Corbulo merge readiness — needs_review\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | CRON cancel block: single try around the whole loop; one POST failure aborts every remaining cancel and the deletes | `packages/features/ee/workflows/api/scheduleEmailReminders.ts:53` |\n| 🔵 Low | Old booking reminders cancelled before unguarded reschedule, no rollback on failure | `packages/features/bookings/lib/handleNewBooking.ts:978` |\n| 🔵 Low | Reminder deletion couples DB-row state to external-service success: email immediateDelete leaves row neither deleted nor marked (unretryable), SMS deleteScheduledSMSReminder drops row only if twilio.cancelSMS succeeds | `packages/features/ee/workflows/lib/reminders/smsReminderManager.ts:180` |\n| 🟡 Medium | Booking-cancel reminder cancellation deferred/fire-and-forget: un-awaited writes lose error handling, deferred email cancellation creates race window for cancelled bookings | `packages/features/bookings/lib/handleCancelBooking.ts:488` |\n| 🟡 Medium | Async callback in forEach — promises are fire-and-forget | `packages/trpc/server/routers/viewer/workflows.tsx:416` (not in the diff) |\n\n---\n\n### 🟡 Medium · Async callback in forEach — promises are fire-and-forget\n\n`packages/trpc/server/routers/viewer/workflows.tsx:416`\n\n`bookingsForReminders.forEach()` is called with an async callback. forEach discards the callback's return value by specification, so every produced promise is dropped: rejections become unhandled and nothing can await completion.\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 64 (C) | -35.8 |\n| **Overall** | **79 (B)** | **73 (B-)** | **-6.1** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:19Z" + } + ] } ] }, @@ -70394,6 +72062,54 @@ "created_at": "2026-06-28T23:39:36Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/14740", + "review_comments": [ + { + "path": "apps/web/components/dialog/AddGuestsDialog.tsx", + "line": 32, + "body": "### 🔵 Low · AddGuestsDialog seeds email list with empty string causing spurious validation error\n\nThe dialog initializes state with [\"\"] instead of []. Since the client schema z.array(z.string().email()) rejects empty strings, clicking Add without entering any email causes safeParse([\"\"]) to fail, setting isInvalidEmail and rendering a misleading 'emails must be unique and valid' error.\n\nThe seed re-applies on success and cancel, so the error recurs on every open. This may be intentional as a placeholder, but the MultiEmail component already supports an empty state and handleAdd no-ops on [], so [] is the supported configuration.\n\n> **Fix** — Change useState([\"\"]) to useState([]) and rely on MultiEmail's existing empty-state handling.\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": "packages/emails/email-manager.ts", + "line": 525, + "body": "### 🔵 Low · No eventTypeDisableHostEmail / eventTypeDisableAttendeeEmail checks in sendAddGuestsEmails\n\nEvery sibling sender gates on eventTypeDisableHostEmail/eventTypeDisableAttendeeEmail(eventTypeMetadata). sendAddGuestsEmails neither takes eventTypeMetadata nor checks the flags. For an event type where the organizer has disabled host emails (or the attendee-emails toggle), adding a guest still fires organizer + every team-member + attendee emails.\n\nThe user's explicit opt-out is silently ignored. The mechanism is confirmed on the changed lines; whether the omission is intentional cannot be ruled out, hence the modest confidence.\n\n> **Fix** — Accept eventTypeMetadata and check the disable flags before sending each email, matching the sibling senders.\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": "packages/emails/email-manager.ts", + "line": 531, + "body": "### 🔵 Low · Dead team-member notification loop — handler never sets evt.team\n\nemail-manager.ts:531-537 iterates calendarEvent.team?.members to notify every team member of the added guests. Its only caller is addGuestsHandler, and the evt object built at addGuests.handler.ts:124-147 never sets the team field (the booking query at 26-42 includes eventType: true but the handler copies no team data into evt). formatCalEvent (pre-existing) does not synthesize team. Therefore calendarEvent.team?.members is always undefined at this call site and the loop never executes — on round-robin/collective bookings the non-assigned hosts are never told guests were added, while the author's own loop shows that notification was intended. Confirmed dead branch in the only caller.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). sendAddGuestsEmails' only caller is addGuestsHandler (addGuests.handler.ts:168). evt at addGuests.handler.ts:124-147 never assigns team; booking include at 26-42 loads no team relation. formatCalEvent (packages/lib/formatCalendarEvent.ts:19-27) does not synthesize team. team is optional (packages/types/Calendar.d.ts:171), so evt.team is undefined and calendarEvent.team?.members at email-manager.ts:531 is always falsy — the loop at 531-537 never executes. The path is live from the public API: viewer/_router.tsx:39 → bookings/_router.tsx:79 → addGuestsHandler → sendAddGuestsEmails.\n_Impact: On round-robin/collective team bookings, adding guests via the public addGuests API never notifies the non-assigned hosts — the OrganizerAddGuestsEmail team loop silently never fires in real operation._\n_Queries: read_file(packages/emails/email-manager.ts, offset=500, limit=70) · grep(pattern=\"sendAddGuestsEmails\", path=\"packages\") · read_file(packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts) · grep(pattern=\"export const formatCalEvent|function formatCalEvent\", path=\"packages\") · read_file(packages/lib/formatCalendarEvent.ts) · read_file(packages/types/Calendar.d.ts, offset=150, limit=40) · grep(pattern=\"team:\\s*\\{|evt\\.team|calEvent\\.team|\\.team\\s*=\\s*\\{\", glob=\"**/*.ts\") · grep(pattern=\"addGuests\", path=\"packages/trpc/server/routers/viewer/bookings\") · grep(pattern=\"bookingsRouter\", path=\"packages/trpc/server/routers/viewer\") · grep(pattern=\"sendAddGuestsEmails|OrganizerAddGuestsEmail\")_\n\n> **Fix** — Populate evt.team from the booking's team relation, or remove the dead loop.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Dead team-member loop (requeued)**\n\nemail-manager.ts:531-537 iterates calendarEvent.team?.members, but the only caller (addGuests.handler.ts:26-42) includes eventType: true only — Prisma include: { eventType: true } loads scalar fields, not the team relation — and never assigns evt.team (124-147). The loop is dead code relative to its only caller; non-assigned hosts on round-robin/collective bookings never learn of added guests.\n\nConfirmed dead branch in newly added code.\n\n
\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": "packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts", + "line": 165, + "body": "### 🟡 Medium · Failure ordering / swallowed errors / wrong credentials in addGuests flow\n\nOrdering visible in the changed code: DB committed at addGuests.handler.ts:92-106 → calendar sync at 165 (await eventManager.updateCalendarAttendees(evt, booking), unguarded) → emails at 167-171 inside try/catch whose handler is only console.log. If updateCalendarAttendees throws, the mutation 500s after attendees were committed and before any email is sent — the client sees failure while the DB has new guests, and a retry then hits uniqueGuests.length === 0 → BAD_REQUEST, a confusing dead-end with no rollback.\n\nEmail failures are swallowed: the handler returns success even when zero notifications were delivered. Credentials: getUsersCredentials(ctx.user) and new EventManager use the acting user's credentials.\n\nOn the permitted attendee path (isAttendee at line 52), the calendar update for the organizer's event/destinationCalendar runs with the attendee's credentials — either no-op or writing to the attendee's own calendar — instead of the organizer's.\n\n> **Fix** — Move calendar sync before DB commit or add rollback; propagate email failures; use the booking owner's credentials for calendar operations.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Calendar sync ordering + acting-user credentials (requeued)**\n\nLine 158-165: getUsersCredentials(ctx.user) then new EventManager({...user, credentials}) then await eventManager.updateCalendarAttendees(evt, booking). The isAttendee grant at 52-54 makes the attendee path explicitly reachable: an attendee adding guests drives updateCalendarAttendees with the attendee's credentials against evt.destinationCalendar built from the organizer's calendar.\n\nThis runs after the DB booking.update committed and outside the email try/catch: a calendar-API failure 500s to the client while the guests are already committed and no emails were sent, and email failures are swallowed by console.log while the handler still returns success — partial failure reported as success. Also booking.userId || 0 at 60: a booking with userId: null turns into findFirstOrThrow({id: 0}) → P2025 → 500.\n\n
\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": "packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts", + "line": 76, + "body": "### 🔵 Low · Case-sensitive blacklist and dedup allow blacklisted or duplicate emails\n\nThe blacklist is normalized to lowercase but the submitted guest is compared raw, and dedup against existing attendees is an exact case-sensitive ===. A blacklisted address submitted as VIP@example.com (blacklist entry vip@example.com) passes the filter and becomes an attendee + receives emails; an existing attendee john@example.com re-submitted as JOHN@example.com is treated as a new guest, creating a duplicate attendee row and a second full invitation. Per the judgment rule on exact, case-sensitive match, no normalization on a validation path, this is a finding.\n\n> **Fix** — Normalize both sides (e.g., toLowerCase) before comparing for blacklist and dedup.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Case-sensitive blacklist and dedupe (requeued)**\n\nLine 71 lowercases the blacklist, but line 76-77 compares the raw guest with no normalization. Evil@Example.com bypasses a blacklist containing evil@example.com.\n\nLikewise the attendee dedupe at 76 is guest === attendee.email (exact, case-sensitive): User@Example.com against attendee user@example.com fails to dedupe → duplicate attendee row created. Case-asymmetric comparison on a validation path — per the judgment rules this asymmetry is itself the finding.\n\n
\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": "packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts", + "line": 168, + "body": "### 🔵 Low · Guest email logic uses raw input instead of deduplicated created guests: pre-existing attendees misclassified, emails sent to wrong/raw recipients\n\nLine 168 passes the raw guests to sendAddGuestsEmails(evt, guests), while attendees were created only from uniqueGuests. In email-manager.ts:539-547 the per-attendee classification is newGuests.includes(attendee.email).\n\nAn email that was filtered out by uniqueGuests because it is already an attendee is still present in raw guests → that existing attendee receives a full re-sent AttendeeScheduledEmail (confirmation + ICS REQUEST), i.e. a duplicate invitation, even though no new attendee was created for them. The uniqueGuests filter checks only against pre-existing booking.attendees, not against other entries in the same input list — submitting the same new email twice passes the filter twice, createMany creates two attendee rows, and two identical confirmation emails go out.\n\n> **Fix** — Pass uniqueGuests to sendAddGuestsEmails instead of raw guests, and dedupe uniqueGuests against itself.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Emails keyed off raw input guests, not created uniqueGuests (requeued)**\n\nLine 168 passes the raw input, while the dedupe at 74-78 produced uniqueGuests (which is what was actually created at 92-106). In email-manager.ts:541 the dispatch decision is newGuests.includes(attendee.email).\n\nInput containing an email that is already an attendee: dedupe drops it from creation, but the existing attendee still matches newGuests.includes(...) → that existing attendee receives the full AttendeeScheduledEmail instead of the add-guests notification. Duplicates inside the input: uniqueGuests is not deduped against itself, so [\"a@b.com\",\"a@b.com\"] creates two identical attendee rows via createMany and sends two identical invitation emails.\n\n
\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR14740__20260821/blob/92f44dcea7ff19e9123a30c63c167a2938df5a55/packages/emails/email-manager.ts#L539-L543\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR14740__20260821/blob/92f44dcea7ff19e9123a30c63c167a2938df5a55/packages/emails/email-manager.ts#L537-L541\n\n
\n", + "created_at": "2026-08-21T21:32:36Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🔵 Low | AddGuestsDialog seeds email list with empty string causing spurious validation error | `apps/web/components/dialog/AddGuestsDialog.tsx:32` |\n| 🔵 Low | No eventTypeDisableHostEmail / eventTypeDisableAttendeeEmail checks in sendAddGuestsEmails | `packages/emails/email-manager.ts:525` |\n| 🔵 Low | Dead team-member notification loop — handler never sets evt.team | `packages/emails/email-manager.ts:531` |\n| 🟡 Medium | Failure ordering / swallowed errors / wrong credentials in addGuests flow | `packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts:165` |\n| 🔵 Low | Case-sensitive blacklist and dedup allow blacklisted or duplicate emails | `packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts:76` |\n| 🔵 Low | Guest email logic uses raw input instead of deduplicated created guests: pre-existing attendees misclassified, emails sent to wrong/raw recipients | `packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts:168` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 92 (A) | -7.6 |\n| Dead Code | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **81 (B+)** | **79 (B)** | **-1.3** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:36Z" + } + ] } ] }, @@ -72114,6 +73830,48 @@ "created_at": "2026-06-28T22:29:53Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/10600", + "review_comments": [ + { + "path": "packages/features/auth/lib/next-auth-options.ts", + "line": 131, + "body": "### 🟡 Medium · Flawed backup-code verification: non-CAL providers blocked from backup-code login, exhausted codes misreported as IncorrectBackupCode, and disable endpoint TypeErrors on non-string backupCode\n\nThe guard at line 113 rejects non-CAL users without a TOTP code before the backup-code branch at line 131 can run. This means a non-CAL user with 2FA enabled who submits a backup code (as the UI at login.tsx:221 allows) will always fail with 'third-party-identity-provider-enabled' and the backup code is never verified. This is a real functional gap: the feature's own UI drives this state, and a CAL user who later links Google retains twoFactorEnabled and backupCodes but cannot use them. It may be intentional to restrict backup codes to CAL users, but the UI does not communicate this restriction and the new backup-code branch was added without relaxing the predicate.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). discover(guards_before, line=131) labels the line-113 condition `(user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode)` as a protective early-exit that is FALSE at line 131 — reaching line 131 requires the user to be CAL or to hold a totpCode. Therefore next-auth-options.ts:131 (`if (user.twoFactorEnabled && credentials.backupCode)`) is unreachable for a non-CAL user submitting only a backup code; line 114 throws `ThirdPartyIdentityProviderEnabled` first. next-auth-options.ts:693-711 (signIn callback): linking Google/SAML to a CAL account writes `password: null, identityProvider: idP` while preserving `twoFactorEnabled` and `backupCodes`, then returns `loginWithTotp` (line 707) — proving the state \"non-CAL user holding twoFactorEnabled + backupCodes\" is created by a supported flow. setup.ts:34 rejects 2FA setup for non-CAL users, so the CAL→Google linking transition is the only route into that state. grep(backupCodes) across packages and grep(IncorrectBackupCode|backupCode) across apps show the only backup-code verifications are next-auth-options.ts:131-145 (login, blocked for non-CAL) and disable.ts:48-64 (requires an authenticated session — unreachable for a locked-out user). No alternative login path verifies backup codes. UI nuance: login.tsx:221 renders `` only when `twoFactorRequired && twoFactorLostAccess`; in the `totpEmail` (OAuth) flow the footer is `ExternalTotpFooter` (login.tsx:180-182, cancel-only, no \"lost access\"), and in the plain credentials flow line 113 throws before `SecondFactorRequired` (login.tsx:156) can set `twoFactorRequired`. The UI does not naturally present the backup form to non-CAL users — this narrows the parenthetical in the claim but does not refute it: the server-side rejection is unconditional for any backup-code submission, and the dead-feature/lockout harm is unchanged.\n_Impact: Account lockout with no recovery path for non-CAL (Google/SAML-linked) users with 2FA enabled — their generated backup codes are never verified, so losing the authenticator device permanently bars access._\n_Queries: read_file(packages/features/auth/lib/next-auth-options.ts) · read_file(apps/web/pages/auth/login.tsx) · read_file(apps/web/components/auth/BackupCode.tsx) · read_file(apps/web/components/auth/TwoFactor.tsx) · read_file(apps/web/pages/api/auth/two-factor/totp/setup.ts) · read_file(apps/web/pages/api/auth/two-factor/totp/enable.ts) · read_file(apps/web/pages/api/auth/two-factor/totp/disable.ts) · grep(pattern=\"backupCodes\", path=\"packages\", glob=\"*.ts\") · grep(pattern=\"IncorrectBackupCode|backupCode\", path=\"apps\", glob=\"*.ts\") · discover(kind=\"guards_before\", args={file:\"packages/features/auth/lib/next-auth-options.ts\", line:\"131\"}) · discover(kind=\"guards_before\", args={file:\"packages/features/auth/lib/next-auth-options.ts\", line:\"113\"})_\n\n> **Fix** — Modify the guard at line 113 to also accept credentials.backupCode, or add a separate check that allows the backup-code branch to run for non-CAL users with 2FA enabled.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Backup code exhaustion shows misleading 'IncorrectBackupCode' instead of 'MissingBackupCodes'**\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR10600__20260821/blob/54486a059cd2032042189bb565646ba4e0f6bd61/packages/features/auth/lib/next-auth-options.ts#L135-L139\n\n
\n\n
This same fix applies at 1 other place in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR10600__20260821/blob/54486a059cd2032042189bb565646ba4e0f6bd61/apps/web/pages/api/auth/two-factor/totp/disable.ts#L59-L63\n\n
\n", + "created_at": "2026-08-21T21:32:08Z" + }, + { + "path": "apps/web/pages/api/auth/two-factor/totp/disable.ts", + "line": 50, + "body": "### 🔵 Low · Misleading error message in 2FA disable endpoint mentions 'backup code login'\n\nThe disable endpoint logs an error message that incorrectly references 'backup code login', which is a copy-paste mislabel from the login flow. This misleads operators reading server logs, though the client-visible result is only an internal server error.\n\nIt may be intentional if the message was meant to be generic, but the wording is clearly wrong for a disable operation.\n\n> **Fix** — Change the message to reference 'backup code disable' or 'two factor disable' instead of 'login'.\n", + "created_at": "2026-08-21T21:32:08Z" + }, + { + "path": "apps/web/components/settings/EnableTwoFactorModal.tsx", + "line": 135, + "body": "### 🔵 Low · Broken enable-2FA modal lifecycle: unconditional success toast on unawaited clipboard write, and deferred onEnable leaves stale settings toggle with unrecoverable codes on dismiss\n\nonEnable() (which closes the modal and invalidates viewer.me, refreshing the settings toggle) is now invoked only by the 'close' button at line 269. Dismissing the dialog at the backup-code screen via ESC/X/overlay routes through onOpenChange which only flips enableModalOpen — no invalidation. Result: the server has twoFactorEnabled=true while the settings switch still reads the stale viewer.me data and shows 'disabled'; clicking the switch then opens the enable modal, which the API rejects with TwoFactorAlreadyEnabled, and the stale switch state makes the disable modal unreachable until a page refresh. The newly generated backup codes are likewise unrecoverable once the page unmounts the modal. This may be intentional to force explicit close, but it is a genuine regression of the dismiss path.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). EnableTwoFactorModal.tsx:134-136: on enable 200, only setStep(SetupStep.DisplayBackupCodes) runs; onEnable() is not called. EnableTwoFactorModal.tsx:266-270: the only call site of onEnable() is the footer 'close' button. two-factor-auth.tsx:62-72: onEnable = setEnableModalOpen(false) + utils.viewer.me.invalidate(); onOpenChange={() => setEnableModalOpen(!enableModalOpen)} — bare toggle, no invalidation. Dialog.tsx:32-68: onOpenChange is rewritten only when the name prop is set; the caller passes no name (grep returned no name= in the caller), so it is forwarded unchanged to DialogPrimitive.Root. Radix fires onOpenChange(false) on Escape/outside-click → modal closes with no invalidation. setup.ts:34-36: enable flow requires identityProvider === \"CAL\"; for CAL providers two-factor-auth.tsx:37,45 give canSetupTwoFactor=false, so the switch stays enabled while reading stale twoFactorEnabled=false; clicking it opens the enable modal and setup.ts:42-44 / enable.ts:30-32 reject with TwoFactorAlreadyEnabled. two-factor-auth.tsx:47-49: switch onCheckedChange branches on stale user?.twoFactorEnabled (false) → opens enable modal, never the disable modal; correct state returns only on page reload (remount refetch). security/EnableTwoFactorModal.tsx:114: sibling copy still calls onEnable() immediately on success — confirms the settings version regressed the dismiss path.\n_Impact: Settings UI shows 2FA disabled while the server has it enabled; re-enable fails with TwoFactorAlreadyEnabled (generic \"something went wrong\"); disable modal unreachable; backup codes lost on reload._\n_Queries: read_file(\"apps/web/components/settings/EnableTwoFactorModal.tsx\") · read_file(\"apps/web/pages/settings/security/two-factor-auth.tsx\") · read_file(\"packages/ui/components/dialog/Dialog.tsx\") · read_file(\"apps/web/pages/api/auth/two-factor/totp/setup.ts\") · read_file(\"apps/web/pages/api/auth/two-factor/totp/enable.ts\") · read_file(\"apps/web/components/security/EnableTwoFactorModal.tsx\") · grep(\"EnableTwoFactorModal\") · grep(\"TwoFactorAlreadyEnabled\") · grep(\"name=|Dialog|enableModalOpen\", \"apps/web/pages/settings/security/two-factor-auth.tsx\")_\n\n> **Fix** — Call onEnable() (or at least invalidate viewer.me) in the onOpenChange handler when the modal closes, or make the backup-code step non-dismissable except via the close button.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. onEnable() skipped when 2FA-enable dialog closed via X/Escape, leaving stale viewer.me and dead switch**\n\nAfter 2FA is enabled server-side, the modal stays open at DisplayBackupCodes and onEnable() — which closes the modal and invalidates viewer.me — is now reachable only through the dedicated close button. Closing via X/Escape goes through onOpenChange, which the caller wires as a bare toggle, leaving viewer.me stale: user?.twoFactorEnabled stays false, the badge shows disabled, and canSetupTwoFactor keeps the switch disabled so the user cannot reopen the modal.\n\nOnly a full page reload recovers. The modal's internal step also persists at DisplayBackupCodes for the next open.\n\nBefore this change, onEnable() ran immediately on success, so every close path was consistent.This comment also covers: resetState and ESC/close leave stale form and blob state\n\n
\n\n
This same fix applies at 2 other places in the code\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR10600__20260821/blob/54486a059cd2032042189bb565646ba4e0f6bd61/apps/web/components/settings/EnableTwoFactorModal.tsx#L69-L73\n\nhttps://github.com/corbulo-martian-benchmark/cal_dot_com__cal.com__corbulo__PR10600__20260821/blob/54486a059cd2032042189bb565646ba4e0f6bd61/apps/web/components/settings/EnableTwoFactorModal.tsx#L276-L280\n\n
\n", + "created_at": "2026-08-21T21:32:08Z" + }, + { + "path": "packages/ui/components/form/inputs/Input.tsx", + "line": 49, + "body": "### 🔵 Low · PasswordField visibility toggle removed from tab order via tabIndex={-1}\n\nThe tabIndex={-1} on the show/hide-password toggle button removes it from sequential keyboard navigation, so keyboard-only users can never focus or activate it to reveal a mistyped password. The sr-only label at line 57 indicates the control was deliberately built to be assistive-tech-accessible, arguing against an intentional tradeoff. This is a WCAG 2.1.1 Keyboard failure on a control with a real function, affecting all 10 PasswordField consumers.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). packages/ui/components/form/inputs/Input.tsx:49 — tabIndex={-1} applied unconditionally on every render of the toggle button. Export chain resolves to this exact component: packages/ui/index.tsx:22 → ./components/form → packages/ui/components/form/index.ts:10 → ./inputs/Input. Component renders on real user-facing pages (login, signup, forgot-password, security settings, 2FA modals, admin setup) — reachable. sr-only span at line 57 gives the button an accessible name, contradicting any claim of deliberate exclusion. No wrapper (Tooltip), role, or keydown handler makes the button keyboard-operable.\n_Impact: Keyboard-only users can never activate the show/hide-password toggle in any PasswordField form (login, signup, 2FA, security settings), forcing full retyping of mistyped passwords — a WCAG 2.1.1 Keyboard failure across all 10+ consumers._\n_Queries: read_file(path=packages/ui/components/form/inputs/Input.tsx) · grep(pattern=PasswordField, glob=**/*.tsx) · read_file(path=packages/ui/index.tsx) · read_file(path=packages/ui/components/form/index.ts) · grep(pattern=tabIndex|onKeyDown|role=, path=packages/ui/components/tooltip) · grep(pattern=show_password|hide_password|toggleIsPasswordVisible)_\n\n> **Fix** — Remove tabIndex={-1} from the toggle button, or replace it with a documented, accessible alternative that keeps the control in the tab order.\n", + "created_at": "2026-08-21T21:32:08Z" + }, + { + "path": "packages/features/auth/lib/next-auth-options.ts", + "line": 149, + "body": "### 🟠 High · Non-atomic read-modify-write of `backupCodes` — lost updates under concurrency\n\n`backupCodes` is read from `user.backupCodes`, mutated in memory, then written back by `prisma.user.update({ where, data: { backupCodes } })` keyed only on the row's id. Two concurrent executions read the same `backupCodes`, each applies its own change, and the last write wins — the other mutation is silently lost.\n\nThis is a read-modify-write race on persisted state, the general form of a non-atomic increment.\n\n> **Fix** — Do the read+write inside an interactive transaction with a row lock (`prisma.$transaction`), add a compare-and-swap discriminator to the `where` clause (e.g. a version column), or use an atomic operator (`{ push }` / `{ set }` / `{ increment }`) so the database computes the new value.\n", + "created_at": "2026-08-21T21:32:08Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Flawed backup-code verification: non-CAL providers blocked from backup-code login, exhausted codes misreported as IncorrectBackupCode, and disable endpoint TypeErrors on non-string backupCode | `packages/features/auth/lib/next-auth-options.ts:131` |\n| 🔵 Low | Misleading error message in 2FA disable endpoint mentions 'backup code login' | `apps/web/pages/api/auth/two-factor/totp/disable.ts:50` |\n| 🔵 Low | Broken enable-2FA modal lifecycle: unconditional success toast on unawaited clipboard write, and deferred onEnable leaves stale settings toggle with unrecoverable codes on dismiss | `apps/web/components/settings/EnableTwoFactorModal.tsx:135` |\n| 🔵 Low | PasswordField visibility toggle removed from tab order via tabIndex={-1} | `packages/ui/components/form/inputs/Input.tsx:49` |\n| 🟠 High | Non-atomic read-modify-write of `backupCodes` — lost updates under concurrency | `packages/features/auth/lib/next-auth-options.ts:149` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Security | 49 (D) | 49 (D) | -0.0 |\n| Runtime | 100 (A+) | 93 (A) | -6.9 |\n| Consistency | 100 (A+) | 100 (A+) | -0.0 |\n| **Overall** | **75 (B)** | **74 (B-)** | **-1.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:09Z" + } + ] } ] }, @@ -74331,6 +76089,36 @@ "created_at": "2026-06-28T22:35:43Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/10967", + "review_comments": [ + { + "path": "packages/trpc/server/routers/viewer/organizations/create.handler.ts", + "line": 151, + "body": "### 🟡 Medium · Slug condition inverted in createHandler\n\nThe condition for spreading slug is inverted: with billing disabled the org is persisted with no slug while vercelCreateDomain(slug)/sendAdminOrganizationNotification({orgSlug: slug}) already ran with it; with billing enabled the final slug is granted at creation bypassing the pending-approval flow. The sibling requestedSlug line preserved its semantics, showing this is an accidental inversion.\n\n> **Fix** — Restore the original condition: ...(!IS_TEAM_BILLING_ENABLED && { slug })\n", + "created_at": "2026-08-21T21:32:12Z" + }, + { + "path": "packages/features/bookings/lib/handleNewBooking.ts", + "line": 757, + "body": "### 🔵 Low · Error-handling rewrite masks original error and mislabels DB failures as 400\n\nThe new catch converts any non-HttpError, non-Prisma error into HttpError(500, \"Unable to load users\") without logging the original error, so the triggering cause is permanently lost. Prisma.PrismaClientKnownRequestError is re-thrown as 400, labeling a database/server failure as a client error, and its raw Prisma message is echoed to the booker. Both are real behavior changes degrading error diagnostics/status classification.\n\n**Confirmed** — reachable, triggerable, and harmful (all three established). handleNewBooking.ts:757-762 — catch (error) { if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { throw new HttpError({ statusCode: 400, message: error.message }); } throw new HttpError({ statusCode: 500, message: \"Unable to load users\" }); }. No log call; no cause passed. This catch belongs to loadUsers, invoked at handleNewBooking.ts:768. handleNewBooking.ts:731 — prisma.user.findMany(...) is inside the try, so a DB query failure (P1001/P1017/P2023-class) throws PrismaClientKnownRequestError into this catch; the guard at line 728 and hosts.map(({ user, isFixed }) => ({...user, isFixed})) at line 750 can throw plain Error/TypeError into it. Resolved reachable path: apps/web/pages/api/book/event.ts:11 (and apps/api/pages/api/bookings/_post.ts:205, apps/web/pages/api/book/recurring-event.ts:50) → handleNewBooking → loadUsers at 768 → catch at 757. Resolved message-leak path: catch:759 wraps the Prisma error in a plain HttpError → defaultResponder.ts:19 → getServerErrorFromUnknown.ts:55 redactError(cause) → redactError.ts:8-13 shouldRedact false (wrapper is not a Prisma.*Error) → defaultResponder.ts:21 res.json({ message: error.message }) serves the raw Prisma message. On the un-caught path the identical error would have been redacted to \"An error occured while querying the database.\" (redactError.ts:23). Resolved lost-cause path: catch:761 constructs HttpError(500, \"Unable to load users\") with no cause; http-error.ts:20-22 only copies the original stack when cause is passed; the catch never logs error, so defaultResponder.ts:18 console.error(err) logs only the generic HttpError.\n_Impact: bookers receive raw database error text with a 400 status; operators cannot recover the original failure cause from logs after a user-loading failure._\n_Queries: read_file(packages/features/bookings/lib/handleNewBooking.ts, offset=680, limit=140) · read_file(packages/features/bookings/lib/handleNewBooking.ts, offset=1, limit=60) · grep(pattern=\"handleNewBooking\") · read_file(apps/web/pages/api/book/event.ts) · read_file(apps/api/pages/api/bookings/_post.ts, offset=180, limit=60) · read_file(apps/web/pages/api/book/recurring-event.ts) · read_file(packages/lib/server/defaultResponder.ts) · read_file(packages/lib/server/getServerErrorFromUnknown.ts) · read_file(packages/lib/redactError.ts) · read_file(packages/lib/http-error.ts)_\n\n> **Fix** — Log the original error before converting, and re-throw Prisma errors as 500 instead of 400.\n", + "created_at": "2026-08-21T21:32:12Z" + }, + { + "path": "packages/core/EventManager.ts", + "line": 118, + "body": "### 🟠 High · EventManager.create crashes when destinationCalendar is null or empty array\n\nThe destructuring of evt.destinationCalendar ?? [] yields undefined for mainHostDestinationCalendar when the array is empty or null, and the subsequent access mainHostDestinationCalendar.integration throws a TypeError. This is reachable from handleNewBooking.ts (which can set destinationCalendar to null) and editLocation.handler.ts (which can set it to []), and the old code guarded this with optional chaining.\n\nThis crashes the booking request with a 500 when a Google Meet location is selected without a destination calendar.\n\n> **Fix** — Guard the access, e.g., if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== \"google_calendar\") or check the array length before accessing the element.\n\n---\n\n
1 related finding reported here, same root cause\n\n**1. Meet→Daily downgrade checks only the first destination calendar**\n\nThe downgrade logic only inspects the first destination calendar's integration type. For multi-host collectives where the first host has a Google Calendar but a later host does not, the downgrade to Daily may be skipped incorrectly, leaving a Meet link that cannot be honored.\n\nThe `@NOTE` documents the first-element choice as intentional, but the wrong-downgrade consequence for multi-host collectives is real. This might be intentional as a fallback heuristic, but the consequence is a functional gap.\n\n
\n", + "created_at": "2026-08-21T21:32:12Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟡 Medium | Slug condition inverted in createHandler | `packages/trpc/server/routers/viewer/organizations/create.handler.ts:151` |\n| 🔵 Low | Error-handling rewrite masks original error and mislabels DB failures as 400 | `packages/features/bookings/lib/handleNewBooking.ts:757` |\n| 🟠 High | EventManager.create crashes when destinationCalendar is null or empty array | `packages/core/EventManager.ts:118` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 82 (B+) | -18.5 |\n| Consistency | 98 (A+) | 98 (A+) | -0.0 |\n| **Overall** | **79 (B)** | **76 (B)** | **-3.2** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:12Z" + } + ] } ] }, @@ -75831,7 +77619,25 @@ "created_at": "2026-06-28T22:13:21Z" } ] + }, + { + "tool": "corbulo", + "pr_url": "https://github.com/calcom/cal.com/pull/8087", + "review_comments": [ + { + "path": "packages/app-store/vital/lib/reschedule.ts", + "line": 125, + "body": "### 🟠 High · Async forEach fire-and-forget in vital reschedule.ts\n\nSame shape as bookings.tsx:553 — forEach with async callback calling deleteEvent/deleteMeeting inside a try block. The catch at lines 135-139 cannot observe rejections from unawaited promises, so a failed deletion produces an unhandled rejection and, for virtual-calendar flows, leaves the cancelled booking's slot potentially unavailable or incorrect.\n\nThis may be intentional if deletions are best-effort, but the pattern is defective.This comment also covers: Async callback in forEach — promises are fire-and-forget\n\n> **Fix** — Use for...of with await or Promise.all to ensure deletions complete and rejections are handled.\n", + "created_at": "2026-08-21T21:32:05Z" + }, + { + "path": null, + "line": null, + "body": "## 🔴 Corbulo merge readiness — blocked\n\n| Severity | Finding | Where |\n|---|---|---|\n| 🟠 High | Async forEach fire-and-forget in vital reschedule.ts | `packages/app-store/vital/lib/reschedule.ts:125` |\n\n
\nHealth projection\n\n| Category | Current | Projected | Change |\n|---|---|---|---|\n| Runtime | 100 (A+) | 68 (C+) | -31.8 |\n| **Overall** | **78 (B)** | **73 (B-)** | **-5.4** |\n\n
\n\n---\nGenerated by Corbulo\n", + "created_at": "2026-08-21T21:32:05Z" + } + ] } ] } -} +} \ No newline at end of file