feat: mask provider api key input (#772)
This commit is contained in:
@@ -487,8 +487,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(summary.providerLabel).toBe('OpenAI-compatible')
|
expect(summary.providerLabel).toBe('OpenAI-compatible')
|
||||||
expect(summary.modelLabel).toBe('sk-...5678')
|
expect(summary.modelLabel).toBe('sk-...678')
|
||||||
expect(summary.endpointLabel).toBe('sk-...5678')
|
expect(summary.endpointLabel).toBe('sk-...678')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {
|
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {
|
||||||
|
|||||||
@@ -1150,6 +1150,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
focus={true}
|
focus={true}
|
||||||
showCursor={true}
|
showCursor={true}
|
||||||
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
||||||
|
mask={currentStepKey === 'apiKey' ? '*' : undefined}
|
||||||
columns={80}
|
columns={80}
|
||||||
cursorOffset={cursorOffset}
|
cursorOffset={cursorOffset}
|
||||||
onChangeCursorOffset={setCursorOffset}
|
onChangeCursorOffset={setCursorOffset}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import stripAnsi from 'strip-ansi'
|
|||||||
|
|
||||||
import { createRoot } from '../ink.js'
|
import { createRoot } from '../ink.js'
|
||||||
import { AppStateProvider } from '../state/AppState.js'
|
import { AppStateProvider } from '../state/AppState.js'
|
||||||
|
import { maskTextWithVisibleEdges } from '../utils/Cursor.js'
|
||||||
import TextInput from './TextInput.js'
|
import TextInput from './TextInput.js'
|
||||||
import VimTextInput from './VimTextInput.js'
|
import VimTextInput from './VimTextInput.js'
|
||||||
|
|
||||||
@@ -199,6 +200,13 @@ test('TextInput renders typed characters before delayed parent value commits', a
|
|||||||
expect(output).not.toContain('Type here...')
|
expect(output).not.toContain('Type here...')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('maskTextWithVisibleEdges preserves only the first and last three chars', () => {
|
||||||
|
expect(maskTextWithVisibleEdges('sk-secret-12345678', '*')).toBe(
|
||||||
|
'sk-************678',
|
||||||
|
)
|
||||||
|
expect(maskTextWithVisibleEdges('abcdef', '*')).toBe('******')
|
||||||
|
})
|
||||||
|
|
||||||
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
|
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
|
||||||
const { stdout, stdin, getOutput } = createTestStreams()
|
const { stdout, stdin, getOutput } = createTestStreams()
|
||||||
const root = await createRoot({
|
const root = await createRoot({
|
||||||
|
|||||||
@@ -148,6 +148,42 @@ type Position = {
|
|||||||
column: number
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function maskTextWithVisibleEdges(
|
||||||
|
value: string,
|
||||||
|
mask: string,
|
||||||
|
visiblePrefix = 3,
|
||||||
|
visibleSuffix = 3,
|
||||||
|
): string {
|
||||||
|
if (!mask || !value) return value
|
||||||
|
|
||||||
|
const graphemes = Array.from(getGraphemeSegmenter().segment(value))
|
||||||
|
const secretGraphemeCount = graphemes.filter(
|
||||||
|
({ segment }) => segment !== '\n',
|
||||||
|
).length
|
||||||
|
const visibleCount = visiblePrefix + visibleSuffix
|
||||||
|
|
||||||
|
if (secretGraphemeCount <= visibleCount) {
|
||||||
|
return graphemes
|
||||||
|
.map(({ segment }) => (segment === '\n' ? segment : mask))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
let secretIndex = 0
|
||||||
|
return graphemes
|
||||||
|
.map(({ segment }) => {
|
||||||
|
if (segment === '\n') return segment
|
||||||
|
|
||||||
|
const nextSegment =
|
||||||
|
secretIndex < visiblePrefix ||
|
||||||
|
secretIndex >= secretGraphemeCount - visibleSuffix
|
||||||
|
? segment
|
||||||
|
: mask
|
||||||
|
secretIndex += 1
|
||||||
|
return nextSegment
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
export class Cursor {
|
export class Cursor {
|
||||||
readonly offset: number
|
readonly offset: number
|
||||||
constructor(
|
constructor(
|
||||||
@@ -208,7 +244,12 @@ export class Cursor {
|
|||||||
maxVisibleLines?: number,
|
maxVisibleLines?: number,
|
||||||
) {
|
) {
|
||||||
const { line, column } = this.getPosition()
|
const { line, column } = this.getPosition()
|
||||||
const allLines = this.measuredText.getWrappedText()
|
const allLines = mask
|
||||||
|
? new MeasuredText(
|
||||||
|
maskTextWithVisibleEdges(this.text, mask),
|
||||||
|
this.measuredText.columns,
|
||||||
|
).getWrappedText()
|
||||||
|
: this.measuredText.getWrappedText()
|
||||||
|
|
||||||
const startLine = this.getViewportStartLine(maxVisibleLines)
|
const startLine = this.getViewportStartLine(maxVisibleLines)
|
||||||
const endLine =
|
const endLine =
|
||||||
@@ -221,23 +262,6 @@ export class Cursor {
|
|||||||
.map((text, i) => {
|
.map((text, i) => {
|
||||||
const currentLine = i + startLine
|
const currentLine = i + startLine
|
||||||
let displayText = text
|
let displayText = text
|
||||||
if (mask) {
|
|
||||||
const graphemes = Array.from(getGraphemeSegmenter().segment(text))
|
|
||||||
if (currentLine === allLines.length - 1) {
|
|
||||||
// Last line: mask all but the trailing 6 chars so the user can
|
|
||||||
// confirm they pasted the right thing without exposing the full token
|
|
||||||
const visibleCount = Math.min(6, graphemes.length)
|
|
||||||
const maskCount = graphemes.length - visibleCount
|
|
||||||
const splitOffset =
|
|
||||||
graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0
|
|
||||||
displayText = mask.repeat(maskCount) + text.slice(splitOffset)
|
|
||||||
} else {
|
|
||||||
// Earlier wrapped lines: fully mask. Previously only the last line
|
|
||||||
// was masked, leaking the start of the token on narrow terminals
|
|
||||||
// where the pasted OAuth code wraps across multiple lines.
|
|
||||||
displayText = mask.repeat(graphemes.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// looking for the line with the cursor
|
// looking for the line with the cursor
|
||||||
if (line !== currentLine) return displayText.trimEnd()
|
if (line !== currentLine) return displayText.trimEnd()
|
||||||
|
|
||||||
|
|||||||
@@ -621,8 +621,8 @@ test('buildStartupEnvFromProfile treats explicit falsey provider flags as user i
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
|
test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
|
||||||
assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...5678')
|
assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...678')
|
||||||
assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIza...5678')
|
assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIz...678')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
|
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
|
||||||
@@ -630,7 +630,7 @@ test('redactSecretValueForDisplay masks poisoned display fields that equal confi
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
|
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
|
||||||
'sk-...5678',
|
'sk-...678',
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
||||||
|
|||||||
@@ -61,15 +61,7 @@ export function maskSecretForDisplay(
|
|||||||
return 'configured'
|
return 'configured'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sanitized.startsWith('sk-')) {
|
return `${sanitized.slice(0, 3)}...${sanitized.slice(-3)}`
|
||||||
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sanitized.startsWith('AIza')) {
|
|
||||||
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redactSecretValueForDisplay(
|
export function redactSecretValueForDisplay(
|
||||||
|
|||||||
Reference in New Issue
Block a user