@superblocksteam/shared 0.9609.6 → 2.0.0-SNAPSHOT.7ea2658f98892d2c

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 (185) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +2 -1
  4. package/dist/constants.js.map +1 -1
  5. package/dist/database-lifecycle/destructiveMigrations.d.ts +1 -0
  6. package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
  7. package/dist/database-lifecycle/destructiveMigrations.js +21 -1
  8. package/dist/database-lifecycle/destructiveMigrations.js.map +1 -1
  9. package/dist/database-lifecycle/destructiveMigrations.test.js +22 -0
  10. package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -1
  11. package/dist/socket/heartbeat.d.ts +10 -0
  12. package/dist/socket/heartbeat.d.ts.map +1 -1
  13. package/dist/socket/heartbeat.js +5 -0
  14. package/dist/socket/heartbeat.js.map +1 -1
  15. package/dist/socket/heartbeat.test.js +36 -0
  16. package/dist/socket/heartbeat.test.js.map +1 -1
  17. package/dist/socket/protocol.d.ts +2 -2
  18. package/dist/socket/protocol.d.ts.map +1 -1
  19. package/dist/socket/protocol.js.map +1 -1
  20. package/dist/socket/socket.d.ts +13 -0
  21. package/dist/socket/socket.d.ts.map +1 -1
  22. package/dist/socket/socket.js +21 -2
  23. package/dist/socket/socket.js.map +1 -1
  24. package/dist/socket/socket.test.js +33 -0
  25. package/dist/socket/socket.test.js.map +1 -1
  26. package/dist/types/codeSecurity/index.d.ts +6 -0
  27. package/dist/types/codeSecurity/index.d.ts.map +1 -1
  28. package/dist/types/codeSecurity/index.js +23 -0
  29. package/dist/types/codeSecurity/index.js.map +1 -1
  30. package/dist/types/codeSecurity/index.test.js +48 -0
  31. package/dist/types/codeSecurity/index.test.js.map +1 -1
  32. package/dist/types/policyGate/errors.d.ts +70 -0
  33. package/dist/types/policyGate/errors.d.ts.map +1 -0
  34. package/dist/types/policyGate/errors.js +42 -0
  35. package/dist/types/policyGate/errors.js.map +1 -0
  36. package/dist/types/policyGate/index.d.ts +3 -0
  37. package/dist/types/policyGate/index.d.ts.map +1 -1
  38. package/dist/types/policyGate/index.js +22 -0
  39. package/dist/types/policyGate/index.js.map +1 -1
  40. package/dist/types/publishPipeline/displayNames.js +6 -6
  41. package/dist/types/publishPipeline/displayNames.js.map +1 -1
  42. package/dist/types/publishPipeline/displayNames.test.js +5 -5
  43. package/dist/types/publishPipeline/displayNames.test.js.map +1 -1
  44. package/dist/types/publishPipeline/index.d.ts +3 -3
  45. package/dist/types/publishPipeline/index.d.ts.map +1 -1
  46. package/dist/types/publishPipeline/plan.d.ts +1 -11
  47. package/dist/types/publishPipeline/plan.d.ts.map +1 -1
  48. package/dist/types/publishPipeline/plan.js +2 -57
  49. package/dist/types/publishPipeline/plan.js.map +1 -1
  50. package/dist/types/publishPipeline/plan.test.js +9 -95
  51. package/dist/types/publishPipeline/plan.test.js.map +1 -1
  52. package/dist/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
  53. package/dist/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
  54. package/dist/types/response/PostApplicationCloneResponseBody.js +3 -0
  55. package/dist/types/response/PostApplicationCloneResponseBody.js.map +1 -0
  56. package/dist/types/response/index.d.ts +1 -0
  57. package/dist/types/response/index.d.ts.map +1 -1
  58. package/dist/types/response/index.js +1 -0
  59. package/dist/types/response/index.js.map +1 -1
  60. package/dist/types/reviewPolicy/index.d.ts +5 -0
  61. package/dist/types/reviewPolicy/index.d.ts.map +1 -1
  62. package/dist/types/reviewPolicy/index.js.map +1 -1
  63. package/dist/utils/attachment-upload.d.ts +40 -0
  64. package/dist/utils/attachment-upload.d.ts.map +1 -1
  65. package/dist/utils/attachment-upload.js +90 -1
  66. package/dist/utils/attachment-upload.js.map +1 -1
  67. package/dist/utils/attachment-upload.test.js +61 -0
  68. package/dist/utils/attachment-upload.test.js.map +1 -1
  69. package/dist/utils/git-url.d.ts.map +1 -1
  70. package/dist/utils/git-url.js +29 -6
  71. package/dist/utils/git-url.js.map +1 -1
  72. package/dist/utils/git-url.test.js +60 -0
  73. package/dist/utils/git-url.test.js.map +1 -1
  74. package/dist/utils/normalizeRoleName.js +2 -2
  75. package/dist/utils/normalizeRoleName.js.map +1 -1
  76. package/dist/utils/normalizeRoleName.test.d.ts +2 -0
  77. package/dist/utils/normalizeRoleName.test.d.ts.map +1 -0
  78. package/dist/utils/normalizeRoleName.test.js +25 -0
  79. package/dist/utils/normalizeRoleName.test.js.map +1 -0
  80. package/dist-esm/constants.d.ts +1 -0
  81. package/dist-esm/constants.d.ts.map +1 -1
  82. package/dist-esm/constants.js +1 -0
  83. package/dist-esm/constants.js.map +1 -1
  84. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +1 -0
  85. package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
  86. package/dist-esm/database-lifecycle/destructiveMigrations.js +21 -2
  87. package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -1
  88. package/dist-esm/database-lifecycle/destructiveMigrations.test.js +23 -1
  89. package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -1
  90. package/dist-esm/socket/heartbeat.d.ts +10 -0
  91. package/dist-esm/socket/heartbeat.d.ts.map +1 -1
  92. package/dist-esm/socket/heartbeat.js +5 -0
  93. package/dist-esm/socket/heartbeat.js.map +1 -1
  94. package/dist-esm/socket/heartbeat.test.js +36 -0
  95. package/dist-esm/socket/heartbeat.test.js.map +1 -1
  96. package/dist-esm/socket/protocol.d.ts +2 -2
  97. package/dist-esm/socket/protocol.d.ts.map +1 -1
  98. package/dist-esm/socket/protocol.js.map +1 -1
  99. package/dist-esm/socket/socket.d.ts +13 -0
  100. package/dist-esm/socket/socket.d.ts.map +1 -1
  101. package/dist-esm/socket/socket.js +20 -1
  102. package/dist-esm/socket/socket.js.map +1 -1
  103. package/dist-esm/socket/socket.test.js +33 -0
  104. package/dist-esm/socket/socket.test.js.map +1 -1
  105. package/dist-esm/types/codeSecurity/index.d.ts +6 -0
  106. package/dist-esm/types/codeSecurity/index.d.ts.map +1 -1
  107. package/dist-esm/types/codeSecurity/index.js +22 -0
  108. package/dist-esm/types/codeSecurity/index.js.map +1 -1
  109. package/dist-esm/types/codeSecurity/index.test.js +49 -1
  110. package/dist-esm/types/codeSecurity/index.test.js.map +1 -1
  111. package/dist-esm/types/policyGate/errors.d.ts +70 -0
  112. package/dist-esm/types/policyGate/errors.d.ts.map +1 -0
  113. package/dist-esm/types/policyGate/errors.js +37 -0
  114. package/dist-esm/types/policyGate/errors.js.map +1 -0
  115. package/dist-esm/types/policyGate/index.d.ts +3 -0
  116. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  117. package/dist-esm/types/policyGate/index.js +7 -0
  118. package/dist-esm/types/policyGate/index.js.map +1 -1
  119. package/dist-esm/types/publishPipeline/displayNames.js +6 -6
  120. package/dist-esm/types/publishPipeline/displayNames.js.map +1 -1
  121. package/dist-esm/types/publishPipeline/displayNames.test.js +5 -5
  122. package/dist-esm/types/publishPipeline/displayNames.test.js.map +1 -1
  123. package/dist-esm/types/publishPipeline/index.d.ts +3 -3
  124. package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
  125. package/dist-esm/types/publishPipeline/plan.d.ts +1 -11
  126. package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
  127. package/dist-esm/types/publishPipeline/plan.js +2 -56
  128. package/dist-esm/types/publishPipeline/plan.js.map +1 -1
  129. package/dist-esm/types/publishPipeline/plan.test.js +10 -96
  130. package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
  131. package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
  132. package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
  133. package/dist-esm/types/response/PostApplicationCloneResponseBody.js +2 -0
  134. package/dist-esm/types/response/PostApplicationCloneResponseBody.js.map +1 -0
  135. package/dist-esm/types/response/index.d.ts +1 -0
  136. package/dist-esm/types/response/index.d.ts.map +1 -1
  137. package/dist-esm/types/response/index.js +1 -0
  138. package/dist-esm/types/response/index.js.map +1 -1
  139. package/dist-esm/types/reviewPolicy/index.d.ts +5 -0
  140. package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
  141. package/dist-esm/types/reviewPolicy/index.js.map +1 -1
  142. package/dist-esm/utils/attachment-upload.d.ts +40 -0
  143. package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
  144. package/dist-esm/utils/attachment-upload.js +85 -0
  145. package/dist-esm/utils/attachment-upload.js.map +1 -1
  146. package/dist-esm/utils/attachment-upload.test.js +62 -1
  147. package/dist-esm/utils/attachment-upload.test.js.map +1 -1
  148. package/dist-esm/utils/git-url.d.ts.map +1 -1
  149. package/dist-esm/utils/git-url.js +29 -6
  150. package/dist-esm/utils/git-url.js.map +1 -1
  151. package/dist-esm/utils/git-url.test.js +60 -0
  152. package/dist-esm/utils/git-url.test.js.map +1 -1
  153. package/dist-esm/utils/normalizeRoleName.js +2 -2
  154. package/dist-esm/utils/normalizeRoleName.js.map +1 -1
  155. package/dist-esm/utils/normalizeRoleName.test.d.ts +2 -0
  156. package/dist-esm/utils/normalizeRoleName.test.d.ts.map +1 -0
  157. package/dist-esm/utils/normalizeRoleName.test.js +23 -0
  158. package/dist-esm/utils/normalizeRoleName.test.js.map +1 -0
  159. package/package.json +1 -1
  160. package/src/constants.ts +1 -0
  161. package/src/database-lifecycle/destructiveMigrations.test.ts +27 -1
  162. package/src/database-lifecycle/destructiveMigrations.ts +22 -2
  163. package/src/socket/heartbeat.test.ts +44 -0
  164. package/src/socket/heartbeat.ts +15 -0
  165. package/src/socket/protocol.ts +2 -1
  166. package/src/socket/socket.test.ts +43 -0
  167. package/src/socket/socket.ts +22 -1
  168. package/src/types/codeSecurity/index.test.ts +58 -1
  169. package/src/types/codeSecurity/index.ts +38 -0
  170. package/src/types/policyGate/errors.ts +51 -0
  171. package/src/types/policyGate/index.ts +12 -0
  172. package/src/types/publishPipeline/displayNames.test.ts +5 -5
  173. package/src/types/publishPipeline/displayNames.ts +6 -6
  174. package/src/types/publishPipeline/index.ts +3 -3
  175. package/src/types/publishPipeline/plan.test.ts +12 -105
  176. package/src/types/publishPipeline/plan.ts +3 -62
  177. package/src/types/response/PostApplicationCloneResponseBody.ts +12 -0
  178. package/src/types/response/index.ts +1 -0
  179. package/src/types/reviewPolicy/index.ts +5 -0
  180. package/src/utils/attachment-upload.test.ts +70 -1
  181. package/src/utils/attachment-upload.ts +106 -0
  182. package/src/utils/git-url.test.ts +65 -0
  183. package/src/utils/git-url.ts +29 -6
  184. package/src/utils/normalizeRoleName.test.ts +27 -0
  185. package/src/utils/normalizeRoleName.ts +2 -2
@@ -291,6 +291,34 @@ function getTextLikeMimeTypeFromFileName(fileName: string | undefined): string |
291
291
  return textAttachmentType ? TEXT_ATTACHMENT_MIME_TYPES[textAttachmentType] : undefined;
292
292
  }
293
293
 
294
+ /**
295
+ * Names the media type for a file that arrives with nothing but a name, as it
296
+ * does when a caller points at a path instead of handing over the bytes.
297
+ *
298
+ * Derived from {@link MIME_TYPE_FILE_EXTENSIONS} rather than a general-purpose
299
+ * MIME database so the answer stays inside what attachments actually accept:
300
+ * a wider lookup would name a type for `.exe` that upload then refuses.
301
+ */
302
+ export function getAttachmentMimeTypeFromFileName(fileName?: string): string | undefined {
303
+ const normalizedFileName = normalizeNonEmptyString(fileName)?.toLowerCase();
304
+ if (!normalizedFileName) {
305
+ return undefined;
306
+ }
307
+
308
+ for (const [mimeType, extension] of Object.entries(MIME_TYPE_FILE_EXTENSIONS)) {
309
+ // '.jpeg' resolves through the text-like pass below only if listed there,
310
+ // so both spellings are matched here against the canonical extension.
311
+ if (normalizedFileName.endsWith(extension)) {
312
+ return mimeType === 'image/jpg' ? 'image/jpeg' : mimeType;
313
+ }
314
+ }
315
+ if (normalizedFileName.endsWith('.jpeg')) {
316
+ return 'image/jpeg';
317
+ }
318
+
319
+ return getTextLikeMimeTypeFromFileName(normalizedFileName);
320
+ }
321
+
294
322
  function isProviderSafeTextMimeType(mimeType: string): boolean {
295
323
  return mimeType.startsWith('text/') || mimeType === 'application/json';
296
324
  }
@@ -630,8 +658,86 @@ export async function uploadAttachmentToServerApi(params: {
630
658
 
631
659
  if (!response.ok) {
632
660
  const errorText = await response.text().catch(() => '');
661
+ const parsed = parseUserFacingUploadError(errorText);
662
+ if (parsed) {
663
+ throw new AttachmentUploadApiError(parsed.message, {
664
+ code: parsed.code,
665
+ status: response.status
666
+ });
667
+ }
633
668
  throw new Error(`Attachment upload failed: status=${response.status}${errorText ? `, body=${errorText}` : ''}`);
634
669
  }
635
670
 
636
671
  return (await response.json()) as AttachmentUploadServerDto;
637
672
  }
673
+
674
+ /**
675
+ * The upload route sets this code ONLY on rejections whose `{ error }` sentence
676
+ * is written for the end user and is safe to show verbatim.
677
+ *
678
+ * `{ error }` is otherwise the route's generic diagnostic channel. It carries
679
+ * internal 500/502 detail (app-scoped-token, invalid Bucketeer response, raw
680
+ * Bucketeer text). Promoting that to a user-facing sentence leaked upstream
681
+ * internals into the UI, so the verbatim path is gated on this code rather
682
+ * than on the presence of the field.
683
+ */
684
+ export type UserFacingUploadErrorCode = 'attachment_content_mismatch';
685
+
686
+ export const ATTACHMENT_CONTENT_MISMATCH_CODE: UserFacingUploadErrorCode = 'attachment_content_mismatch';
687
+
688
+ export class AttachmentUploadApiError extends Error {
689
+ readonly code: UserFacingUploadErrorCode;
690
+ readonly status: number;
691
+
692
+ constructor(message: string, options: { code: UserFacingUploadErrorCode; status: number }) {
693
+ super(message);
694
+ this.name = 'AttachmentUploadApiError';
695
+ this.code = options.code;
696
+ this.status = options.status;
697
+ }
698
+ }
699
+
700
+ export function isUserFacingUploadError(error: unknown): error is AttachmentUploadApiError {
701
+ return error instanceof AttachmentUploadApiError;
702
+ }
703
+
704
+ function readStringProperty(value: object, key: string): string | undefined {
705
+ if (!(key in value)) {
706
+ return undefined;
707
+ }
708
+ const property = Reflect.get(value, key);
709
+ return typeof property === 'string' ? property : undefined;
710
+ }
711
+
712
+ function isUserFacingUploadErrorCode(code: string): code is UserFacingUploadErrorCode {
713
+ return code === ATTACHMENT_CONTENT_MISMATCH_CODE;
714
+ }
715
+
716
+ /**
717
+ * Pull the server's user-facing `{ code, error }` pair out of a response body.
718
+ * Returns undefined unless `code` is {@link ATTACHMENT_CONTENT_MISMATCH_CODE},
719
+ * and for non-JSON bodies, non-string `error` fields, and blank strings, so
720
+ * the caller keeps the diagnostic form that still carries the HTTP status.
721
+ */
722
+ export function parseUserFacingUploadError(body: string): { code: UserFacingUploadErrorCode; message: string } | undefined {
723
+ if (!body.trim()) {
724
+ return undefined;
725
+ }
726
+ try {
727
+ const parsed: unknown = JSON.parse(body);
728
+ if (typeof parsed !== 'object' || parsed === null) {
729
+ return undefined;
730
+ }
731
+ const code = readStringProperty(parsed, 'code');
732
+ const message = readStringProperty(parsed, 'error');
733
+ if (code === undefined || !isUserFacingUploadErrorCode(code)) {
734
+ return undefined;
735
+ }
736
+ if (message === undefined || !message.trim()) {
737
+ return undefined;
738
+ }
739
+ return { code, message };
740
+ } catch {
741
+ return undefined;
742
+ }
743
+ }
@@ -21,6 +21,10 @@ describe('isValidGitUrl', () => {
21
21
  it('trims whitespace', () => {
22
22
  expect(isValidGitUrl(' https://github.com/org/repo ')).toBe(true);
23
23
  });
24
+
25
+ it('accepts ssh:// double-slash absolute-path URLs (valid git SSH syntax)', () => {
26
+ expect(isValidGitUrl('ssh://git@host//org/repo.git')).toBe(true);
27
+ });
24
28
  });
25
29
 
26
30
  describe('isGitHubUrl', () => {
@@ -144,3 +148,64 @@ describe('sshGitUrlToHttps', () => {
144
148
  expect(sshGitUrlToHttps('not-a-url')).toBeNull();
145
149
  });
146
150
  });
151
+
152
+ describe('git-url ReDoS (SEC-78)', () => {
153
+ it('still validates/normalizes real URLs', () => {
154
+ expect(isValidGitUrl('https://github.com/org/repo.git')).toBe(true);
155
+ expect(isValidGitUrl('git@github.com:org/repo.git')).toBe(true);
156
+ expect(isValidGitUrl('ssh://git@github.com/org/repo')).toBe(true);
157
+ expect(isValidGitUrl('not a url')).toBe(false);
158
+ expect(gitRemoteUrlToWeb('git@github.com:org/repo.git')).toBe('https://github.com/org/repo');
159
+ expect(sshGitUrlToHttps('git@github.com:org/repo.git')).toBe('https://github.com/org/repo.git');
160
+ });
161
+
162
+ it('still converts SCP-style paths containing spaces (e.g. Azure DevOps repo names)', () => {
163
+ // Raw SCP `git@host:path` strings are not URL-encoded by convention, so
164
+ // legitimate paths (Azure DevOps project/repo names in particular) may
165
+ // contain literal spaces. The bounded regex must preserve this — only the
166
+ // length is capped, not the character class.
167
+ expect(gitRemoteUrlToWeb('git@host:org/repo name.git')).toBe('https://host/org/repo name');
168
+ expect(sshGitUrlToHttps('git@host:org/repo name.git')).toBe('https://host/org/repo name.git');
169
+ });
170
+
171
+ it('does not rescan trailing whitespace polynomially on the SCP sshMatch fallback', () => {
172
+ // Internal-whitespace shape: a long run of spaces BEFORE the final
173
+ // non-whitespace char, rather than after it (the existing adversarial
174
+ // test puts the non-whitespace run first). Against the pre-fix
175
+ // `(?:\.git)?\s*$` tail this drove a genuine O(4096*n) scan (measured
176
+ // ~3.9s at n=1,000,000) because the lazy path capture and the trailing
177
+ // `\s*` were two independent backtracking axes. `trimmed` never ends in
178
+ // whitespace, so `\s*` before the final `$` was always redundant — it
179
+ // was dropped in favor of a plain `$`.
180
+ const evil = 'git@h:' + ' '.repeat(200000) + 'X';
181
+ const start = performance.now();
182
+ gitRemoteUrlToWeb(evil);
183
+ sshGitUrlToHttps(evil);
184
+ expect(performance.now() - start).toBeLessThan(100);
185
+ });
186
+
187
+ it('does not backtrack polynomially on adversarial inputs', () => {
188
+ const cases = [
189
+ 'https://' + 'a.'.repeat(50000), // HTTPS_GIT_URL_RE host
190
+ 'https://x/' + 'a/'.repeat(50000), // HTTPS_GIT_URL_RE path
191
+ 'git@' + 'a.'.repeat(50000) + ':x', // SSH_GIT_URL_RE host
192
+ 'ssh://git@h/' + 'a/'.repeat(50000), // SSH_PROTOCOL_RE path
193
+ 'git@h:' + 'a'.repeat(200000), // SCP sshMatch (L72/226)
194
+ // Trailing-whitespace ambiguity against the `(?:\.git)?\s*$` tail: the
195
+ // lazy `.+?` capture and the `\s*` quantifier are two independent
196
+ // O(n) backtracking axes, which combine into genuine O(n^2) blowup on
197
+ // the pre-fix regex (confirmed: ~35ms at n=8000, ~120ms at n=16000 —
198
+ // consistent ~4x-per-2x quadratic scaling; would take tens of seconds
199
+ // at n=200000 without the bound).
200
+ 'git@h:' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X',
201
+ 'git@ssh.dev.azure.com:v3/o/p/' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X'
202
+ ];
203
+ for (const evil of cases) {
204
+ const start = performance.now();
205
+ isValidGitUrl(evil);
206
+ gitRemoteUrlToWeb(evil);
207
+ sshGitUrlToHttps(evil);
208
+ expect(performance.now() - start).toBeLessThan(150);
209
+ }
210
+ });
211
+ });
@@ -5,10 +5,13 @@
5
5
  * Used by both the UI and the vite-plugin-file-sync AI agent tooling.
6
6
  */
7
7
 
8
- const HTTPS_GIT_URL_RE = /^https?:\/\/[^\s/$.?#]+(?:\.[^\s/$.?#]+)+\/[^\s/$.?#]+(\/[^\s/$.?#]+)+(\.git)?\/?$/i;
9
- const SSH_GIT_URL_RE = /^[\w-]+@[\w.-]+:[\w./-]+(\.git)?$/;
8
+ // Character classes below are bounded ({1,256} / {1,4096}) to avoid polynomial
9
+ // backtracking on adversarial input (SEC-78) — real hosts/paths are far shorter,
10
+ // so behavior is preserved for every valid git remote URL.
11
+ const HTTPS_GIT_URL_RE = /^https?:\/\/[^\s/$.?#]{1,256}(?:\.[^\s/$.?#]{1,256})+\/[^\s/$.?#]{1,256}(?:\/[^\s/$.?#]{1,256})+(?:\.git)?\/?$/i;
12
+ const SSH_GIT_URL_RE = /^[\w-]{1,256}@[\w.-]{1,256}:[\w./-]{1,4096}(?:\.git)?$/;
10
13
  // ssh:// protocol format: ssh://git@host/path
11
- const SSH_PROTOCOL_RE = /^ssh:\/\/[\w-]+@[\w.-]+(\/[\w./-]+)+(\.git)?\/?$/i;
14
+ const SSH_PROTOCOL_RE = /^ssh:\/\/[\w-]{1,256}@[\w.-]{1,256}\/[\w./-]{1,4096}(?:\.git)?\/?$/i;
12
15
 
13
16
  export function isValidGitUrl(url: string): boolean {
14
17
  const trimmed = url.trim();
@@ -69,7 +72,15 @@ export function gitRemoteUrlToWeb(url: string): string | null {
69
72
  }
70
73
 
71
74
  // SCP-style SSH: git@host:org/repo.git → https://host/org/repo
72
- const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):(.+?)(?:\.git)?\s*$/);
75
+ // Path capture is bounded ({1,4096}) so it cannot drive polynomial
76
+ // backtracking on adversarial input (SEC-78). The char class itself is
77
+ // unchanged (`.`) — paths may legitimately contain spaces (e.g. Azure
78
+ // DevOps project/repo names), and the numeric bound alone is enough to
79
+ // keep matching linear. `trimmed` (see top of function) never ends in
80
+ // whitespace, so the trailing `\s*` that used to sit before `$` was
81
+ // redundant (always zero-width) and only added its own O(n) rescan per
82
+ // lazy-capture step — dropped for a plain `$`.
83
+ const sshMatch = trimmed.match(/^[\w-]{1,256}@([\w.-]{1,256}):(.{1,4096}?)(?:\.git)?$/);
73
84
  if (sshMatch?.[1] && sshMatch[2]) {
74
85
  const host = sshMatch[1];
75
86
  const path = sshMatch[2];
@@ -212,7 +223,12 @@ export function sshGitUrlToHttps(url: string): string | null {
212
223
  const host = parsed.hostname;
213
224
  const path = parsed.pathname;
214
225
  if (host === 'ssh.dev.azure.com') {
215
- const azureMatch = path.match(/^\/v3\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
226
+ // Path capture is bounded ({1,4096}) so the trailing `(?:\.git)?\/?$`
227
+ // cannot drive polynomial backtracking on adversarial input (SEC-78).
228
+ // The char class itself is unchanged (`.`) — Azure DevOps repo names
229
+ // may legitimately contain spaces, and the numeric bound alone is
230
+ // enough to keep matching linear.
231
+ const azureMatch = path.match(/^\/v3\/([^/]{1,256})\/([^/]{1,256})\/(.{1,4096}?)(?:\.git)?\/?$/);
216
232
  if (azureMatch) {
217
233
  return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`;
218
234
  }
@@ -223,7 +239,14 @@ export function sshGitUrlToHttps(url: string): string | null {
223
239
  // Not a standard URL — try SCP-style SSH below.
224
240
  }
225
241
 
226
- const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):(.+?)(?:\.git)?\s*$/);
242
+ // Path capture is bounded ({1,4096}) so it cannot drive polynomial
243
+ // backtracking on adversarial input (SEC-78). The char class itself is
244
+ // unchanged (`.`) — paths may legitimately contain spaces, and the numeric
245
+ // bound alone is enough to keep matching linear. `trimmed` (see top of
246
+ // function) never ends in whitespace, so the trailing `\s*` that used to
247
+ // sit before `$` was redundant (always zero-width) and only added its own
248
+ // O(n) rescan per lazy-capture step — dropped for a plain `$`.
249
+ const sshMatch = trimmed.match(/^[\w-]{1,256}@([\w.-]{1,256}):(.{1,4096}?)(?:\.git)?$/);
227
250
  if (sshMatch?.[1] && sshMatch[2]) {
228
251
  const host = sshMatch[1];
229
252
  const path = sshMatch[2];
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { normalizeRoleName } from './normalizeRoleName.js';
4
+
5
+ describe('normalizeRoleName', () => {
6
+ it('lowercases a simple name', () => {
7
+ expect(normalizeRoleName('Admin')).toBe('admin');
8
+ });
9
+
10
+ it('collapses internal non-alphanumeric runs to a single underscore', () => {
11
+ expect(normalizeRoleName('Team Lead')).toBe('team_lead');
12
+ expect(normalizeRoleName('a__b')).toBe('a_b');
13
+ });
14
+
15
+ it('strips leading and trailing non-alphanumeric characters', () => {
16
+ expect(normalizeRoleName(' Team Lead ')).toBe('team_lead');
17
+ expect(normalizeRoleName('!!!Role@@@')).toBe('role');
18
+ expect(normalizeRoleName('___')).toBe('');
19
+ });
20
+
21
+ it('handles a long non-alphanumeric run without catastrophic backtracking', () => {
22
+ const input = `${'!'.repeat(100_000)}role`;
23
+ const start = Date.now();
24
+ expect(normalizeRoleName(input)).toBe('role');
25
+ expect(Date.now() - start).toBeLessThan(1_000);
26
+ });
27
+ });
@@ -1,7 +1,7 @@
1
1
  export function normalizeRoleName(displayName: string): string {
2
2
  const key = displayName
3
3
  .trim() // remove leading/trailing whitespace
4
- .replace(/^[\W_]+|[\W_]+$/g, '') // remove leading/trailing non-alphanumeric characters
5
- .replace(/[\W_]+/g, '_'); // replace non-alphanumeric characters with underscores
4
+ .replace(/[\W_]+/g, '_') // collapse non-alphanumeric runs to a single underscore (linear)
5
+ .replace(/^_|_$/g, ''); // strip the single edge underscore left by the collapse step (no quantifier -> no polynomial shape)
6
6
  return key.toLowerCase();
7
7
  }