306ffe21b9
🧹 Relax overly restrictive HTTP header name validation
188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
import * as core from '@actions/core'
|
|
import * as fs from 'fs'
|
|
import * as yaml from 'js-yaml'
|
|
import {PromptConfig} from './prompt.js'
|
|
import {InferenceRequest} from './inference.js'
|
|
|
|
/**
|
|
* Helper function to load content from a file or use fallback input
|
|
* @param filePathInput - Input name for the file path
|
|
* @param contentInput - Input name for the direct content
|
|
* @param defaultValue - Default value to use if neither file nor content is provided
|
|
* @returns The loaded content
|
|
*/
|
|
export function loadContentFromFileOrInput(filePathInput: string, contentInput: string, defaultValue?: string): string {
|
|
const filePath = core.getInput(filePathInput)
|
|
const contentString = core.getInput(contentInput)
|
|
|
|
if (filePath !== undefined && filePath !== '') {
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new Error(`File for ${filePathInput} was not found: ${filePath}`)
|
|
}
|
|
return fs.readFileSync(filePath, 'utf-8')
|
|
} else if (contentString !== undefined && contentString !== '') {
|
|
return contentString
|
|
} else if (defaultValue !== undefined) {
|
|
return defaultValue
|
|
} else {
|
|
throw new Error(`Neither ${filePathInput} nor ${contentInput} was set`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build messages array from either prompt config or legacy format
|
|
*/
|
|
export function buildMessages(
|
|
promptConfig?: PromptConfig,
|
|
systemPrompt?: string,
|
|
prompt?: string,
|
|
): Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}> {
|
|
if (promptConfig?.messages && promptConfig.messages.length > 0) {
|
|
// Use new message format
|
|
return promptConfig.messages.map(msg => ({
|
|
role: msg.role as 'system' | 'user' | 'assistant' | 'tool',
|
|
content: msg.content,
|
|
}))
|
|
} else {
|
|
// Use legacy format
|
|
return [
|
|
{
|
|
role: 'system',
|
|
content: systemPrompt || 'You are a helpful assistant',
|
|
},
|
|
{role: 'user', content: prompt || ''},
|
|
]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build response format object for API from prompt config
|
|
*/
|
|
export function buildResponseFormat(
|
|
promptConfig?: PromptConfig,
|
|
): {type: 'json_schema'; json_schema: unknown} | undefined {
|
|
if (promptConfig?.responseFormat === 'json_schema' && promptConfig.jsonSchema) {
|
|
try {
|
|
const schema = JSON.parse(promptConfig.jsonSchema)
|
|
return {
|
|
type: 'json_schema',
|
|
json_schema: schema,
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON schema: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Parse custom headers from YAML or JSON format
|
|
* @param input - String in YAML or JSON format containing headers
|
|
* @returns Record of header names to values, or empty object if invalid
|
|
*/
|
|
export function parseCustomHeaders(input: string): Record<string, string> {
|
|
if (!input || input.trim() === '') {
|
|
return {}
|
|
}
|
|
|
|
const trimmedInput = input.trim()
|
|
|
|
try {
|
|
// Try JSON first (check if it starts with { or [)
|
|
if (trimmedInput.startsWith('{') || trimmedInput.startsWith('[')) {
|
|
const parsed = JSON.parse(trimmedInput)
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
core.warning('Custom headers JSON must be an object, not null or an array')
|
|
return {}
|
|
}
|
|
return validateAndMaskHeaders(parsed as Record<string, unknown>)
|
|
}
|
|
|
|
// Try YAML
|
|
const parsed = yaml.load(trimmedInput)
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
core.warning('Custom headers YAML must be an object')
|
|
return {}
|
|
}
|
|
return validateAndMaskHeaders(parsed as Record<string, unknown>)
|
|
} catch (error) {
|
|
core.warning(`Failed to parse custom headers: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate header names and mask sensitive values in logs
|
|
* @param headers - Raw headers object
|
|
* @returns Validated headers with string values
|
|
*/
|
|
function validateAndMaskHeaders(headers: Record<string, unknown>): Record<string, string> {
|
|
const validHeaders: Record<string, string> = {}
|
|
const sensitivePatterns = ['key', 'token', 'secret', 'password', 'authorization']
|
|
|
|
for (const [name, value] of Object.entries(headers)) {
|
|
// Validate header name (RFC 7230: token = 1*tchar)
|
|
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
|
if (!/^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(name)) {
|
|
core.warning(`Skipping invalid header name: ${name} (contains invalid characters)`)
|
|
continue
|
|
}
|
|
|
|
// Convert value to string
|
|
const stringValue = String(value)
|
|
|
|
// Validate header value to prevent CRLF/header injection
|
|
if (stringValue.includes('\r') || stringValue.includes('\n')) {
|
|
core.warning(
|
|
`Skipping header "${name}" because its value contains newline characters, which are not allowed in HTTP header values.`,
|
|
)
|
|
continue
|
|
}
|
|
validHeaders[name] = stringValue
|
|
|
|
// Mask sensitive headers in logs
|
|
const lowerName = name.toLowerCase()
|
|
const isSensitive = sensitivePatterns.some(pattern => lowerName.includes(pattern))
|
|
if (isSensitive) {
|
|
core.debug(`Custom header added: ${name}: ***MASKED***`)
|
|
} else {
|
|
core.debug(`Custom header added: ${name}: ${stringValue}`)
|
|
}
|
|
}
|
|
|
|
return validHeaders
|
|
}
|
|
|
|
/**
|
|
* Build complete InferenceRequest from prompt config and inputs
|
|
*/
|
|
export function buildInferenceRequest(
|
|
promptConfig: PromptConfig | undefined,
|
|
systemPrompt: string | undefined,
|
|
prompt: string | undefined,
|
|
modelName: string,
|
|
temperature: number | undefined,
|
|
topP: number | undefined,
|
|
maxTokens: number | undefined, // Deprecated
|
|
maxCompletionTokens: number | undefined,
|
|
endpoint: string,
|
|
token: string,
|
|
customHeaders?: Record<string, string>,
|
|
): InferenceRequest {
|
|
const messages = buildMessages(promptConfig, systemPrompt, prompt)
|
|
const responseFormat = buildResponseFormat(promptConfig)
|
|
|
|
return {
|
|
messages,
|
|
modelName,
|
|
temperature,
|
|
topP,
|
|
maxTokens, // Deprecated
|
|
maxCompletionTokens,
|
|
endpoint,
|
|
token,
|
|
responseFormat,
|
|
customHeaders,
|
|
}
|
|
}
|