fix: address code scanning alerts (#240)
This commit is contained in:
3
.github/workflows/pr-checks.yml
vendored
3
.github/workflows/pr-checks.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke-and-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -199,15 +199,19 @@ export async function submitTranscriptShare() { return { success: false }; }
|
||||
`,
|
||||
}
|
||||
|
||||
function escapeForResolvedPathRegex(modulePath: string): string {
|
||||
return modulePath
|
||||
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
|
||||
.replace(/\//g, '[/\\\\]')
|
||||
}
|
||||
|
||||
export const noTelemetryPlugin: BunPlugin = {
|
||||
name: 'no-telemetry',
|
||||
setup(build) {
|
||||
for (const [modulePath, contents] of Object.entries(stubs)) {
|
||||
// Build regex that matches the resolved file path on any OS
|
||||
// e.g. "services/analytics/growthbook" → /services[/\\]analytics[/\\]growthbook\.(ts|js)$/
|
||||
const escaped = modulePath
|
||||
.replace(/\//g, '[/\\\\]')
|
||||
.replace(/\./g, '\\.')
|
||||
const escaped = escapeForResolvedPathRegex(modulePath)
|
||||
const filter = new RegExp(`${escaped}\\.(ts|js)$`)
|
||||
|
||||
build.onLoad({ filter }, () => ({
|
||||
|
||||
@@ -124,19 +124,15 @@ function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void {
|
||||
console.log(`Launching profile: ${profile}`)
|
||||
if (profile === 'gemini') {
|
||||
console.log(`GEMINI_MODEL=${env.GEMINI_MODEL}`)
|
||||
console.log(`GEMINI_API_KEY_SET=${Boolean(env.GEMINI_API_KEY)}`)
|
||||
} else if (profile === 'codex') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log(`CODEX_API_KEY_SET=${Boolean(resolveCodexApiCredentials(env).apiKey)}`)
|
||||
} else if (profile === 'atomic-chat') {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log('OPENAI_API_KEY_SET=false (local provider, no key required)')
|
||||
} else {
|
||||
console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`)
|
||||
console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`)
|
||||
console.log(`OPENAI_API_KEY_SET=${Boolean(env.OPENAI_API_KEY)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -430,6 +430,7 @@ function writeJsonReport(
|
||||
options: CliOptions,
|
||||
results: CheckResult[],
|
||||
): void {
|
||||
const envSummary = serializeSafeEnvSummary()
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
@@ -438,12 +439,24 @@ function writeJsonReport(
|
||||
passed: results.filter(result => result.ok).length,
|
||||
failed: results.filter(result => !result.ok).length,
|
||||
},
|
||||
env: serializeSafeEnvSummary(),
|
||||
env: envSummary,
|
||||
results,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(payload, null, 2))
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
timestamp: payload.timestamp,
|
||||
cwd: payload.cwd,
|
||||
summary: payload.summary,
|
||||
env: '[redacted in console JSON output; use --out-file for the full report]',
|
||||
results: payload.results,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (options.outFile) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { randomInt } from 'crypto'
|
||||
import type { AppState } from './state/AppState.js'
|
||||
import type { AgentId } from './types/ids.js'
|
||||
import { getTaskOutputPath } from './utils/task/diskOutput.js'
|
||||
@@ -97,10 +97,9 @@ const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
export function generateTaskId(type: TaskType): string {
|
||||
const prefix = getTaskIdPrefix(type)
|
||||
const bytes = randomBytes(8)
|
||||
let id = prefix
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
|
||||
id += TASK_ID_ALPHABET[randomInt(TASK_ID_ALPHABET.length)]!
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopI
|
||||
import { render } from '../../ink.js';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
||||
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
||||
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
||||
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
||||
@@ -323,9 +323,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
parts.push('oauth client configured');
|
||||
}
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
@@ -347,9 +345,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
parts.push('oauth client configured');
|
||||
}
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
|
||||
File diff suppressed because one or more lines are too long
36
src/commands/install-github-app/repoSlug.test.ts
Normal file
36
src/commands/install-github-app/repoSlug.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { extractGitHubRepoSlug } from './repoSlug.ts'
|
||||
|
||||
test('keeps owner/repo input as-is', () => {
|
||||
assert.equal(extractGitHubRepoSlug('Gitlawb/openclaude'), 'Gitlawb/openclaude')
|
||||
})
|
||||
|
||||
test('extracts slug from https GitHub URLs', () => {
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://github.com/Gitlawb/openclaude'),
|
||||
'Gitlawb/openclaude',
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('https://www.github.com/Gitlawb/openclaude.git'),
|
||||
'Gitlawb/openclaude',
|
||||
)
|
||||
})
|
||||
|
||||
test('extracts slug from ssh GitHub URLs', () => {
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('git@github.com:Gitlawb/openclaude.git'),
|
||||
'Gitlawb/openclaude',
|
||||
)
|
||||
assert.equal(
|
||||
extractGitHubRepoSlug('ssh://git@github.com/Gitlawb/openclaude'),
|
||||
'Gitlawb/openclaude',
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
38
src/commands/install-github-app/repoSlug.ts
Normal file
38
src/commands/install-github-app/repoSlug.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
}
|
||||
|
||||
if (!trimmed.includes('github.com')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const sshMatch = trimmed.match(
|
||||
/^(?:git@|ssh:\/\/git@)(?:www\.)?github\.com[:/](?<owner>[^/:\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?\/?$/i,
|
||||
)
|
||||
if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
|
||||
return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
if (hostname !== 'github.com' && hostname !== 'www.github.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = parsed.pathname
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
if (segments.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${segments[0]}/${segments[1]}`.replace(/\.git$/i, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { randomInt } from 'crypto'
|
||||
import {
|
||||
OUTPUT_FILE_TAG,
|
||||
STATUS_TAG,
|
||||
@@ -73,10 +73,9 @@ const DEFAULT_MAIN_SESSION_AGENT: CustomAgentDefinition = {
|
||||
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
function generateMainSessionTaskId(): string {
|
||||
const bytes = randomBytes(8)
|
||||
let id = 's'
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
|
||||
id += TASK_ID_ALPHABET[randomInt(TASK_ID_ALPHABET.length)]!
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
40
src/tools/BashTool/sedEditParser.test.ts
Normal file
40
src/tools/BashTool/sedEditParser.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { applySedSubstitution, type SedEditInfo } from './sedEditParser.js'
|
||||
|
||||
function sedInfo(pattern: string, replacement: string, extendedRegex = false): SedEditInfo {
|
||||
return {
|
||||
filePath: 'example.txt',
|
||||
pattern,
|
||||
replacement,
|
||||
flags: 'g',
|
||||
extendedRegex,
|
||||
}
|
||||
}
|
||||
|
||||
test('BRE mode keeps unescaped plus literal', () => {
|
||||
const result = applySedSubstitution(
|
||||
'a+b and aaab',
|
||||
sedInfo('a+b', 'literal-plus'),
|
||||
)
|
||||
|
||||
expect(result).toBe('literal-plus and aaab')
|
||||
})
|
||||
|
||||
test('BRE mode treats escaped plus as one-or-more', () => {
|
||||
const result = applySedSubstitution(
|
||||
'abbb and a+b',
|
||||
sedInfo('ab\\+', 'one-or-more'),
|
||||
)
|
||||
|
||||
expect(result).toBe('one-or-more and a+b')
|
||||
})
|
||||
|
||||
test('BRE mode preserves escaped backslashes', () => {
|
||||
const result = applySedSubstitution(
|
||||
String.raw`foo\bar foo/bar`,
|
||||
sedInfo(String.raw`foo\\bar`, 'backslash-match'),
|
||||
)
|
||||
|
||||
expect(result).toBe('backslash-match foo/bar')
|
||||
})
|
||||
@@ -7,18 +7,6 @@ import { randomBytes } from 'crypto'
|
||||
import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
|
||||
|
||||
// BRE→ERE conversion placeholders (null-byte sentinels, never appear in user input)
|
||||
const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00'
|
||||
const PLUS_PLACEHOLDER = '\x00PLUS\x00'
|
||||
const QUESTION_PLACEHOLDER = '\x00QUESTION\x00'
|
||||
const PIPE_PLACEHOLDER = '\x00PIPE\x00'
|
||||
const LPAREN_PLACEHOLDER = '\x00LPAREN\x00'
|
||||
const RPAREN_PLACEHOLDER = '\x00RPAREN\x00'
|
||||
const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g')
|
||||
const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g')
|
||||
const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g')
|
||||
const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g')
|
||||
const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g')
|
||||
const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g')
|
||||
|
||||
export type SedEditInfo = {
|
||||
/** The file path being edited */
|
||||
@@ -33,6 +21,40 @@ export type SedEditInfo = {
|
||||
extendedRegex: boolean
|
||||
}
|
||||
|
||||
function convertBrePatternToJs(pattern: string): string {
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const char = pattern[i]!
|
||||
|
||||
if (char === '\\') {
|
||||
const next = pattern[i + 1]
|
||||
if (next === undefined) {
|
||||
result += '\\\\'
|
||||
continue
|
||||
}
|
||||
if (next === '\\') {
|
||||
result += '\\\\'
|
||||
} else if ('+?|()'.includes(next)) {
|
||||
result += next
|
||||
} else {
|
||||
result += `\\${next}`
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if ('+?|()'.includes(char)) {
|
||||
result += `\\${char}`
|
||||
continue
|
||||
}
|
||||
|
||||
result += char
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is a sed in-place edit command
|
||||
* Returns true only for simple sed -i 's/pattern/replacement/flags' file commands
|
||||
@@ -273,28 +295,7 @@ export function applySedSubstitution(
|
||||
// ERE/JS: + means "one or more", \+ is literal
|
||||
// We need to convert BRE escaping to ERE for JavaScript regex
|
||||
if (!sedInfo.extendedRegex) {
|
||||
jsPattern = jsPattern
|
||||
// Step 1: Protect literal backslashes (\\) first - in both BRE and ERE, \\ is literal backslash
|
||||
.replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
|
||||
// Step 2: Replace escaped metacharacters with placeholders (these should become unescaped in JS)
|
||||
.replace(/\\\+/g, PLUS_PLACEHOLDER)
|
||||
.replace(/\\\?/g, QUESTION_PLACEHOLDER)
|
||||
.replace(/\\\|/g, PIPE_PLACEHOLDER)
|
||||
.replace(/\\\(/g, LPAREN_PLACEHOLDER)
|
||||
.replace(/\\\)/g, RPAREN_PLACEHOLDER)
|
||||
// Step 3: Escape unescaped metacharacters (these are literal in BRE)
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/\?/g, '\\?')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)')
|
||||
// Step 4: Replace placeholders with their JS equivalents
|
||||
.replace(BACKSLASH_PLACEHOLDER_RE, '\\\\')
|
||||
.replace(PLUS_PLACEHOLDER_RE, '+')
|
||||
.replace(QUESTION_PLACEHOLDER_RE, '?')
|
||||
.replace(PIPE_PLACEHOLDER_RE, '|')
|
||||
.replace(LPAREN_PLACEHOLDER_RE, '(')
|
||||
.replace(RPAREN_PLACEHOLDER_RE, ')')
|
||||
jsPattern = convertBrePatternToJs(jsPattern)
|
||||
}
|
||||
|
||||
// Unescape sed-specific escapes in replacement
|
||||
|
||||
@@ -307,10 +307,6 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): {
|
||||
let result = ''
|
||||
let stripped = false
|
||||
|
||||
// A well-formed HTML comment span. Non-greedy so multiple comments on the
|
||||
// same line are matched independently; [\s\S] to span newlines.
|
||||
const commentSpan = /<!--[\s\S]*?-->/g
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'html') {
|
||||
const trimmed = token.raw.trimStart()
|
||||
@@ -318,7 +314,7 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): {
|
||||
// Per CommonMark, a type-2 HTML block ends at the *line* containing
|
||||
// `-->`, so text after `-->` on that line is part of this token.
|
||||
// Strip only the comment spans and keep any residual content.
|
||||
const residue = token.raw.replace(commentSpan, '')
|
||||
const residue = stripHtmlCommentSpans(token.raw)
|
||||
stripped = true
|
||||
if (residue.trim().length > 0) {
|
||||
// Residual content exists (e.g. `<!-- note --> Use bun`): keep it.
|
||||
@@ -333,6 +329,20 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): {
|
||||
return { content: result, stripped }
|
||||
}
|
||||
|
||||
function stripHtmlCommentSpans(raw: string): string {
|
||||
let residue = raw
|
||||
|
||||
while (residue.includes('<!--')) {
|
||||
const updated = residue.replace(/<!--[\s\S]*?-->/g, '')
|
||||
if (updated === residue) {
|
||||
break
|
||||
}
|
||||
residue = updated
|
||||
}
|
||||
|
||||
return residue
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw memory file content into a MemoryFileInfo. Pure function — no I/O.
|
||||
*
|
||||
@@ -504,8 +514,7 @@ function extractIncludePathsFromTokens(
|
||||
const raw = element.raw || ''
|
||||
const trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith('<!--') && trimmed.includes('-->')) {
|
||||
const commentSpan = /<!--[\s\S]*?-->/g
|
||||
const residue = raw.replace(commentSpan, '')
|
||||
const residue = stripHtmlCommentSpans(raw)
|
||||
if (residue.trim().length > 0) {
|
||||
extractPathsFromText(residue)
|
||||
}
|
||||
|
||||
@@ -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 with:', err.stack || err.message)
|
||||
console.error('[HARD FAIL] logError called:', err.name || 'Error')
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Inspired by https://github.com/nas5w/random-word-slugs
|
||||
* with Claude-flavored words
|
||||
*/
|
||||
import { randomBytes } from 'crypto'
|
||||
import { randomInt as cryptoRandomInt } from 'crypto'
|
||||
|
||||
// Adjectives for slug generation - whimsical and delightful
|
||||
const ADJECTIVES = [
|
||||
@@ -765,10 +765,7 @@ const VERBS = [
|
||||
* Generate a cryptographically random integer in the range [0, max)
|
||||
*/
|
||||
function randomInt(max: number): number {
|
||||
// Use crypto.randomBytes for better randomness than Math.random
|
||||
const bytes = randomBytes(4)
|
||||
const value = bytes.readUInt32BE(0)
|
||||
return value % max
|
||||
return cryptoRandomInt(max)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user