diff --git a/nest/src/constants/validation.constants.spec.ts b/nest/src/constants/validation.constants.spec.ts new file mode 100644 index 0000000..57a05f8 --- /dev/null +++ b/nest/src/constants/validation.constants.spec.ts @@ -0,0 +1,65 @@ +import { + isValidCommitSha, + isValidGitRef, + isValidNearAccountId, +} from './validation.constants'; + +describe('validation constants', () => { + it('should accept real NEAR account ID formats', () => { + const validAccountIds = [ + 'sourcescan.near', + 'v2-verifier.sourcescan.near', + 'wrap.near', + 'aurora', + 'dev-123.testnet', + 'b-o_w_e-n', + '98793cd91a3f870fb126f66285808c7e094afcfc4eda8a970f6648cdf0dbd6de', + '0x87b435f1fcb4519306f9b755e274107cc78ac4e3', + '0s87b435f1fcb4519306f9b755e274107cc78ac4e3', + ]; + + for (const accountId of validAccountIds) { + expect(isValidNearAccountId(accountId)).toBe(true); + } + }); + + it('should reject invalid NEAR account IDs', () => { + const invalidAccountIds = [ + '', + 'a', + 'SourceScan.near', + '-near', + 'near-', + '.near', + 'near.', + 'a..near', + '0__0', + '0_-_0', + 'hello world', + 'near;touch', + 'abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz', + ]; + + for (const accountId of invalidAccountIds) { + expect(isValidNearAccountId(accountId)).toBe(false); + } + }); + + it('should validate full commit SHAs only', () => { + expect(isValidCommitSha('0123456789abcdef0123456789abcdef01234567')).toBe( + true, + ); + expect(isValidCommitSha('a80bc29')).toBe(false); + expect(isValidCommitSha('main')).toBe(false); + }); + + it('should validate safe git refs', () => { + for (const ref of ['main', 'v1.2.3', 'feature/repro-build', 'a80bc29']) { + expect(isValidGitRef(ref)).toBe(true); + } + + for (const ref of ['--help', 'main;touch', 'foo..bar', 'foo@{bar}']) { + expect(isValidGitRef(ref)).toBe(false); + } + }); +}); diff --git a/nest/src/constants/validation.constants.ts b/nest/src/constants/validation.constants.ts new file mode 100644 index 0000000..7388b0a --- /dev/null +++ b/nest/src/constants/validation.constants.ts @@ -0,0 +1,28 @@ +export const NETWORK_IDS = ['mainnet', 'testnet'] as const; + +export type NetworkId = (typeof NETWORK_IDS)[number]; + +export const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; +export const GIT_REF_PATTERN = + /^(?!-)(?!.*(?:\.\.|@\{|\/\/|\\))[A-Za-z0-9._/@+-]{1,256}$/; + +const NAMED_ACCOUNT_PATTERN = + /^(?=.{2,64}$)(?:(?:[a-z0-9]+[-_])*[a-z0-9]+\.)*(?:[a-z0-9]+[-_])*[a-z0-9]+$/; + +export const NEAR_ACCOUNT_ID_PATTERN = NAMED_ACCOUNT_PATTERN; + +export function isValidNetworkId(networkId: string): networkId is NetworkId { + return NETWORK_IDS.includes(networkId as NetworkId); +} + +export function isValidCommitSha(sha: string): boolean { + return COMMIT_SHA_PATTERN.test(sha); +} + +export function isValidGitRef(ref: string): boolean { + return GIT_REF_PATTERN.test(ref); +} + +export function isValidNearAccountId(accountId: string): boolean { + return NEAR_ACCOUNT_ID_PATTERN.test(accountId); +} diff --git a/nest/src/controllers/verify/verify.controller.ts b/nest/src/controllers/verify/verify.controller.ts index cd7f5eb..5774235 100644 --- a/nest/src/controllers/verify/verify.controller.ts +++ b/nest/src/controllers/verify/verify.controller.ts @@ -112,8 +112,8 @@ export class VerifyController { buildInfo = contractMetadata.build_info; - // Extract the repository URL and commit hash - const { repoUrl, sha } = this.githubService.parseSourceCodeSnapshot( + // Extract the repository URL and pinned git ref + const { repoUrl, ref } = this.githubService.parseSourceCodeSnapshot( buildInfo.source_code_snapshot, ); @@ -122,7 +122,7 @@ export class VerifyController { try { await this.githubService.clone(tempFolder, repoUrl); const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl); - await this.githubService.checkout(repoPath, sha); + await this.githubService.checkout(repoPath, ref); } catch (error) { if ( error.message.includes('Authentication failed') || @@ -131,18 +131,15 @@ export class VerifyController { error.message.includes('remote: Repository not found') || error.message.includes('fatal: repository') ) { - await this.tempService.deleteFolder(tempFolder); return res.status(400).json({ message: `Repository is not publicly accessible: ${repoUrl}. Contract verification requires public repositories.`, }); } - await this.tempService.deleteFolder(tempFolder); throw error; // Re-throw other errors + } finally { + await this.cleanupTempFolder(tempFolder); } - // Clean up successful clone - await this.tempService.deleteFolder(tempFolder); - // Check Docker image availability if using Docker build environment if ( buildInfo.build_environment && @@ -163,7 +160,7 @@ export class VerifyController { ); } } catch (error) { - if (error.status === 400) { + if (error instanceof HttpException && error.getStatus() === 400) { throw error; // Re-throw validation errors } this.logger.error(`Pre-verification checks failed: ${error.message}`); @@ -227,27 +224,30 @@ export class VerifyController { // Contract metadata and buildInfo already fetched in pre-verification checks - // Extract the repository URL and the commit hash - const { repoUrl, sha } = this.githubService.parseSourceCodeSnapshot( + // Extract the repository URL and pinned git ref + const { repoUrl, ref } = this.githubService.parseSourceCodeSnapshot( buildInfo.source_code_snapshot, ); - // Create a temporary folder to clone the repository for IPFS - const tempFolder = await this.tempService.createFolder(); - - // Clone the repository and checkout the commit - await this.githubService.clone(tempFolder, repoUrl); - const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl); - await this.githubService.checkout(repoPath, sha); - - // Pin to IPFS - this.logger.log(`Adding repository to IPFS for ${accountId}`); + let tempFolder: string = null; let cid = ''; - cid = await this.ipfsService.addFolder(repoPath); - this.logger.log(`IPFS CID for ${accountId}: ${cid}`); - // Clean up temp folder - await this.tempService.deleteFolder(tempFolder); + try { + // Create a temporary folder to clone the repository for IPFS + tempFolder = await this.tempService.createFolder(); + + // Clone the repository and checkout the pinned git ref + await this.githubService.clone(tempFolder, repoUrl); + const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl); + await this.githubService.checkout(repoPath, ref); + + // Pin to IPFS + this.logger.log(`Adding repository to IPFS for ${accountId}`); + cid = await this.ipfsService.addFolder(repoPath); + this.logger.log(`IPFS CID for ${accountId}: ${cid}`); + } finally { + await this.cleanupTempFolder(tempFolder); + } // Store verification result this.logger.log( @@ -271,4 +271,18 @@ export class VerifyController { return res.status(500).json({ message: error.message }); } } + + private async cleanupTempFolder(tempFolder: string | null): Promise { + if (!tempFolder) { + return; + } + + try { + await this.tempService.deleteFolder(tempFolder); + } catch (cleanupError) { + this.logger.error( + `Failed to clean up ${tempFolder}: ${cleanupError.message}`, + ); + } + } } diff --git a/nest/src/dtos/verify.dto.ts b/nest/src/dtos/verify.dto.ts index 72d765b..3ac63ec 100644 --- a/nest/src/dtos/verify.dto.ts +++ b/nest/src/dtos/verify.dto.ts @@ -1,14 +1,30 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + Min, +} from 'class-validator'; +import { + NEAR_ACCOUNT_ID_PATTERN, + NETWORK_IDS, + NetworkId, +} from '../constants/validation.constants'; export class VerifyRustDto { @ApiProperty({ description: 'Network ID', example: 'mainnet', + enum: NETWORK_IDS, }) @IsNotEmpty() @IsString() - networkId: string; + @IsIn(NETWORK_IDS) + networkId: NetworkId; @ApiProperty({ description: 'Account ID', @@ -16,6 +32,9 @@ export class VerifyRustDto { }) @IsNotEmpty() @IsString() + @Matches(NEAR_ACCOUNT_ID_PATTERN, { + message: 'accountId must be a valid NEAR account ID', + }) accountId: string; @ApiProperty({ @@ -23,7 +42,9 @@ export class VerifyRustDto { example: 165753012, }) @IsOptional() - @IsNumber() + @Type(() => Number) + @IsInt() + @Min(0) blockId: number; } diff --git a/nest/src/services/compiler/compiler.service.spec.ts b/nest/src/services/compiler/compiler.service.spec.ts index ae8682d..13d578b 100644 --- a/nest/src/services/compiler/compiler.service.spec.ts +++ b/nest/src/services/compiler/compiler.service.spec.ts @@ -13,7 +13,7 @@ describe('CompilerService', () => { { provide: ExecService, useValue: { - executeCommand: jest.fn(), + executeFile: jest.fn(), }, }, ], @@ -26,22 +26,35 @@ describe('CompilerService', () => { it('should verify contract successfully', async () => { const mockOutput = ['Contract verified']; jest - .spyOn(execService, 'executeCommand') + .spyOn(execService, 'executeFile') .mockResolvedValue({ stderr: [], stdout: mockOutput }); const result = await compilerService.verifyContract('test.near', 'mainnet'); expect(result.stdout).toEqual(mockOutput); - expect(execService.executeCommand).toHaveBeenCalledWith( - 'near contract verify deployed-at test.near network-config mainnet now', - ); + expect(execService.executeFile).toHaveBeenCalledWith('near', [ + 'contract', + 'verify', + 'deployed-at', + 'test.near', + 'network-config', + 'mainnet', + 'now', + ]); }); it('should handle errors in contract verification', async () => { const error = new Error('Verification error'); - jest.spyOn(execService, 'executeCommand').mockRejectedValue(error); + jest.spyOn(execService, 'executeFile').mockRejectedValue(error); await expect( compilerService.verifyContract('test.near', 'mainnet'), ).rejects.toThrow(error); }); + + it('should reject unsafe account IDs before executing', async () => { + await expect( + compilerService.verifyContract('test.near; touch /tmp/pwned', 'mainnet'), + ).rejects.toThrow('Invalid NEAR account ID'); + expect(execService.executeFile).not.toHaveBeenCalled(); + }); }); diff --git a/nest/src/services/compiler/compiler.service.ts b/nest/src/services/compiler/compiler.service.ts index 454511b..cb62b19 100644 --- a/nest/src/services/compiler/compiler.service.ts +++ b/nest/src/services/compiler/compiler.service.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + isValidNearAccountId, + isValidNetworkId, +} from '../../constants/validation.constants'; import { ExecService } from '../exec/exec.service'; @Injectable() @@ -14,10 +18,27 @@ export class CompilerService { accountId: string, networkId: string, ): Promise<{ stdout: string[] }> { - const command = `near contract verify deployed-at ${accountId} network-config ${networkId} now`; - this.logger.log(`Starting contract verification: ${command}`); + if (!isValidNearAccountId(accountId)) { + throw new BadRequestException('Invalid NEAR account ID'); + } + + if (!isValidNetworkId(networkId)) { + throw new BadRequestException('Invalid network ID'); + } + + this.logger.log( + `Starting contract verification for ${accountId} on ${networkId}`, + ); try { - const { stdout } = await this.execService.executeCommand(command); + const { stdout } = await this.execService.executeFile('near', [ + 'contract', + 'verify', + 'deployed-at', + accountId, + 'network-config', + networkId, + 'now', + ]); this.logger.log(`Contract verification completed`); return { stdout }; } catch (error) { diff --git a/nest/src/services/exec/exec.service.spec.ts b/nest/src/services/exec/exec.service.spec.ts index 4910e30..115ed14 100644 --- a/nest/src/services/exec/exec.service.spec.ts +++ b/nest/src/services/exec/exec.service.spec.ts @@ -5,6 +5,7 @@ import { ExecService } from './exec.service'; jest.mock('child_process', () => ({ exec: jest.fn(), + execFile: jest.fn(), })); describe('ExecService', () => { @@ -24,8 +25,8 @@ describe('ExecService', () => { it('should execute command successfully', async () => { const mockExec = cp.exec as unknown as jest.Mock; - mockExec.mockImplementation((cmd, callback) => - callback(null, { stdout: 'success', stderr: '' }), + mockExec.mockImplementation((cmd, options, callback) => + callback(null, 'success', ''), ); await expect(service.executeCommand('echo "Hello World"')).resolves.toEqual( @@ -38,8 +39,8 @@ describe('ExecService', () => { it('should throw ExecException when stderr contains errors', async () => { const mockExec = cp.exec as unknown as jest.Mock; - mockExec.mockImplementation((cmd, callback) => - callback(null, { stdout: '', stderr: 'error: something went wrong' }), + mockExec.mockImplementation((cmd, options, callback) => + callback(null, '', 'error: something went wrong'), ); await expect(service.executeCommand('someCommand')).rejects.toThrow( @@ -49,12 +50,31 @@ describe('ExecService', () => { it('should throw ExecException on command execution failure', async () => { const mockExec = cp.exec as unknown as jest.Mock; - mockExec.mockImplementation((cmd, callback) => - callback(new Error('Execution failed'), { stdout: '', stderr: '' }), + mockExec.mockImplementation((cmd, options, callback) => + callback(new Error('Execution failed'), '', ''), ); await expect(service.executeCommand('faultyCommand')).rejects.toThrow( ExecException, ); }); + + it('should execute a file without a shell', async () => { + const mockExecFile = cp.execFile as unknown as jest.Mock; + mockExecFile.mockImplementation((file, args, options, callback) => + callback(null, 'success', ''), + ); + + await expect(service.executeFile('git', ['status'])).resolves.toEqual({ + stdout: ['success'], + stderr: [], + }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['status'], + expect.objectContaining({ shell: false }), + expect.any(Function), + ); + }); }); diff --git a/nest/src/services/exec/exec.service.ts b/nest/src/services/exec/exec.service.ts index 1f67307..5a419c0 100644 --- a/nest/src/services/exec/exec.service.ts +++ b/nest/src/services/exec/exec.service.ts @@ -1,19 +1,27 @@ import { Injectable, Logger } from '@nestjs/common'; import * as cp from 'child_process'; -import { promisify } from 'util'; import { ExecException } from '../../exceptions/exec.exception'; -const execAsync = promisify(cp.exec); +const DEFAULT_COMMAND_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_MAX_BUFFER_BYTES = 20 * 1024 * 1024; @Injectable() export class ExecService { private readonly logger = new Logger(ExecService.name); + private readonly timeoutMs = this.readPositiveInteger( + process.env.EXEC_TIMEOUT_MS, + DEFAULT_COMMAND_TIMEOUT_MS, + ); + private readonly maxBufferBytes = this.readPositiveInteger( + process.env.EXEC_MAX_BUFFER_BYTES, + DEFAULT_MAX_BUFFER_BYTES, + ); async executeCommand( command: string, ): Promise<{ stdout: string[]; stderr: string[] }> { try { - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await this.runShellCommand(command); const { stdout: parsedStdout, stderr: parsedStderr } = this.parseLogs( stdout, @@ -31,13 +39,125 @@ export class ExecService { return { stdout: parsedStdout, stderr: parsedStderr }; } catch (error: any) { - throw new ExecException( + throw this.toExecException(command, error); + } + } + + async executeFile( + file: string, + args: string[], + options: cp.ExecFileOptions = {}, + ): Promise<{ stdout: string[]; stderr: string[] }> { + const command = this.formatCommand(file, args); + + try { + const { stdout, stderr } = await this.runFileCommand(file, args, options); + + const { stdout: parsedStdout, stderr: parsedStderr } = this.parseLogs( + stdout, + stderr, + ); + + if (parsedStderr.length > 0) { + throw new ExecException( + command, + `Error executing command: ${parsedStderr}`, + parsedStdout, + parsedStderr, + ); + } + + return { stdout: parsedStdout, stderr: parsedStderr }; + } catch (error: any) { + throw this.toExecException(command, error); + } + } + + private runShellCommand( + command: string, + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + cp.exec( command, - error.message, - error.stdout, - error.stderr, + { + timeout: this.timeoutMs, + maxBuffer: this.maxBufferBytes, + }, + (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }); + reject(error); + return; + } + + resolve({ + stdout: stdout?.toString() ?? '', + stderr: stderr?.toString() ?? '', + }); + }, ); + }); + } + + private runFileCommand( + file: string, + args: string[], + options: cp.ExecFileOptions, + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + cp.execFile( + file, + args, + { + timeout: this.timeoutMs, + maxBuffer: this.maxBufferBytes, + ...options, + shell: false, + }, + (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }); + reject(error); + return; + } + + resolve({ + stdout: stdout?.toString() ?? '', + stderr: stderr?.toString() ?? '', + }); + }, + ); + }); + } + + private toExecException(command: string, error: any): ExecException { + if (error instanceof ExecException) { + return error; + } + + return new ExecException( + command, + error.message, + this.normalizeOutput(error.stdout), + this.normalizeOutput(error.stderr), + ); + } + + private normalizeOutput(output?: string | string[]): string[] { + if (Array.isArray(output)) { + return output; } + + return output ? output.split('\n') : []; + } + + private formatCommand(file: string, args: string[]): string { + return [file, ...args].join(' '); + } + + private readPositiveInteger(value: string, fallback: number): number { + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } private parseLogs( diff --git a/nest/src/services/github/github.service.spec.ts b/nest/src/services/github/github.service.spec.ts index c2b561f..d0bd477 100644 --- a/nest/src/services/github/github.service.spec.ts +++ b/nest/src/services/github/github.service.spec.ts @@ -13,7 +13,7 @@ describe('GithubService', () => { { provide: ExecService, useValue: { - executeCommand: jest + executeFile: jest .fn() .mockResolvedValue({ stdout: [], stderr: [] }), }, @@ -30,28 +30,109 @@ describe('GithubService', () => { }); it('should successfully clone a repository', async () => { - await expect(service.clone('sourcePath', 'repo')).resolves.not.toThrow(); - expect(execService.executeCommand).toHaveBeenCalledWith( - 'sh /app/scripts/github/clone.sh sourcePath repo', + await expect( + service.clone('sourcePath', 'https://github.com/user/repo'), + ).resolves.not.toThrow(); + expect(execService.executeFile).toHaveBeenCalledWith( + 'git', + ['clone', '--', 'https://github.com/user/repo'], + { cwd: 'sourcePath' }, ); }); it('should handle errors in clone method', async () => { const error = new Error('Clone failed'); - jest.spyOn(execService, 'executeCommand').mockRejectedValueOnce(error); - await expect(service.clone('sourcePath', 'repo')).rejects.toThrow(error); + jest.spyOn(execService, 'executeFile').mockRejectedValueOnce(error); + await expect( + service.clone('sourcePath', 'https://github.com/user/repo'), + ).rejects.toThrow(error); }); it('should parse source code snapshot', () => { + const ref = '9c16aaff3c0fe5bda4d8ffb418c4bb2b535eb420'; + const result = service.parseSourceCodeSnapshot( + `git+https://github.com/near/cargo-near-new-project-template?rev=${ref}`, + ); + expect(result).toEqual({ + repoUrl: 'https://github.com/near/cargo-near-new-project-template', + ref, + }); + }); + + it('should parse NEP-330 source code snapshot with fragment SHA', () => { + const ref = '9c16aaff3c0fe5bda4d8ffb418c4bb2b535eb420'; + const result = service.parseSourceCodeSnapshot( + `git+https://github.com/near/cargo-near-new-project-template.git#${ref}`, + ); + expect(result).toEqual({ + repoUrl: 'https://github.com/near/cargo-near-new-project-template.git', + ref, + }); + }); + + it('should parse non-GitHub and symbolic git refs', () => { + const result = service.parseSourceCodeSnapshot( + 'git+https://gitlab.com/user/repo.git?rev=v1.2.3', + ); + + expect(result).toEqual({ + repoUrl: 'https://gitlab.com/user/repo.git', + ref: 'v1.2.3', + }); + }); + + it('should parse SSH source snapshots', () => { const result = service.parseSourceCodeSnapshot( - 'git+https://github.com/user/repo?rev=abc123', + 'git+ssh://git@gitlab.com/user/repo.git?rev=feature/repro-build', ); + expect(result).toEqual({ - repoUrl: 'https://github.com/user/repo', - sha: 'abc123', + repoUrl: 'ssh://git@gitlab.com/user/repo.git', + ref: 'feature/repro-build', }); }); + it('should parse SCP-like SSH source snapshots', () => { + const result = service.parseSourceCodeSnapshot( + 'git+git@gitlab.com:user/repo.git?rev=a80bc29', + ); + + expect(result).toEqual({ + repoUrl: 'git@gitlab.com:user/repo.git', + ref: 'a80bc29', + }); + }); + + it('should reject source snapshots with conflicting refs', () => { + expect(() => + service.parseSourceCodeSnapshot( + 'git+https://github.com/user/repo?rev=0123456789abcdef0123456789abcdef01234567#89abcdef0123456789abcdef0123456789abcdef', + ), + ).toThrow('Source snapshot must not contain conflicting git refs'); + }); + + it('should reject unsafe refs', () => { + expect(() => + service.parseSourceCodeSnapshot( + 'git+https://github.com/user/repo?rev=main;touch', + ), + ).toThrow('Source snapshot must pin a safe git ref'); + }); + + it('should reject local filesystem source snapshots', () => { + expect(() => + service.parseSourceCodeSnapshot('git+file:///tmp/repo?rev=main'), + ).toThrow('Repository URL must use HTTPS or SSH'); + }); + + it('should reject unauthenticated git protocol source snapshots', () => { + expect(() => + service.parseSourceCodeSnapshot( + 'git+git://github.com/user/repo?rev=main', + ), + ).toThrow('Repository URL must use HTTPS or SSH'); + }); + it('should get repo path', () => { const result = service.getRepoPath( '/tmp/folder', @@ -61,11 +142,23 @@ describe('GithubService', () => { }); it('should successfully checkout a commit', async () => { - await expect( - service.checkout('/tmp/repo', 'abc123'), - ).resolves.not.toThrow(); - expect(execService.executeCommand).toHaveBeenCalledWith( - 'sh /app/scripts/github/checkout.sh /tmp/repo abc123', + const sha = '0123456789abcdef0123456789abcdef01234567'; + await expect(service.checkout('/tmp/repo', sha)).resolves.not.toThrow(); + expect(execService.executeFile).toHaveBeenCalledWith( + 'git', + ['-c', 'advice.detachedHead=false', 'checkout', '--detach', sha], + { cwd: '/tmp/repo' }, + ); + }); + + it('should reject option-like refs before checkout', async () => { + await expect(service.checkout('/tmp/repo', '--help')).rejects.toThrow( + 'Git ref contains unsafe characters', + ); + expect(execService.executeFile).not.toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['--help']), + expect.anything(), ); }); }); diff --git a/nest/src/services/github/github.service.ts b/nest/src/services/github/github.service.ts index f8b1072..b8a5101 100644 --- a/nest/src/services/github/github.service.ts +++ b/nest/src/services/github/github.service.ts @@ -1,18 +1,24 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import * as path from 'path'; +import { isValidGitRef } from '../../constants/validation.constants'; import { ExecService } from '../exec/exec.service'; +const SCP_LIKE_GIT_URL_PATTERN = + /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+(?:\.git)?$/; + @Injectable() export class GithubService { private readonly logger = new Logger(GithubService.name); - private readonly scriptsPath = '/app/scripts/github'; constructor(private execService: ExecService) {} async clone(sourcePath: string, repo: string): Promise { - const command = `sh ${this.scriptsPath}/clone.sh ${sourcePath} ${repo}`; - this.logger.log(`Starting clone command: ${command}`); + const repoUrl = this.validateRepoUrl(repo); + this.logger.log(`Starting clone command for ${repoUrl}`); try { - await this.execService.executeCommand(command); + await this.execService.executeFile('git', ['clone', '--', repoUrl], { + cwd: sourcePath, + }); this.logger.log(`Repository cloned successfully.`); } catch (error) { this.logger.error(`Error in clone: ${error.message}`); @@ -22,28 +28,164 @@ export class GithubService { parseSourceCodeSnapshot(sourceCodeSnapshot: string): { repoUrl: string; - sha: string; + ref: string; } { - // Remove the 'git+' prefix - const repoInfo = sourceCodeSnapshot.replace('git+', ''); - // Extract the repository URL and the commit hash - const [repoUrl, sha] = repoInfo.split('?rev='); - return { repoUrl, sha }; + if ( + typeof sourceCodeSnapshot !== 'string' || + !sourceCodeSnapshot.startsWith('git+') + ) { + throw new BadRequestException('Source snapshot must use git+ URL format'); + } + + const { repoUrl: rawRepoUrl, ref } = this.parseGitSnapshot( + sourceCodeSnapshot.slice('git+'.length), + ); + + if (!ref || !isValidGitRef(ref)) { + throw new BadRequestException('Source snapshot must pin a safe git ref'); + } + + const repoUrl = this.validateRepoUrl(rawRepoUrl); + return { repoUrl, ref }; } getRepoPath(tempFolder: string, repoUrl: string): string { - return `${tempFolder}/${repoUrl.split('/').pop().replace('.git', '')}`; + const normalizedRepoUrl = this.validateRepoUrl(repoUrl); + const repoPath = this.getRepoUrlPath(normalizedRepoUrl); + const repoName = path.basename(repoPath).replace(/\.git$/, ''); + return path.join(tempFolder, repoName); } - async checkout(repoPath: string, sha: string): Promise { - const command = `sh ${this.scriptsPath}/checkout.sh ${repoPath} ${sha}`; - this.logger.log(`Starting checkout command: ${command}`); + async checkout(repoPath: string, ref: string): Promise { + if (!isValidGitRef(ref)) { + throw new BadRequestException('Git ref contains unsafe characters'); + } + + this.logger.log(`Starting checkout command for ${ref}`); try { - await this.execService.executeCommand(command); + await this.execService.executeFile( + 'git', + ['-c', 'advice.detachedHead=false', 'checkout', '--detach', ref], + { cwd: repoPath }, + ); this.logger.log(`Checkout completed successfully.`); } catch (error) { this.logger.error(`Error in checkout: ${error.message}`); throw error; } } + + private parseGitSnapshot(snapshot: string): { + repoUrl: string; + ref: string | null; + } { + try { + const snapshotUrl = new URL(snapshot); + const revRef = snapshotUrl.searchParams.get('rev'); + const hashRef = snapshotUrl.hash + ? decodeURIComponent(snapshotUrl.hash.slice(1)) + : null; + + if (revRef && hashRef && revRef !== hashRef) { + throw new BadRequestException( + 'Source snapshot must not contain conflicting git refs', + ); + } + + snapshotUrl.search = ''; + snapshotUrl.hash = ''; + + return { repoUrl: snapshotUrl.toString(), ref: revRef ?? hashRef }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + } + + return this.parseScpLikeGitSnapshot(snapshot); + } + + private parseScpLikeGitSnapshot(snapshot: string): { + repoUrl: string; + ref: string; + } { + const revMarker = '?rev='; + const revIndex = snapshot.indexOf(revMarker); + const hashIndex = snapshot.indexOf('#'); + + if (revIndex === -1 && hashIndex === -1) { + throw new BadRequestException('Source snapshot must pin a git ref'); + } + + if (revIndex !== -1) { + const repoUrl = snapshot.slice(0, revIndex); + const refWithPossibleHash = snapshot.slice(revIndex + revMarker.length); + const [revRef, hashRef] = refWithPossibleHash.split('#'); + + if (hashRef && revRef !== hashRef) { + throw new BadRequestException( + 'Source snapshot must not contain conflicting git refs', + ); + } + + return { repoUrl, ref: revRef }; + } + + return { + repoUrl: snapshot.slice(0, hashIndex), + ref: snapshot.slice(hashIndex + 1), + }; + } + + private validateRepoUrl(repoUrl: string): string { + if (SCP_LIKE_GIT_URL_PATTERN.test(repoUrl)) { + return repoUrl; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(repoUrl); + } catch { + throw new BadRequestException('Repository URL must be a valid URL'); + } + + if (!['https:', 'ssh:'].includes(parsedUrl.protocol)) { + throw new BadRequestException('Repository URL must use HTTPS or SSH'); + } + + if ( + parsedUrl.password || + (parsedUrl.protocol === 'https:' && parsedUrl.username) + ) { + throw new BadRequestException('Repository URL must not contain secrets'); + } + + const normalizedPath = parsedUrl.pathname.replace(/\/$/, ''); + if (!/^\/[A-Za-z0-9._~/-]+(?:\.git)?$/.test(normalizedPath)) { + throw new BadRequestException( + 'Repository URL must point to a git repository', + ); + } + const repoName = path.posix.basename(normalizedPath).replace(/\.git$/, ''); + if (!repoName || repoName === '.' || repoName === '..') { + throw new BadRequestException( + 'Repository URL must point to a git repository', + ); + } + + parsedUrl.pathname = normalizedPath; + parsedUrl.search = ''; + parsedUrl.hash = ''; + + return parsedUrl.toString(); + } + + private getRepoUrlPath(repoUrl: string): string { + if (SCP_LIKE_GIT_URL_PATTERN.test(repoUrl)) { + return repoUrl.slice(repoUrl.indexOf(':') + 1); + } + + return new URL(repoUrl).pathname; + } }