@oneuptime/common 12.0.3 → 12.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,381 @@
1
+ import AIAgentDataAPI from "../../../Server/API/AIAgentDataAPI";
2
+ import CodeFixAgentAuth, {
3
+ CodeFixAgentSource,
4
+ } from "../../../Server/Utils/AI/CodeFix/CodeFixAgentAuth";
5
+ import CodeRepositoryService from "../../../Server/Services/CodeRepositoryService";
6
+ import GitHubUtil from "../../../Server/Utils/CodeRepository/GitHub/GitHub";
7
+ import GitHubInstallationBinding from "../../../Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding";
8
+ import OpenPullRequestCap from "../../../Server/Utils/AI/CodeFix/OpenPullRequestCap";
9
+ import Response from "../../../Server/Utils/Response";
10
+ import {
11
+ ExpressRequest,
12
+ ExpressResponse,
13
+ NextFunction,
14
+ } from "../../../Server/Utils/Express";
15
+ import { mockRouter } from "./Helpers";
16
+ import CodeRepository from "../../../Models/DatabaseModels/CodeRepository";
17
+ import CodeRepositoryType from "../../../Types/CodeRepository/CodeRepositoryType";
18
+ import ObjectID from "../../../Types/ObjectID";
19
+ import OneUptimeDate from "../../../Types/Date";
20
+ import BadDataException from "../../../Types/Exception/BadDataException";
21
+ import { JSONObject } from "../../../Types/JSON";
22
+ import {
23
+ afterEach,
24
+ beforeAll,
25
+ beforeEach,
26
+ describe,
27
+ expect,
28
+ test,
29
+ } from "@jest/globals";
30
+
31
+ /*
32
+ * GHSA-xx95-gmcf-7q86 — the endpoint at the sharp end.
33
+ *
34
+ * This route hands back a live GitHub installation token with contents:write
35
+ * and pull_requests:write. It used to authorize by comparing the repository
36
+ * row's projectId against the calling agent's project — but an attacker
37
+ * controls BOTH sides of that comparison, because they can create their own
38
+ * project, issue themselves an agent key in it, and (before this fix) put a
39
+ * row in it carrying any installation ID they liked. The row being "in your
40
+ * project" says nothing about whether the INSTALLATION is.
41
+ *
42
+ * So the binding is re-derived from the project itself, immediately before
43
+ * minting.
44
+ */
45
+
46
+ jest.mock("../../../Server/Utils/Express", () => {
47
+ return {
48
+ getRouter: () => {
49
+ return mockRouter;
50
+ },
51
+ };
52
+ });
53
+
54
+ jest.mock("../../../Server/Utils/Response", () => {
55
+ return {
56
+ sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
57
+ return args;
58
+ }),
59
+ sendEmptySuccessResponse: jest.fn(),
60
+ sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
61
+ return args;
62
+ }),
63
+ sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
64
+ return args;
65
+ }),
66
+ redirect: jest.fn(),
67
+ };
68
+ });
69
+
70
+ const TOKEN_ROUTE: string = "/ai-agent-data/get-repository-token";
71
+
72
+ function thrownError(): Error | undefined {
73
+ const sendErrorResponse: jest.Mock =
74
+ Response.sendErrorResponse as unknown as jest.Mock;
75
+
76
+ if (sendErrorResponse.mock.calls.length === 0) {
77
+ return undefined;
78
+ }
79
+
80
+ return sendErrorResponse.mock.calls[0]![2] as Error;
81
+ }
82
+
83
+ function jsonResponse(): JSONObject | undefined {
84
+ const sendJson: jest.Mock =
85
+ Response.sendJsonObjectResponse as unknown as jest.Mock;
86
+
87
+ if (sendJson.mock.calls.length === 0) {
88
+ return undefined;
89
+ }
90
+
91
+ return sendJson.mock.calls[0]![2] as JSONObject;
92
+ }
93
+
94
+ describe("POST /ai-agent-data/get-repository-token", () => {
95
+ let attackerProjectId: ObjectID;
96
+ let codeRepositoryId: ObjectID;
97
+ let victimInstallationId: string;
98
+ let ownInstallationId: string;
99
+
100
+ let mintTokenSpy: jest.SpyInstance;
101
+ let bindingSpy: jest.SpyInstance;
102
+ let findRepositorySpy: jest.SpyInstance;
103
+
104
+ beforeAll(() => {
105
+ mockRouter.routes.length = 0;
106
+ new AIAgentDataAPI();
107
+ });
108
+
109
+ beforeEach(() => {
110
+ jest.clearAllMocks();
111
+
112
+ attackerProjectId = ObjectID.generate();
113
+ codeRepositoryId = ObjectID.generate();
114
+ victimInstallationId = "99999999";
115
+ ownInstallationId = "11111111";
116
+
117
+ // The agent key is project-scoped and self-issued in the attacker's project.
118
+ jest.spyOn(CodeFixAgentAuth, "resolveAgentIdentity").mockResolvedValue({
119
+ id: ObjectID.generate(),
120
+ projectId: attackerProjectId,
121
+ source: CodeFixAgentSource.AIAgent,
122
+ });
123
+
124
+ findRepositorySpy = jest.spyOn(CodeRepositoryService, "findOneById");
125
+
126
+ const capSpy: jest.SpyInstance = jest.spyOn(
127
+ OpenPullRequestCap,
128
+ "checkForRepository",
129
+ );
130
+ capSpy.mockResolvedValue({ allowed: true });
131
+
132
+ bindingSpy = jest.spyOn(
133
+ GitHubInstallationBinding,
134
+ "isInstallationBoundToProject",
135
+ );
136
+
137
+ mintTokenSpy = jest.spyOn(GitHubUtil, "getInstallationAccessToken");
138
+ mintTokenSpy.mockResolvedValue({
139
+ token: "ghs_live_token",
140
+ expiresAt: OneUptimeDate.getCurrentDate(),
141
+ });
142
+ });
143
+
144
+ afterEach(() => {
145
+ jest.restoreAllMocks();
146
+ });
147
+
148
+ /*
149
+ * The row IS in the attacker's own project — that check has always passed.
150
+ * The installation ID on it is what decides whose repositories the token
151
+ * opens.
152
+ */
153
+ function mockRepository(installationId: string | null): void {
154
+ const repository: CodeRepository = new CodeRepository();
155
+ repository.id = codeRepositoryId;
156
+ repository.projectId = attackerProjectId;
157
+ repository.repositoryHostedAt = CodeRepositoryType.GitHub;
158
+ repository.organizationName = "victim-org";
159
+ repository.repositoryName = "victim-private-repo";
160
+
161
+ if (installationId) {
162
+ repository.gitHubAppInstallationId = installationId;
163
+ }
164
+
165
+ findRepositorySpy.mockResolvedValue(repository);
166
+ }
167
+
168
+ async function callRoute(): Promise<void> {
169
+ const req: ExpressRequest = {
170
+ params: {},
171
+ query: {},
172
+ body: {
173
+ aiAgentId: ObjectID.generate().toString(),
174
+ aiAgentKey: "attacker-self-issued-key",
175
+ codeRepositoryId: codeRepositoryId.toString(),
176
+ },
177
+ headers: {},
178
+ } as unknown as ExpressRequest;
179
+
180
+ const res: ExpressResponse = {
181
+ send: jest.fn(),
182
+ json: jest.fn(),
183
+ status: jest.fn().mockReturnThis(),
184
+ } as unknown as ExpressResponse;
185
+
186
+ const next: jest.Mock = jest.fn();
187
+
188
+ await mockRouter
189
+ .match("POST", TOKEN_ROUTE)
190
+ .handlerFunction(req, res, next as unknown as NextFunction);
191
+ }
192
+
193
+ describe("cross-tenant token minting", () => {
194
+ /*
195
+ * The advisory's step 3. Everything up to here passes: valid agent key,
196
+ * repository row in the agent's own project. Only the binding check stands
197
+ * between the attacker and a `ghs_` token over the victim's repositories.
198
+ */
199
+ test("refuses to mint when the row names an installation the project does not own", async () => {
200
+ mockRepository(victimInstallationId);
201
+ bindingSpy.mockResolvedValue(false);
202
+
203
+ await callRoute();
204
+
205
+ expect(mintTokenSpy).not.toHaveBeenCalled();
206
+ expect(thrownError()).toBeInstanceOf(BadDataException);
207
+ });
208
+
209
+ test("returns no token in the response when the binding is missing", async () => {
210
+ mockRepository(victimInstallationId);
211
+ bindingSpy.mockResolvedValue(false);
212
+
213
+ await callRoute();
214
+
215
+ expect(jsonResponse()).toBeUndefined();
216
+ });
217
+
218
+ test("checks the binding against the repository's project and installation", async () => {
219
+ mockRepository(ownInstallationId);
220
+ bindingSpy.mockResolvedValue(true);
221
+
222
+ await callRoute();
223
+
224
+ expect(bindingSpy).toHaveBeenCalledTimes(1);
225
+
226
+ const args: { projectId: ObjectID; installationId: string } = bindingSpy
227
+ .mock.calls[0]![0] as {
228
+ projectId: ObjectID;
229
+ installationId: string;
230
+ };
231
+
232
+ expect(args.projectId.toString()).toBe(attackerProjectId.toString());
233
+ expect(args.installationId).toBe(ownInstallationId);
234
+ });
235
+
236
+ /*
237
+ * Being in the right project is necessary but not sufficient — pin that
238
+ * the binding check is what gates the mint, so nobody "simplifies" it away
239
+ * on the grounds that the project check already ran.
240
+ */
241
+ test("the project check alone does not authorize a mint", async () => {
242
+ mockRepository(victimInstallationId);
243
+ bindingSpy.mockResolvedValue(false);
244
+
245
+ await callRoute();
246
+
247
+ // Repository genuinely belongs to the caller's project...
248
+ const repository: CodeRepository = (await findRepositorySpy.mock
249
+ .results[0]!.value) as CodeRepository;
250
+ expect(repository.projectId!.toString()).toBe(
251
+ attackerProjectId.toString(),
252
+ );
253
+
254
+ // ...and it still does not get a token.
255
+ expect(mintTokenSpy).not.toHaveBeenCalled();
256
+ });
257
+
258
+ test("checks the binding before calling GitHub at all", async () => {
259
+ const callOrder: Array<string> = [];
260
+
261
+ mockRepository(ownInstallationId);
262
+ bindingSpy.mockImplementation(async (): Promise<boolean> => {
263
+ callOrder.push("binding");
264
+ return true;
265
+ });
266
+ mintTokenSpy.mockImplementation(async (): Promise<JSONObject> => {
267
+ callOrder.push("mint");
268
+ return {
269
+ token: "ghs_live_token",
270
+ expiresAt: OneUptimeDate.getCurrentDate(),
271
+ } as unknown as JSONObject;
272
+ });
273
+
274
+ await callRoute();
275
+
276
+ expect(callOrder).toEqual(["binding", "mint"]);
277
+ });
278
+ });
279
+
280
+ describe("legitimate use keeps working", () => {
281
+ test("mints a token when the project genuinely owns the installation", async () => {
282
+ mockRepository(ownInstallationId);
283
+ bindingSpy.mockResolvedValue(true);
284
+
285
+ await callRoute();
286
+
287
+ expect(thrownError()).toBeUndefined();
288
+ expect(mintTokenSpy).toHaveBeenCalledTimes(1);
289
+ expect(mintTokenSpy.mock.calls[0]![0]).toBe(ownInstallationId);
290
+ expect(jsonResponse()!["token"]).toBe("ghs_live_token");
291
+ });
292
+
293
+ test("still requests only the scopes the agent needs", async () => {
294
+ mockRepository(ownInstallationId);
295
+ bindingSpy.mockResolvedValue(true);
296
+
297
+ await callRoute();
298
+
299
+ const options: {
300
+ permissions: {
301
+ contents?: string;
302
+ pull_requests?: string;
303
+ metadata?: string;
304
+ };
305
+ } = mintTokenSpy.mock.calls[0]![1] as {
306
+ permissions: {
307
+ contents?: string;
308
+ pull_requests?: string;
309
+ metadata?: string;
310
+ };
311
+ };
312
+
313
+ expect(options.permissions.contents).toBe("write");
314
+ expect(options.permissions.pull_requests).toBe("write");
315
+ expect(options.permissions.metadata).toBe("read");
316
+ });
317
+ });
318
+
319
+ describe("pre-existing guards still hold", () => {
320
+ test("rejects invalid agent credentials before reading anything", async () => {
321
+ jest
322
+ .spyOn(CodeFixAgentAuth, "resolveAgentIdentity")
323
+ .mockResolvedValue(null);
324
+
325
+ await callRoute();
326
+
327
+ expect(thrownError()).toBeInstanceOf(BadDataException);
328
+ expect(findRepositorySpy).not.toHaveBeenCalled();
329
+ expect(mintTokenSpy).not.toHaveBeenCalled();
330
+ });
331
+
332
+ test("rejects a repository in another project without checking a binding", async () => {
333
+ const repository: CodeRepository = new CodeRepository();
334
+ repository.id = codeRepositoryId;
335
+ repository.projectId = ObjectID.generate(); // someone else's project
336
+ repository.repositoryHostedAt = CodeRepositoryType.GitHub;
337
+ repository.gitHubAppInstallationId = victimInstallationId;
338
+ findRepositorySpy.mockResolvedValue(repository);
339
+
340
+ await callRoute();
341
+
342
+ expect(thrownError()).toBeInstanceOf(BadDataException);
343
+ expect(bindingSpy).not.toHaveBeenCalled();
344
+ expect(mintTokenSpy).not.toHaveBeenCalled();
345
+ });
346
+
347
+ test("rejects a repository with no installation id", async () => {
348
+ mockRepository(null);
349
+
350
+ await callRoute();
351
+
352
+ expect(thrownError()).toBeInstanceOf(BadDataException);
353
+ expect(mintTokenSpy).not.toHaveBeenCalled();
354
+ });
355
+
356
+ test("rejects a repository that does not exist", async () => {
357
+ findRepositorySpy.mockResolvedValue(null);
358
+
359
+ await callRoute();
360
+
361
+ expect(thrownError()).toBeInstanceOf(BadDataException);
362
+ expect(mintTokenSpy).not.toHaveBeenCalled();
363
+ });
364
+
365
+ test("still enforces the open pull request cap", async () => {
366
+ mockRepository(ownInstallationId);
367
+ bindingSpy.mockResolvedValue(true);
368
+
369
+ const capSpy: jest.SpyInstance = jest.spyOn(
370
+ OpenPullRequestCap,
371
+ "checkForRepository",
372
+ );
373
+ capSpy.mockResolvedValue({ allowed: false, openCount: 5, limit: 5 });
374
+
375
+ await callRoute();
376
+
377
+ expect(thrownError()).toBeInstanceOf(BadDataException);
378
+ expect(mintTokenSpy).not.toHaveBeenCalled();
379
+ });
380
+ });
381
+ });