security: address remaining code scanning alerts (#253)
This commit is contained in:
@@ -120,19 +120,18 @@ function applyFastFlags(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env
|
||||
}
|
||||
|
||||
function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void {
|
||||
function printSummary(profile: ProviderProfile): void {
|
||||
console.log(`Launching profile: ${profile}`)
|
||||
if (profile === 'gemini') {
|
||||
console.log(`GEMINI_MODEL=${env.GEMINI_MODEL}`)
|
||||
console.log('Using configured Gemini provider settings.')
|
||||
} else if (profile === 'codex') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured Codex/OpenAI-compatible provider settings.')
|
||||
} else if (profile === 'atomic-chat') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured Atomic Chat provider settings.')
|
||||
} else if (profile === 'ollama') {
|
||||
console.log('Using configured Ollama provider settings.')
|
||||
} else {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('Using configured OpenAI-compatible provider settings.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +226,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(profile, env)
|
||||
printSummary(profile)
|
||||
|
||||
const doctorCode = await runProcess('bun', ['run', 'scripts/system-check.ts'], env)
|
||||
if (doctorCode !== 0) {
|
||||
|
||||
@@ -325,7 +325,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('oauth client configured');
|
||||
}
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
if (server.oauth.callbackPort) parts.push('callback port configured');
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('oauth client configured');
|
||||
}
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
if (server.oauth.callbackPort) parts.push('callback port configured');
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -282,17 +282,16 @@ function InstallGitHubApp(props: {
|
||||
return;
|
||||
}
|
||||
const repoWarnings: Warning[] = [];
|
||||
if (repoName_1.includes('github.com')) {
|
||||
const slug = extractGitHubRepoSlug(repoName_1);
|
||||
if (!slug) {
|
||||
repoWarnings.push({
|
||||
title: 'Invalid GitHub URL format',
|
||||
message: 'The repository URL format appears to be invalid.',
|
||||
instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli']
|
||||
});
|
||||
} else {
|
||||
repoName_1 = slug;
|
||||
}
|
||||
const slug = extractGitHubRepoSlug(repoName_1);
|
||||
const isUrlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(repoName_1) || repoName_1.startsWith('www.');
|
||||
if (slug) {
|
||||
repoName_1 = slug;
|
||||
} else if (isUrlLike) {
|
||||
repoWarnings.push({
|
||||
title: 'Invalid GitHub URL format',
|
||||
message: 'The repository URL format appears to be invalid.',
|
||||
instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli']
|
||||
});
|
||||
}
|
||||
if (!repoName_1.includes('/')) {
|
||||
repoWarnings.push({
|
||||
|
||||
@@ -33,4 +33,16 @@ test('rejects malformed or non-GitHub URLs', () => {
|
||||
assert.equal(extractGitHubRepoSlug('https://gitlab.com/Gitlawb/openclaude'), null)
|
||||
assert.equal(extractGitHubRepoSlug('https://github.com/Gitlawb'), null)
|
||||
assert.equal(extractGitHubRepoSlug('not actually github.com/Gitlawb/openclaude'), null)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://evil.example/?next=github.com/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://github.com.evil.example/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://example.com/github.com/Gitlawb/openclaude'),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
export function extractGitHubRepoSlug(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.includes('github.com')) {
|
||||
return null
|
||||
const slugMatch = trimmed.match(
|
||||
/^(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i,
|
||||
)
|
||||
if (slugMatch?.groups?.owner && slugMatch.groups.repo) {
|
||||
return `${slugMatch.groups.owner}/${slugMatch.groups.repo}`.replace(
|
||||
/\.git$/i,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
if (!trimmed.includes('github.com')) {
|
||||
return trimmed
|
||||
const shorthandUrlMatch = trimmed.match(
|
||||
/^(?:https?:\/\/)?(?:www\.)?github\.com\/(?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i,
|
||||
)
|
||||
if (shorthandUrlMatch?.groups?.owner && shorthandUrlMatch.groups.repo) {
|
||||
return `${shorthandUrlMatch.groups.owner}/${shorthandUrlMatch.groups.repo}`.replace(
|
||||
/\.git$/i,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
const sshMatch = trimmed.match(
|
||||
@@ -16,6 +28,10 @@ export function extractGitHubRepoSlug(value: string): string | null {
|
||||
return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
27
src/services/oauth/crypto.test.ts
Normal file
27
src/services/oauth/crypto.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from './crypto.ts'
|
||||
|
||||
test('generateCodeChallenge returns the RFC 7636 S256 challenge', async () => {
|
||||
const challenge = await generateCodeChallenge(
|
||||
'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
|
||||
)
|
||||
assert.equal(challenge, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')
|
||||
})
|
||||
|
||||
test('generateCodeVerifier returns a URL-safe random string', () => {
|
||||
const verifier = generateCodeVerifier()
|
||||
assert.match(verifier, /^[A-Za-z0-9_-]+$/)
|
||||
assert.ok(verifier.length >= 43)
|
||||
})
|
||||
|
||||
test('generateState returns a URL-safe random string', () => {
|
||||
const state = generateState()
|
||||
assert.match(state, /^[A-Za-z0-9_-]+$/)
|
||||
assert.ok(state.length >= 43)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { randomBytes, webcrypto } from 'crypto'
|
||||
|
||||
function base64URLEncode(buffer: Buffer): string {
|
||||
return buffer
|
||||
@@ -12,10 +12,10 @@ export function generateCodeVerifier(): string {
|
||||
return base64URLEncode(randomBytes(32))
|
||||
}
|
||||
|
||||
export function generateCodeChallenge(verifier: string): string {
|
||||
const hash = createHash('sha256')
|
||||
hash.update(verifier)
|
||||
return base64URLEncode(hash.digest())
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(verifier)
|
||||
const digest = await webcrypto.subtle.digest('SHA-256', encoded)
|
||||
return base64URLEncode(Buffer.from(digest))
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
|
||||
@@ -52,7 +52,7 @@ export class OAuthService {
|
||||
this.port = await this.authCodeListener.start()
|
||||
|
||||
// Generate PKCE values and state
|
||||
const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier)
|
||||
const codeChallenge = await crypto.generateCodeChallenge(this.codeVerifier)
|
||||
const state = crypto.generateState()
|
||||
|
||||
// Build auth URLs for both automatic and manual flows
|
||||
|
||||
@@ -50,7 +50,6 @@ type ExecFileWithCwdOptions = {
|
||||
maxBuffer?: number
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
shell?: boolean | string | undefined
|
||||
stdin?: 'ignore' | 'inherit' | 'pipe'
|
||||
input?: string
|
||||
}
|
||||
@@ -96,7 +95,6 @@ export function execFileNoThrowWithCwd(
|
||||
cwd: finalCwd,
|
||||
env: finalEnv,
|
||||
maxBuffer,
|
||||
shell,
|
||||
stdin: finalStdin,
|
||||
input: finalInput,
|
||||
}: ExecFileWithCwdOptions = {
|
||||
@@ -113,7 +111,7 @@ export function execFileNoThrowWithCwd(
|
||||
timeout: finalTimeout,
|
||||
cwd: finalCwd,
|
||||
env: finalEnv,
|
||||
shell,
|
||||
shell: false,
|
||||
stdin: finalStdin,
|
||||
input: finalInput,
|
||||
reject: false, // Don't throw on non-zero exit codes
|
||||
|
||||
@@ -159,7 +159,7 @@ export function logError(error: unknown): void {
|
||||
const err = toError(error)
|
||||
if (feature('HARD_FAIL') && isHardFailMode()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional crash output
|
||||
console.error('[HARD FAIL] logError called:', err.name || 'Error')
|
||||
console.error('[HARD FAIL] logError called')
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user