apisix 카탈로그 CVE 게이트 완전 해소 (차단 165건 → 0건)

etcd(bitnamilegacy 동결 미러) → etcd.enabled=false + 카탈로그 자체 etcd 차트를
externalEtcd 기본값으로 연결. adc·apisix-ingress-controller·apisix(paasup/apisix)
세 이미지는 SUSE BCI 자체 빌드로 교체 — 전부 벤더 등급만으로는 안 보이던
벤더 하향 등급 CVE(NVD 재평가 시 드러남)가 원인이었다.

- images/apisix-ingress-controller: 정적 링크 Go 모듈 취약 버전만 강제 업그레이드
- images/apisix: APISIX-Runtime(WASM·dubbo 등 커스텀 모듈 포함) 전체를 SUSE BCI
  위에서 소스로 재현, keycloak-authz 플러그인 오버레이
- images/adc: 업스트림 빌더 스테이지는 그대로 두고 distroless 최종 베이스만
  SUSE BCI+nodejs24 로 교체

scripts/build/patch-catalog-tag.py 의 TAG_BLOCK 이 점 구분 중첩 경로를 지원하도록
확장(apisix 서브차트 alias 때문에 필요).

세 이미지 모두 게이트 PASS(실효 CRITICAL/HIGH 0/0)와 배포 검증(테스트 클러스터)을
마쳤다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-12 11:37:32 +09:00
parent bc4002f41c
commit 18044210f6
21 changed files with 1577 additions and 45 deletions
+168
View File
@@ -0,0 +1,168 @@
-- keycloak-authz APISIX custom plugin
-- Kong keycloak-authz 플러그인을 APISIX로 포팅
--
-- 동작:
-- 1. X-Access-Token 헤더에서 JWT 추출 → preferred_username 파싱
-- 2. 외부 API (/gwapi/v1/projectusers/{username}) POST 호출로 권한 확인
-- 3. 결과를 TTL 기반으로 in-memory 캐싱 (lrucache)
-- 4. 미인가 시 401, 내부 오류 시 500 반환
local core = require("apisix.core")
local http = require("resty.http")
local jwt = require("resty.jwt")
local lrucache = require("resty.lrucache")
local cjson = require("cjson.safe")
local plugin_name = "keycloak-authz"
-- 최대 1024개의 사용자 인증 결과를 캐싱
local auth_cache, err = lrucache.new(1024)
if not auth_cache then
error("failed to create lrucache: " .. (err or "unknown"))
end
local schema = {
type = "object",
properties = {
api_url = {
type = "string",
description = "권한 확인 API의 base URL (예: https://api.example.com)",
},
basic_auth_token = {
type = "string",
description = "API 호출 시 사용할 Basic 인증 토큰 (Base64 인코딩된 값)",
default = "",
},
timeout = {
type = "integer",
description = "API 호출 타임아웃 (밀리초)",
default = 5000,
minimum = 100,
},
skip_ssl_verify = {
type = "boolean",
description = "API 호출 시 SSL 인증서 검증 생략 여부",
default = false,
},
ttl = {
type = "integer",
description = "인증 결과 캐싱 시간 (초)",
default = 300,
minimum = 1,
},
},
required = {"api_url"},
}
local _M = {
version = 0.1,
priority = 950, -- Kong의 PRIORITY = 950과 동일
name = plugin_name,
schema = schema,
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
-- 외부 API 호출로 사용자 권한 확인
local function fetch_auth_status(api_url, preferred_username, basic_auth_token, timeout, skip_ssl_verify)
local httpc = http.new()
local scheme = ngx.var.scheme
local host = ngx.var.host
local url = scheme .. "://" .. host
local body, encode_err = cjson.encode({ url = url })
if encode_err then
return nil, "failed to encode request body: " .. encode_err
end
local headers = { ["Content-Type"] = "application/json" }
if basic_auth_token and basic_auth_token ~= "" then
headers["Authorization"] = "Basic " .. basic_auth_token
end
local full_url = api_url .. "/gwapi/v1/projectusers/" .. preferred_username
local res, req_err = httpc:request_uri(full_url, {
method = "POST",
body = body,
headers = headers,
timeout = timeout,
ssl_verify = not skip_ssl_verify,
keepalive_timeout = 60000,
keepalive_pool = 10,
})
httpc:close()
if not res then
return nil, "API request failed: " .. (req_err or "unknown error")
end
core.log.debug("auth API response status=", res.status, " user=", preferred_username)
return res.status == 200, nil
end
function _M.access(conf, ctx)
-- X-Access-Token 헤더 추출
local access_token = core.request.header(ctx, "X-Access-Token")
if not access_token then
return core.response.exit(401, { message = "Missing X-Access-Token header" })
end
-- JWT 디코딩 (서명 검증 없이 페이로드만 파싱)
local jwt_obj = jwt:load_jwt(access_token)
if not jwt_obj or not jwt_obj.payload then
core.log.warn("failed to decode JWT token")
return core.response.exit(401, { message = "Failed to decode JWT token" })
end
local preferred_username = jwt_obj.payload["preferred_username"]
if not preferred_username then
return core.response.exit(401, { message = "Missing preferred_username in JWT token" })
end
local cache_key = "auth_status:" .. preferred_username
-- 캐시 조회
local cached_result = auth_cache:get(cache_key)
if cached_result ~= nil then
core.log.debug("cache hit for user=", preferred_username, " result=", tostring(cached_result))
if not cached_result then
return core.response.exit(401, { message = "User is not authorized" })
end
ctx.authenticated_user = preferred_username
return
end
-- 캐시 미스: 외부 API 호출
core.log.debug("cache miss, fetching auth status for user=", preferred_username)
local is_authorized, fetch_err = fetch_auth_status(
conf.api_url,
preferred_username,
conf.basic_auth_token or "",
conf.timeout or 5000,
conf.skip_ssl_verify or false
)
if fetch_err then
core.log.error("auth check failed user=", preferred_username, " err=", fetch_err)
return core.response.exit(500, { message = "Internal server error" })
end
-- 결과 캐싱 (TTL 적용)
auth_cache:set(cache_key, is_authorized, conf.ttl or 300)
core.log.debug("cached auth result user=", preferred_username, " authorized=", tostring(is_authorized))
if not is_authorized then
core.log.warn("authorization denied for user=", preferred_username)
return core.response.exit(401, { message = "User is not authorized" })
end
ctx.authenticated_user = preferred_username
end
return _M