From a2edd58e60013f910ec8b76a4fb9320816f84cfb Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sat, 4 Oct 2025 17:00:38 +0900 Subject: [PATCH 1/4] feat(core): Add support for Azure DevOps remote repository URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for Azure DevOps repository URLs in both SSH and HTTPS formats. Azure DevOps uses a special URL structure that differs from standard Git hosting services: - SSH: git@ssh.dev.azure.com:v3/organization/project/repo - HTTPS: https://dev.azure.com/organization/project/_git/repo The git-url-parse library can parse these URLs but its toString() method doesn't preserve the full path structure (e.g., v3/organization/ part is lost in SSH URLs). To address this, we now detect Azure DevOps URLs by checking the source field and use the original URL as-is instead of reconstructing it. Changes: - Modified parseRemoteValue() to use switch statement for source-based URL handling - Added Azure DevOps cases ('dev.azure.com' and 'azure.com') to preserve original URLs - Added test cases for both Azure DevOps SSH and HTTPS URL formats - All existing tests continue to pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/core/git/gitRemoteParse.ts | 34 ++++++++++++++++++++------- tests/core/git/gitRemoteParse.test.ts | 18 ++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 920ff1204..0ce01b38c 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -34,18 +34,34 @@ export const parseRemoteValue = ( try { const parsedFields = gitUrlParse(remoteValue, refs) as IGitUrl; - // This will make parsedFields.toString() automatically append '.git' to the returned url - parsedFields.git_suffix = true; - - const ownerSlashRepo = - parsedFields.full_name.split('/').length > 1 ? parsedFields.full_name.split('/').slice(-2).join('/') : ''; + let repoUrl: string; + + switch (parsedFields.source) { + // Azure DevOps uses special URL format: + // - SSH: git@ssh.dev.azure.com:v3/org/project/repo + // - HTTPS: https://dev.azure.com/organization/project/_git/repo + // The parser's toString() method doesn't preserve the full path, so use the original URL + case 'dev.azure.com': + case 'azure.com': + repoUrl = remoteValue; + break; + + default: { + // This will make parsedFields.toString() automatically append '.git' to the returned url + parsedFields.git_suffix = true; + + const ownerSlashRepo = + parsedFields.full_name.split('/').length > 1 ? parsedFields.full_name.split('/').slice(-2).join('/') : ''; + + if (ownerSlashRepo !== '' && !isValidShorthand(ownerSlashRepo)) { + throw new RepomixError('Invalid owner/repo in repo URL'); + } - if (ownerSlashRepo !== '' && !isValidShorthand(ownerSlashRepo)) { - throw new RepomixError('Invalid owner/repo in repo URL'); + repoUrl = parsedFields.toString(parsedFields.protocol); + break; + } } - const repoUrl = parsedFields.toString(parsedFields.protocol); - if (parsedFields.ref) { return { repoUrl: repoUrl, diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index 437a16627..e31fd925a 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -49,6 +49,24 @@ describe('remoteAction functions', () => { }); }); + test('should handle Azure DevOps SSH URLs', () => { + const azureDevOpsUrl = 'git@ssh.dev.azure.com:v3/organization/project/repo'; + const parsed = parseRemoteValue(azureDevOpsUrl); + expect(parsed).toEqual({ + repoUrl: azureDevOpsUrl, + remoteBranch: undefined, + }); + }); + + test('should handle Azure DevOps HTTPS URLs', () => { + const azureDevOpsUrl = 'https://dev.azure.com/organization/project/_git/repo'; + const parsed = parseRemoteValue(azureDevOpsUrl); + expect(parsed).toEqual({ + repoUrl: azureDevOpsUrl, + remoteBranch: undefined, + }); + }); + test('should get correct branch name from url', () => { expect(parseRemoteValue('https://github.com/username/repo/tree/branchname')).toEqual({ repoUrl: 'https://github.com/username/repo.git', From 5c232a8f63612019d496ba8f438bd435e3516db0 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sat, 4 Oct 2025 17:27:59 +0900 Subject: [PATCH 2/4] chore: Adjust dev container config --- .devcontainer/devcontainer.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7f6a172d1..14210f3fd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,16 +17,9 @@ "vscode": { "extensions": [ "anthropic.claude-code", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", "eamodio.gitlens" ], "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, "terminal.integrated.defaultProfile.linux": "zsh", "terminal.integrated.profiles.linux": { "bash": { From 179ca6fba49750f4e034eb1af02d53f0a5caeb8e Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 7 Oct 2025 23:28:36 +0900 Subject: [PATCH 3/4] fix(core): Improve Azure DevOps URL parsing support Address PR review feedback by expanding Azure DevOps URL support: - Add support for SSH URLs (ssh.dev.azure.com) - Add support for legacy Visual Studio Team Services (*.visualstudio.com) - Remove invalid azure.com case - Add test coverage for legacy VSTS URLs - Move Azure DevOps detection before git-url-parse to avoid parsing issues This ensures compatibility with all Azure DevOps URL formats including modern and legacy domains. --- src/core/git/gitRemoteParse.ts | 48 +++++++++++++-------------- tests/core/git/gitRemoteParse.test.ts | 9 +++++ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 0ce01b38c..80c0f5baf 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -31,37 +31,37 @@ export const parseRemoteValue = ( }; } + // Check for Azure DevOps URLs before parsing, as git-url-parse may not handle them correctly + // - SSH: git@ssh.dev.azure.com:v3/org/project/repo + // - HTTPS: https://dev.azure.com/organization/project/_git/repo + // - Legacy: https://org.visualstudio.com/project/_git/repo + const isAzureDevOpsUrl = + remoteValue.includes('dev.azure.com') || + remoteValue.includes('ssh.dev.azure.com') || + remoteValue.includes('.visualstudio.com'); + + if (isAzureDevOpsUrl) { + return { + repoUrl: remoteValue, + remoteBranch: undefined, + }; + } + try { const parsedFields = gitUrlParse(remoteValue, refs) as IGitUrl; - let repoUrl: string; - - switch (parsedFields.source) { - // Azure DevOps uses special URL format: - // - SSH: git@ssh.dev.azure.com:v3/org/project/repo - // - HTTPS: https://dev.azure.com/organization/project/_git/repo - // The parser's toString() method doesn't preserve the full path, so use the original URL - case 'dev.azure.com': - case 'azure.com': - repoUrl = remoteValue; - break; + // This will make parsedFields.toString() automatically append '.git' to the returned url + parsedFields.git_suffix = true; - default: { - // This will make parsedFields.toString() automatically append '.git' to the returned url - parsedFields.git_suffix = true; + const ownerSlashRepo = + parsedFields.full_name.split('/').length > 1 ? parsedFields.full_name.split('/').slice(-2).join('/') : ''; - const ownerSlashRepo = - parsedFields.full_name.split('/').length > 1 ? parsedFields.full_name.split('/').slice(-2).join('/') : ''; - - if (ownerSlashRepo !== '' && !isValidShorthand(ownerSlashRepo)) { - throw new RepomixError('Invalid owner/repo in repo URL'); - } - - repoUrl = parsedFields.toString(parsedFields.protocol); - break; - } + if (ownerSlashRepo !== '' && !isValidShorthand(ownerSlashRepo)) { + throw new RepomixError('Invalid owner/repo in repo URL'); } + const repoUrl = parsedFields.toString(parsedFields.protocol); + if (parsedFields.ref) { return { repoUrl: repoUrl, diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index e31fd925a..24a7a60d2 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -67,6 +67,15 @@ describe('remoteAction functions', () => { }); }); + test('should handle legacy Visual Studio Team Services URLs', () => { + const vstsUrl = 'https://myorg.visualstudio.com/myproject/_git/myrepo'; + const parsed = parseRemoteValue(vstsUrl); + expect(parsed).toEqual({ + repoUrl: vstsUrl, + remoteBranch: undefined, + }); + }); + test('should get correct branch name from url', () => { expect(parseRemoteValue('https://github.com/username/repo/tree/branchname')).toEqual({ repoUrl: 'https://github.com/username/repo.git', From 3a10a9f25504c7302a73f2cf47d614e50a045274 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 7 Oct 2025 23:43:38 +0900 Subject: [PATCH 4/4] fix(core): Use proper hostname validation for Azure DevOps URLs Replace substring matching with proper URL parsing to fix CodeQL security alert. Previously, the code used `includes()` for substring matching which could incorrectly identify malicious URLs like `https://evil.com/dev.azure.com/` as Azure DevOps URLs. Changes: - Extract Azure DevOps URL detection into a dedicated function - Use URL constructor to parse and validate the hostname - For SSH URLs, use `startsWith()` for exact prefix matching - For HTTP(S) URLs, check the hostname property exactly - Add security tests to ensure malicious URLs are not incorrectly identified This resolves the "Incomplete URL substring sanitization" alert from CodeQL. --- src/core/git/gitRemoteParse.ts | 39 ++++++++++++++++++++++----- tests/core/git/gitRemoteParse.test.ts | 15 +++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 80c0f5baf..323294949 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -19,6 +19,38 @@ export const isValidShorthand = (remoteValue: string): boolean => { return validShorthandRegex.test(remoteValue); }; +/** + * Check if a URL is an Azure DevOps repository URL by validating the hostname. + * This uses proper URL parsing to avoid security issues with substring matching. + */ +const isAzureDevOpsUrl = (remoteValue: string): boolean => { + // Handle SSH URLs (e.g., git@ssh.dev.azure.com:v3/org/project/repo) + if (remoteValue.startsWith('git@ssh.dev.azure.com:')) { + return true; + } + + // Handle HTTP(S) URLs + try { + const url = new URL(remoteValue); + const hostname = url.hostname.toLowerCase(); + + // Check for exact Azure DevOps hostnames + if (hostname === 'dev.azure.com' || hostname === 'ssh.dev.azure.com') { + return true; + } + + // Check for legacy Visual Studio Team Services (*.visualstudio.com) + if (hostname.endsWith('.visualstudio.com')) { + return true; + } + + return false; + } catch { + // Not a valid URL, let git-url-parse handle it + return false; + } +}; + export const parseRemoteValue = ( remoteValue: string, refs: string[] = [], @@ -35,12 +67,7 @@ export const parseRemoteValue = ( // - SSH: git@ssh.dev.azure.com:v3/org/project/repo // - HTTPS: https://dev.azure.com/organization/project/_git/repo // - Legacy: https://org.visualstudio.com/project/_git/repo - const isAzureDevOpsUrl = - remoteValue.includes('dev.azure.com') || - remoteValue.includes('ssh.dev.azure.com') || - remoteValue.includes('.visualstudio.com'); - - if (isAzureDevOpsUrl) { + if (isAzureDevOpsUrl(remoteValue)) { return { repoUrl: remoteValue, remoteBranch: undefined, diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index 24a7a60d2..d8959d54f 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -76,6 +76,21 @@ describe('remoteAction functions', () => { }); }); + test('should not treat URLs with Azure DevOps hostnames in path as Azure DevOps URLs', () => { + // Security test: Ensure URLs with Azure DevOps keywords in the path are not treated as Azure DevOps + const maliciousUrl = 'https://evil.com/dev.azure.com/fake/repo'; + const parsed = parseRemoteValue(maliciousUrl); + // Should be parsed normally (not as Azure DevOps), with .git suffix added + expect(parsed.repoUrl).toBe('https://evil.com/dev.azure.com/fake/repo.git'); + }); + + test('should not treat URLs with visualstudio.com in path as Azure DevOps URLs', () => { + const maliciousUrl = 'https://evil.com/path/visualstudio.com/fake/repo'; + const parsed = parseRemoteValue(maliciousUrl); + // Should be parsed normally (not as Azure DevOps), with .git suffix added + expect(parsed.repoUrl).toBe('https://evil.com/path/visualstudio.com/fake/repo.git'); + }); + test('should get correct branch name from url', () => { expect(parseRemoteValue('https://github.com/username/repo/tree/branchname')).toEqual({ repoUrl: 'https://github.com/username/repo.git',