diff --git a/.grype.yaml b/.grype.yaml index 706362184..bda777bb4 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -71,3 +71,38 @@ ignore: package: name: brace-expansion type: npm + + # GHSA-rgw5-rvv9-x895 (brace-expansion <5.0.9, HIGH) is a follow-on DoS + # affecting the same npm-vendored package and threat surface described above. + # AWF does not pass remote input to npm's brace expansion code. npm 12.0.1, + # the latest official release as of 2026-08-04, still bundles 5.0.7; retain + # this acceptance only until an official npm release bundles >=5.0.9. + - vulnerability: GHSA-rgw5-rvv9-x895 + package: + name: brace-expansion + type: npm + + # ── ip-address bundled inside the vendored npm CLI ─────────────────────────── + # + # GHSA-mwp4-54f8-5fhr (ip-address <=10.3.0 -> 10.3.1, HIGH): + # IPv4 octets with leading zeroes can be interpreted inconsistently, which + # can bypass an SSRF guard that trusts ip-address classification. + # + # Risk acceptance — NOT USED AS A SECURITY BOUNDARY: + # This copy is bundled inside the npm CLI, not an AWF application + # dependency. AWF's proxy, firewall, and network policy do not use it for + # address classification or SSRF decisions. npm is a local CLI in these + # images and its vendored copy is not exposed as a request handler. + # + # No official npm release contains the fix yet: npm 12.0.1, the latest + # release as of 2026-08-04, still bundles ip-address 10.2.0. Hand-patching + # npm's internal dependency tree would replace a verified upstream artifact + # with a locally modified distribution. + # + # Revisit: once an official npm release bundles ip-address >=10.3.1, update + # the npm tarball pin and SHA256 in all four container Dockerfiles and DELETE + # this entry. + - vulnerability: GHSA-mwp4-54f8-5fhr + package: + name: ip-address + type: npm diff --git a/containers/api-proxy/key-validation.js b/containers/api-proxy/key-validation.js index d1c6c991f..721f003d9 100644 --- a/containers/api-proxy/key-validation.js +++ b/containers/api-proxy/key-validation.js @@ -255,13 +255,9 @@ function validateRequestedModel() { for (const line of resolution.log) { logRequest('debug', 'model_validation_step', { message: line, provider: resolution.provider }); } - // resolved_via is 'alias' when: the model name matches an alias key (alias lookup - // takes precedence in resolveModel) OR the resolved name differs from the request. + // An explicit provider model takes precedence over a matching alias key. const requestedKey = requestedModel.toLowerCase(); - const isAlias = modelAliases - ? Object.keys(modelAliases.models).some(k => k.toLowerCase() === requestedKey) - : false; - const resolvedVia = isAlias || resolution.resolvedModel.toLowerCase() !== requestedKey ? 'alias' : 'direct'; + const resolvedVia = resolution.resolvedModel.toLowerCase() !== requestedKey ? 'alias' : 'direct'; logRequest('info', 'model_validation', { requested_model: requestedModel, resolved_via: resolvedVia, diff --git a/containers/api-proxy/model-resolver.js b/containers/api-proxy/model-resolver.js index 51c96a6c3..387effad3 100644 --- a/containers/api-proxy/model-resolver.js +++ b/containers/api-proxy/model-resolver.js @@ -182,7 +182,16 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase if (slashIdx === -1) { // Recursive alias reference (no provider prefix) - const sub = resolveModel(pattern, aliases, availableModels, currentProvider, newChain, fallbackConfig, modelPolicyConfig); + const sub = resolveModel( + pattern, + aliases, + availableModels, + currentProvider, + newChain, + fallbackConfig, + modelPolicyConfig, + false + ); if (sub) { log.push(...sub.log); candidates.push(sub.resolvedModel); @@ -252,9 +261,10 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase * * Resolution algorithm: * 1. Loop detection — bail out if key already visited. - * 2. Alias lookup (case-insensitive); family alias fallback for gpt-5.. - * 3. No alias found → _resolveDirectMatch (direct, family-version, or middle-power). - * 4. Alias found → _resolveAliasPatterns (pattern expansion + best-candidate selection). + * 2. Direct match — preserve an explicitly available provider model. + * 3. Alias lookup (case-insensitive); family alias fallback for gpt-5.. + * 4. No alias found → _resolveDirectMatch (family-version or middle-power fallback). + * 5. Alias found → _resolveAliasPatterns (pattern expansion + best-candidate selection). * * @param {string} requestedModel - Model name from the request body (or "" for default) * @param {Record} aliases - Alias map from parseModelAliases() @@ -263,9 +273,19 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase * @param {string[]} [chain=[]] - Accumulates visited alias names for loop detection * @param {{ enabled?: boolean, strategy?: string }} [modelFallbackConfig] * @param {{ allowedModels?: string[]|null, disallowedModels?: string[]|null }|null} [modelPolicyConfig] + * @param {boolean} [preferDirectRequest=true] - Prefer an exact provider model over a same-named alias for top-level requests * @returns {{ resolvedModel: string, candidates: string[], log: string[], fallback?: object } | null} */ -function resolveModel(requestedModel, aliases, availableModels, currentProvider, chain = [], modelFallbackConfig = DEFAULT_MODEL_FALLBACK, modelPolicyConfig = null) { +function resolveModel( + requestedModel, + aliases, + availableModels, + currentProvider, + chain = [], + modelFallbackConfig = DEFAULT_MODEL_FALLBACK, + modelPolicyConfig = null, + preferDirectRequest = true +) { const log = []; const key = requestedModel.toLowerCase(); const fallbackConfig = normalizeFallbackConfig(modelFallbackConfig); @@ -289,32 +309,33 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider, } const newChain = [...chain, key]; - // Find alias entry (case-insensitive) - let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key); - - if (!aliasEntry) { - // Prefer exact provider-advertised model names over family-alias fallback. - // This avoids silently rewriting a concrete user request (e.g. gpt-5.6-sol) - // to another family member when that exact model is already available. + // An explicit model available from this provider is authoritative over any + // matching alias. This prevents an alias with the same name from silently + // steering a request away from the configured provider. + if (preferDirectRequest) { const providerModels = (availableModels[currentProvider] || []); const direct = providerModels.find(m => m.toLowerCase() === key); if (direct) { if (!_isModelPermittedByPolicy(direct, modelPolicyConfig)) { - // Model is advertised but blocked by policy — treat as terminal to prevent - // a denied model from being silently rewritten to a permitted family member. log.push(`[model-resolver] model policy blocked direct match: "${direct}"`); return null; } log.push(`[model-resolver] direct match: "${requestedModel}" → "${direct}"`); return { resolvedModel: direct, + candidates: [direct], log, fallback: fallbackConfig.enabled ? { activated: false, selection_method: 'middle_power_median', reason: 'direct_match' } : undefined, }; } + } + // Find alias entry (case-insensitive) + let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key); + + if (!aliasEntry) { // Family fallback: treat gpt-5. as gpt-5 when only the family alias // exists. This keeps versioned IDs like gpt-5.4 compatible with configs that // define "gpt-5" alias patterns. @@ -368,7 +389,7 @@ function filterResolvableAliases(aliases, availableModels) { for (const aliasKey of Object.keys(aliases)) { const canResolve = providersWithData.some(provider => { - const resolution = resolveModel(aliasKey, aliases, availableModels, provider, [], noFallback); + const resolution = resolveModel(aliasKey, aliases, availableModels, provider, [], noFallback, null, false); return resolution !== null; }); diff --git a/containers/api-proxy/model-resolver.test.js b/containers/api-proxy/model-resolver.test.js index 192a4e700..58ea1b210 100644 --- a/containers/api-proxy/model-resolver.test.js +++ b/containers/api-proxy/model-resolver.test.js @@ -162,6 +162,36 @@ describe('resolveModel', () => { expect(result.resolvedModel).toBe('gpt-5.6-sol'); }); + it('preserves an explicit provider model when an alias has the same name', () => { + const result = resolveModel( + 'claude-sonnet-5', + { 'claude-sonnet-5': ['copilot/claude-sonnet-6*'] }, + { + anthropic: ['claude-sonnet-5'], + copilot: ['claude-sonnet-6'], + }, + 'anthropic' + ); + expect(result).not.toBeNull(); + expect(result.resolvedModel).toBe('claude-sonnet-5'); + expect(result.candidates).toEqual(['claude-sonnet-5']); + expect(result.log).toContain('[model-resolver] direct match: "claude-sonnet-5" → "claude-sonnet-5"'); + }); + + it('expands a same-named alias when referenced recursively', () => { + const result = resolveModel( + 'coding', + { + coding: ['claude-sonnet-5'], + 'claude-sonnet-5': ['anthropic/claude-sonnet-6*'], + }, + { anthropic: ['claude-sonnet-5', 'claude-sonnet-6'] }, + 'anthropic' + ); + expect(result).not.toBeNull(); + expect(result.resolvedModel).toBe('claude-sonnet-6'); + }); + it('returns null (terminal) when the exact advertised model is denied by policy — does not fall through to family alias', () => { // gpt-5.6-sol is advertised by the provider AND has a gpt-5 family alias. // When gpt-5.6-sol is explicitly denylisted, resolution must stop (null) rather @@ -477,6 +507,15 @@ describe('filterResolvableAliases', () => { expect(result).not.toHaveProperty('sonnet'); // 'gpt-5-codex' has no match expect(result).not.toHaveProperty('gpt-5-codex'); + }); + + it('should not keep an alias solely because its key is an available model', () => { + const collidingAliases = { + 'claude-sonnet-5': ['anthropic/claude-sonnet-6*'], + }; + const availableModels = { anthropic: ['claude-sonnet-5'] }; + const result = filterResolvableAliases(collidingAliases, availableModels); + expect(result).not.toHaveProperty('claude-sonnet-5'); // '' → 'sonnet' → no match → filtered out too expect(result).not.toHaveProperty(''); }); diff --git a/containers/api-proxy/server.startup-model-validation.test.js b/containers/api-proxy/server.startup-model-validation.test.js index 8cf7502b2..63bdff900 100644 --- a/containers/api-proxy/server.startup-model-validation.test.js +++ b/containers/api-proxy/server.startup-model-validation.test.js @@ -134,6 +134,35 @@ describe('validateRequestedModel', () => { } }); + it('logs an explicit provider model as direct when it shares an alias name', () => { + const prevAliases = process.env.AWF_MODEL_ALIASES; + process.env.AWF_MODEL_ALIASES = JSON.stringify({ + models: { 'claude-sonnet-5': ['copilot/claude-sonnet-6*'] }, + }); + + let isolatedServer; + jest.isolateModules(() => { + jest.mock('./logging', () => ({ logRequest: jest.fn() })); + isolatedServer = require('./server'); + }); + + const { logRequest: isolatedLog } = require('./logging'); + + try { + isolatedServer.resetModelCacheState(); + isolatedServer.cachedModels.anthropic = ['claude-sonnet-5']; + process.env.AWF_REQUESTED_MODEL = 'claude-sonnet-5'; + isolatedServer.validateRequestedModel(); + expect(isolatedLog).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'claude-sonnet-5', + resolved_via: 'direct', + })); + } finally { + if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES; + else process.env.AWF_MODEL_ALIASES = prevAliases; + } + }); + it('does not emit model_validation via alias when fallback would fire but model is absent', () => { const prevAliases = process.env.AWF_MODEL_ALIASES; const prevFallback = process.env.AWF_MODEL_FALLBACK;