feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -232,6 +232,7 @@ function updateLeafField(field: keyof EditableConditionLeaf, rawValue: string):
next.source = rawValue as ConditionSource
} else if (field === 'path' || field === 'value') {
next[field] = rawValue
if (field === 'value') next.retainValue = false
}
emit('update:modelValue', next)
@@ -1088,6 +1088,10 @@ import {
type EditableConditionNode,
validateEditableCondition,
} from './endpoint-rule-condition'
import {
endpointSecretMarkerPayload,
retainsEndpointSecret,
} from './endpoint-secret-markers'
// 编辑用的规则类型(统一的可编辑结构)
interface EditableRule {
@@ -1095,6 +1099,7 @@ interface EditableRule {
enabled: boolean
key: string // set/drop 用
value: string // set 用
retainValue: boolean
from: string // rename 用
to: string // rename 用
condition: EditableConditionNode | null
@@ -1108,11 +1113,14 @@ interface EditableBodyRule {
enabled: boolean
path: string // set/drop/append/insert/regex_replace 用
value: string // set/append/insert 用(JSON 格式)
retainValue: boolean
from: string // rename 用
to: string // rename 用
index: string // insert 用(字符串输入,保存时解析为 int)
pattern: string // regex_replace 用
retainPattern: boolean
replacement: string // regex_replace 用
retainReplacement: boolean
flags: string // regex_replace 用(i/m/s)
count: string // regex_replace 用(空=默认全部;0=全部)
condition: EditableConditionNode | null
@@ -2081,7 +2089,16 @@ async function clearEndpointProxy(endpoint: ProviderEndpoint) {
}
function emptyHeaderRule(): EditableRule {
return { action: 'set', enabled: true, key: '', value: '', from: '', to: '', condition: null }
return {
action: 'set',
enabled: true,
key: '',
value: '',
retainValue: false,
from: '',
to: '',
condition: null,
}
}
function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): EditableRule[] {
@@ -2089,7 +2106,15 @@ function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): E
const editableRules: EditableRule[] = []
for (const rule of rules) {
if (rule.action === 'set') {
editableRules.push({ ...emptyHeaderRule(), action: 'set', enabled: rule.enabled !== false, key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
editableRules.push({
...emptyHeaderRule(),
action: 'set',
enabled: rule.enabled !== false,
key: rule.key,
value: rule.value || '',
retainValue: retainsEndpointSecret(rule.value, rule.has_value),
condition: conditionToEditable(rule.condition),
})
} else if (rule.action === 'drop') {
editableRules.push({ ...emptyHeaderRule(), action: 'drop', enabled: rule.enabled !== false, key: rule.key, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
@@ -2105,11 +2130,14 @@ function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
enabled: true,
path: '',
value: '',
retainValue: false,
from: '',
to: '',
index: '',
pattern: '',
retainPattern: false,
replacement: '',
retainReplacement: false,
flags: '',
count: '',
condition: null,
@@ -2123,24 +2151,26 @@ function editableBodyRulesFromRules(rules: BodyRule[] | null | undefined): Edita
for (const rule of rules) {
if (rule.action === 'set') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, condition: conditionToEditable(rule.condition) })
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'drop') {
bodyRules.push({ ...emptyBodyRule('drop'), enabled: rule.enabled !== false, path: rule.path, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
bodyRules.push({ ...emptyBodyRule('rename'), enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'append') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, condition: conditionToEditable(rule.condition) })
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'insert') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'regex_replace') {
bodyRules.push({
...emptyBodyRule('regex_replace'),
enabled: rule.enabled !== false,
path: rule.path || '',
pattern: rule.pattern || '',
retainPattern: retainsEndpointSecret(rule.pattern, rule.has_pattern),
replacement: rule.replacement || '',
retainReplacement: retainsEndpointSecret(rule.replacement, rule.has_replacement),
flags: rule.flags || '',
count: rule.count === undefined || rule.count === null ? '' : String(rule.count),
condition: conditionToEditable(rule.condition),
@@ -2366,6 +2396,7 @@ function updateEndpointRuleField(endpointId: string, index: number, field: 'key'
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index][field] = value
if (field === 'value') rules[index].retainValue = false
}
}
@@ -2373,6 +2404,7 @@ function updateEndpointResponseRuleField(endpointId: string, index: number, fiel
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index][field] = value
if (field === 'value') rules[index].retainValue = false
}
}
@@ -2579,6 +2611,9 @@ function updateEndpointBodyRuleField(endpointId: string, index: number, field: '
const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) {
rules[index][field] = value
if (field === 'value') rules[index].retainValue = false
if (field === 'pattern') rules[index].retainPattern = false
if (field === 'replacement') rules[index].retainReplacement = false
}
}
@@ -2728,6 +2763,7 @@ function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
// 正则表达式验证状态:true=有效, false=无效, null=空
function getRegexPatternValidation(rule: EditableBodyRule): boolean | null {
if (rule.action !== 'regex_replace') return null
if (rule.retainPattern) return true
const pattern = rule.pattern.trim()
if (!pattern) return null
try {
@@ -2897,6 +2933,7 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
} else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.path !== original.path) return true
} else if (edited.action === 'rename' && original.action === 'rename') {
@@ -2905,15 +2942,19 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
} else if (edited.action === 'insert' && original.action === 'insert') {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.index !== String(original.index ?? '')) return true
if (edited.value !== baseline.value) return true
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
} else if (edited.action === 'regex_replace' && original.action === 'regex_replace') {
if (edited.path !== original.path) return true
if (edited.pattern !== (original.pattern ?? '')) return true
if (edited.replacement !== (original.replacement ?? '')) return true
if (edited.retainPattern !== retainsEndpointSecret(original.pattern, original.has_pattern)) return true
if (edited.retainReplacement !== retainsEndpointSecret(original.replacement, original.has_replacement)) return true
if (edited.flags !== (original.flags ?? '')) return true
if (edited.count !== (original.count === undefined || original.count === null ? '' : String(original.count))) return true
}
@@ -2932,7 +2973,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
if (rule.action === 'set' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
result.push({ action: 'set', path: rule.path.trim(), value, ...common })
result.push({ action: 'set', path: rule.path.trim(), value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
} else if (rule.action === 'drop' && rule.path.trim()) {
result.push({ action: 'drop', path: rule.path.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
@@ -2940,19 +2981,21 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
} else if (rule.action === 'append' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
result.push({ action: 'append', path: rule.path.trim(), value, ...common })
result.push({ action: 'append', path: rule.path.trim(), value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
} else if (rule.action === 'insert' && rule.path.trim()) {
let value: unknown = rule.value
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
if (!isStrictIntegerString(rule.index)) continue
const idx = parseInt(rule.index.trim(), 10)
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...common })
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
} else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
const entry: BodyRuleRegexReplace = {
action: 'regex_replace',
path: rule.path.trim(),
pattern: rule.pattern,
replacement: rule.replacement || '',
...endpointSecretMarkerPayload('has_pattern', rule.retainPattern, rule.pattern),
...endpointSecretMarkerPayload('has_replacement', rule.retainReplacement, rule.replacement),
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
...(isStrictNonNegativeIntegerString(rule.count) ? { count: parseInt(rule.count.trim(), 10) } : {}),
}
@@ -2999,13 +3042,15 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
if (!rule.pattern.trim()) return `${prefix}${legacyT('正则表达式不能为空')}`
try {
new RegExp(rule.pattern.trim())
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
return locale.value === 'en-US'
? `${prefix}Invalid regular expression: ${message}`
: `${prefix}正则表达式无效:${message}`
if (!rule.retainPattern) {
try {
new RegExp(rule.pattern.trim())
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
return locale.value === 'en-US'
? `${prefix}Invalid regular expression: ${message}`
: `${prefix}正则表达式无效:${message}`
}
}
const flags = rule.flags.trim()
if (flags) {
@@ -3057,6 +3102,7 @@ function editableHeaderRulesChanged(edited: EditableRule[], originalRules: Heade
if (edited.enabled !== (original.enabled !== false)) return true
if (edited.action === 'set' && original.action === 'set') {
if (edited.key !== original.key || edited.value !== (original.value || '')) return true
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
} else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.key !== original.key) return true
} else if (edited.action === 'rename' && original.action === 'rename') {
@@ -3145,7 +3191,13 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
const condition = editableConditionToApi(rule.condition)
const common = { ...(rule.enabled ? {} : { enabled: false }), ...(condition ? { condition } : {}) }
if (rule.action === 'set' && rule.key.trim()) {
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...common })
result.push({
action: 'set',
key: rule.key.trim(),
value: rule.value,
...endpointSecretMarkerPayload('has_value', rule.retainValue, rule.value),
...common,
})
} else if (rule.action === 'drop' && rule.key.trim()) {
result.push({ action: 'drop', key: rule.key.trim(), ...common })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
@@ -868,6 +868,7 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useTotp } from '@/composables/useTotp'
import { parseApiError } from '@/utils/errorParser'
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
import { useI18n } from '@/i18n'
import {
startProviderLevelOAuth,
@@ -1550,7 +1551,12 @@ function handleClose() {
function openAuthorizationUrl() {
const url = oauth.value.authorization_url
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
const safeUrl = safeExternalHttpsUrl(url)
if (!safeUrl) {
showError(legacyT('OAuth 服务返回了不安全的授权地址'))
return
}
window.open(safeUrl, '_blank', 'noopener,noreferrer')
}
async function initOAuth() {
@@ -2324,7 +2330,13 @@ async function handleCreateAgentIdentity() {
function openDeviceVerificationUrl() {
const url = device.value.verification_uri_complete || device.value.verification_uri
if (url) window.open(url, '_blank', 'noopener,noreferrer')
if (!url) return
const safeUrl = safeExternalHttpsUrl(url)
if (!safeUrl) {
showError(legacyT('OAuth 服务返回了不安全的设备验证地址'))
return
}
window.open(safeUrl, '_blank', 'noopener,noreferrer')
}
function startCountdown() {
@@ -196,6 +196,7 @@ import {
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
type ProviderBatchAction = 'enable' | 'disable' | 'delete'
@@ -390,8 +391,7 @@ async function executeBatchAction(): Promise<void> {
successCount += 1
} catch (err) {
failedCount += 1
// eslint-disable-next-line no-console
console.error(`[ProviderBatchActionDialog] ${selectedAction.value} failed (${provider.id}):`, err)
log.error('Provider batch action failed', err)
} finally {
progressDone.value += 1
}
@@ -120,16 +120,16 @@
</div>
<div
v-if="provider.website"
v-if="safeProviderWebsite"
class="-mt-0.5"
>
<a
:href="provider.website"
:href="safeProviderWebsite"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground hover:text-primary hover:underline transition-colors truncate block"
:title="provider.website"
>{{ provider.website }}</a>
:title="safeProviderWebsite"
>{{ safeProviderWebsite }}</a>
</div>
<div class="flex items-center gap-1.5 flex-wrap mt-3">
@@ -181,6 +181,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { useI18n } from '@/i18n'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
const props = defineProps<{
@@ -209,6 +210,7 @@ defineEmits<{
}>()
const { legacyT } = useI18n()
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
const formatConversionTitle = computed(() => {
if (props.systemFormatConversionEnabled) return legacyT('系统级格式转换已启用')
@@ -9,12 +9,12 @@
<div class="flex items-center gap-1.5">
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
<a
v-if="provider.website"
:href="provider.website"
v-if="safeProviderWebsite"
:href="safeProviderWebsite"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
:title="provider.website"
:title="safeProviderWebsite"
@click.stop
>
<ExternalLink class="w-3.5 h-3.5" />
@@ -221,7 +221,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import {
Edit,
Eye,
@@ -241,6 +241,7 @@ import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { useI18n } from '@/i18n'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -272,6 +273,7 @@ const vAutoFocus = {
const localDescriptionValue = ref('')
const { legacyT, locale } = useI18n()
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
watch(
() => props.editingDescriptionId,
@@ -9,12 +9,12 @@
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<a
v-if="provider.website"
:href="provider.website"
v-if="safeProviderWebsite"
:href="safeProviderWebsite"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
:title="provider.website"
:title="safeProviderWebsite"
@click.stop
>
<ExternalLink class="w-3.5 h-3.5" />
@@ -192,7 +192,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import {
Edit,
Eye,
@@ -213,6 +213,7 @@ import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/e
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import { useI18n } from '@/i18n'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -249,6 +250,7 @@ const vAutoFocus = {
const localDescriptionValue = ref('')
const { legacyT, locale } = useI18n()
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
// 当进入编辑模式时,同步 props 的 description
watch(
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { BodyRuleCondition } from '@/api/endpoints'
import {
conditionToEditable,
editableConditionToApi,
type EditableConditionLeaf,
type EditableConditionNode,
} from '../endpoint-rule-condition'
function findLeaf(node: EditableConditionNode | null): EditableConditionLeaf {
if (!node) throw new Error('condition was not converted')
if (node.kind === 'leaf') return node
const child = node.children[0]
if (!child || child.kind !== 'leaf') throw new Error('condition leaf was not converted')
return child
}
describe('endpoint condition secret markers', () => {
it('round-trips a masked request-header condition through a nested group', () => {
const condition: BodyRuleCondition = {
all: [{
source: 'request_headers',
path: 'x-tenant-token',
op: 'eq',
value: '***',
has_value: true,
}],
}
const editable = conditionToEditable(condition)
expect(findLeaf(editable).retainValue).toBe(true)
expect(editableConditionToApi(editable)).toEqual(condition)
})
it('drops the marker after the user replaces the masked value', () => {
const editable = conditionToEditable({
source: 'request_headers',
path: 'authorization',
op: 'eq',
value: '***',
has_value: true,
})
const leaf = findLeaf(editable)
leaf.value = 'Bearer replacement-token'
leaf.retainValue = false
expect(editableConditionToApi(editable)).toEqual({
source: 'request_headers',
path: 'authorization',
op: 'eq',
value: 'Bearer replacement-token',
})
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
endpointSecretMarkerPayload,
retainsEndpointSecret,
} from '../endpoint-secret-markers'
describe('endpoint secret marker contract', () => {
it('retains only a marked redacted placeholder', () => {
expect(retainsEndpointSecret('***', true)).toBe(true)
expect(retainsEndpointSecret('***', false)).toBe(false)
expect(retainsEndpointSecret('new-secret', true)).toBe(false)
})
it('emits a marker only while the redacted value remains untouched', () => {
expect(endpointSecretMarkerPayload('has_value', true, '***')).toEqual({ has_value: true })
expect(endpointSecretMarkerPayload('has_pattern', true, '***')).toEqual({ has_pattern: true })
expect(endpointSecretMarkerPayload('has_replacement', false, '***')).toEqual({})
expect(endpointSecretMarkerPayload('has_value', true, 'new-secret')).toEqual({})
})
})
@@ -1,4 +1,8 @@
import type { BodyRuleCondition, BodyRuleConditionOp } from '@/api/endpoints'
import {
endpointSecretMarkerPayload,
retainsEndpointSecret,
} from './endpoint-secret-markers'
export type ConditionSource = 'body' | 'original' | 'request_headers'
export type ConditionGroupMode = 'all' | 'any'
@@ -8,6 +12,7 @@ export interface EditableConditionLeaf {
path: string
op: BodyRuleConditionOp
value: string
retainValue: boolean
source: ConditionSource
}
@@ -46,6 +51,7 @@ export function createEmptyConditionLeaf(): EditableConditionLeaf {
path: '',
op: 'eq',
value: '',
retainValue: false,
source: 'body',
}
}
@@ -94,6 +100,7 @@ export function conditionToEditable(condition?: BodyRuleCondition | null): Edita
value: condition.value !== undefined
? (typeof condition.value === 'string' ? condition.value : JSON.stringify(condition.value))
: '',
retainValue: retainsEndpointSecret(condition.value, condition.has_value),
source: source === 'request_headers' || source === 'headers'
? 'request_headers'
: source === 'original'
@@ -131,14 +138,15 @@ export function editableConditionToApi(node: EditableConditionNode | null): Body
}
const raw = node.value.trim()
const marker = endpointSecretMarkerPayload('has_value', node.retainValue, raw)
if (!raw) {
return { ...base, value: '' }
return { ...base, value: '', ...marker }
}
try {
return { ...base, value: JSON.parse(raw) }
return { ...base, value: JSON.parse(raw), ...marker }
} catch {
return { ...base, value: raw }
return { ...base, value: raw, ...marker }
}
}
@@ -174,6 +182,7 @@ export function conditionEquals(
return left.path === right.path
&& left.op === right.op
&& left.value === right.value
&& left.retainValue === right.retainValue
&& left.source === right.source
}
@@ -196,6 +205,7 @@ export function validateEditableCondition(node: EditableConditionNode | null): s
if (!path) return '条件路径不能为空'
if (!isConditionValueRequired(node.op)) return null
if (node.retainValue) return null
const raw = node.value.trim()
let parsed: unknown = raw
@@ -0,0 +1,20 @@
export const REDACTED_SECRET_PLACEHOLDER = '***'
export type EndpointSecretMarker = 'has_value' | 'has_pattern' | 'has_replacement'
export function retainsEndpointSecret(
value: unknown,
marker: unknown,
): boolean {
return marker === true && value === REDACTED_SECRET_PLACEHOLDER
}
export function endpointSecretMarkerPayload(
marker: EndpointSecretMarker,
retained: boolean,
value: unknown,
): Partial<Record<EndpointSecretMarker, true>> {
return retained && value === REDACTED_SECRET_PLACEHOLDER
? { [marker]: true }
: {}
}