Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4bc425ec | |||
| 06dc148515 | |||
| 3cfd6b0b04 | |||
| 7a0f88bfb1 | |||
| 6d7b292b8a | |||
| 20ad6f8394 | |||
| 2bae0e350c | |||
| 45e8fa2903 |
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
|
||||
|
||||
설정: `config/PortalDatasourceConfiguration.java`
|
||||
- 각 데이터베이스별 별도 EntityManager
|
||||
- Atomikos JTA를 통한 분산 트랜잭션
|
||||
- 트랜잭션 로그: `/Log/eapim/portal/`
|
||||
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
|
||||
- `transactionManager` (@Primary) → EMS
|
||||
- `gatewayTransactionManager` → Gateway
|
||||
|
||||
### 설정 프로파일
|
||||
|
||||
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
|
||||
적절한 propagation과 함께 `@Transactional` 사용:
|
||||
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
|
||||
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
|
||||
- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리
|
||||
- **다중 데이터베이스: 분산 트랜잭션(JTA/XA) 없음.** EMS·Gateway 각각 독립 로컬 트랜잭션이다.
|
||||
- 무지정 `@Transactional` = EMS(`transactionManager`)
|
||||
- **Gateway 엔티티(`com.eactive.apim.gateway.*`, `com.eactive.eai.data.entity.onl.*`)를 다루는 서비스는
|
||||
`@Transactional("gatewayTransactionManager")` 를 명시**한다. 안 하면 게이트웨이 EntityManager 가
|
||||
리포지토리 호출 단위로 닫혀 지연 로딩에서 `LazyInitializationException` 이 난다
|
||||
(예: `ApiServiceService` — `ApiGroup.apiGroupApiList`).
|
||||
- 두 DB 를 한 원자 단위로 묶어야 하는 작업은 만들지 않는다. 현재 Gateway 는 조회 전용이다.
|
||||
|
||||
### 에러 처리
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
// 보안 검사 통합 파이프라인 — OWASP Dependency-Check(SCA) + SonarQube(SAST).
|
||||
//
|
||||
// 두 검사는 보는 대상이 다르다. 하나만 돌리면 절반만 본다.
|
||||
// SonarQube : 우리가 쓴 코드의 결함 (SQL Injection, XSS, 하드코딩 시크릿 …)
|
||||
// Dependency-Check : 우리가 쓰는 라이브러리의 알려진 취약점 (CVE)
|
||||
//
|
||||
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록만 고치면 된다)
|
||||
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
|
||||
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (없으면 PATH 탐색)
|
||||
// 3) 노드에 Dependency-Check CLI 설치 (DC_HOME) — 설치 방법은 17-보안 가이드 문서 참고
|
||||
// 4) NVD API Key 를 Secret text credential 로 등록 (기본 ID: nvd-api-key)
|
||||
// 키가 없으면 NVD 갱신이 극심하게 느려진다(수 시간). 폐쇄망은 UPDATE_NVD=false 로 운영.
|
||||
// 5) Quality Gate 대기를 쓰려면 SonarQube 에 <JENKINS_URL>/sonarqube-webhook/ 웹훅 등록
|
||||
//
|
||||
// JDK 분리: 빌드는 JDK 8, 스캐너/Dependency-Check 실행은 JDK 17.
|
||||
// (SonarScanner CLI 5.x 는 JRE 17 필수, Dependency-Check 9.x 이상은 JRE 11 이상 필수)
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
triggers {
|
||||
// 코드 변경 감지
|
||||
pollSCM('H/30 * * * *')
|
||||
// 코드가 그대로여도 새 CVE 는 계속 공개된다 → 야간 정기 재검사
|
||||
cron('H 3 * * *')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_DEPENDENCY_CHECK',
|
||||
defaultValue: true,
|
||||
description: 'OWASP Dependency-Check(SCA) 실행'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'RUN_SONAR',
|
||||
defaultValue: true,
|
||||
description: 'SonarQube 정적 분석(SAST) 실행'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'RUN_TESTS',
|
||||
defaultValue: false,
|
||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'UPDATE_NVD',
|
||||
defaultValue: true,
|
||||
description: 'NVD 취약점 DB 갱신. 폐쇄망(외부 인터넷 불가)이면 반드시 끈다 — 캐시된 DB 로만 검사한다.'
|
||||
)
|
||||
string(
|
||||
name: 'FAIL_ON_CVSS',
|
||||
defaultValue: '11',
|
||||
description: '이 CVSS 점수 이상이면 검사 실패로 표시. 11 = 실패시키지 않음(리포트만). 예: 7 = High 이상'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'FAIL_BUILD_ON_FINDING',
|
||||
defaultValue: false,
|
||||
description: 'FAIL_ON_CVSS 위반 시 빌드를 FAILURE 로 만든다. 끄면 UNSTABLE 로만 표시.'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'SCAN_JS',
|
||||
defaultValue: true,
|
||||
description: '정적 JS 라이브러리(static/js, static/plugins)도 RetireJS 로 검사. 폐쇄망에서 오류나면 끈다.'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
|
||||
SONAR_ENV_NAME = 'SonarQube-Community'
|
||||
SONAR_SCANNER_TOOL = ''
|
||||
// NVD API Key 를 담은 Secret text credential ID. 없으면 키 없이 진행한다(느림).
|
||||
NVD_API_KEY_CRED = 'nvd-api-key'
|
||||
|
||||
// --- 툴체인 ---
|
||||
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
|
||||
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner / dependency-check 실행용
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
|
||||
// --- Dependency-Check ---
|
||||
// CLI 설치 경로. 비어 있거나 없으면 Jenkins Tools 등록분 > PATH 순으로 탐색한다.
|
||||
DC_HOME = '/apps/opts/dependency-check'
|
||||
// NVD 캐시(H2 DB). 워크스페이스 밖에 두어야 빌드마다 1GB 이상을 다시 받지 않는다.
|
||||
// 여러 Job 이 공유하므로 disableConcurrentBuilds 를 켠 채로 쓴다.
|
||||
DC_DATA = '/apps/opts/dependency-check-data'
|
||||
DC_TOOL_NAME = ''
|
||||
DC_REPORT_DIR = 'build/reports/dependency-check'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
|
||||
// 이 둘이 없으면 컴파일도 의존성 해석도 불가능하다.
|
||||
stage('Checkout dependencies') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
cd "$WORKSPACE/.."
|
||||
|
||||
if [ ! -d elink-portal-common/.git ]; then
|
||||
rm -rf elink-portal-common
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||
elink-portal-common
|
||||
else
|
||||
git -C elink-portal-common fetch --depth=1 origin master
|
||||
git -C elink-portal-common reset --hard origin/master
|
||||
git -C elink-portal-common clean -fdx
|
||||
fi
|
||||
|
||||
mkdir -p eapim-online
|
||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||
rm -rf eapim-online/elink-online-core-jpa
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||
eapim-online/elink-online-core-jpa
|
||||
else
|
||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
echo "--- build JDK ---"
|
||||
java -version
|
||||
gradle --version
|
||||
|
||||
echo "--- scanner/dependency-check JDK ---"
|
||||
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
|
||||
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
|
||||
echo "SonarScanner 는 JRE 17, Dependency-Check 는 JRE 11 이상을 요구한다."
|
||||
exit 1
|
||||
fi
|
||||
"$JAVA_HOME_SCANNER/bin/java" -version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
|
||||
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
|
||||
// 생성 소스 디렉터리는 별도로 먼저 지운다.
|
||||
stage('Compile') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
rm -rf build/generated build/classes
|
||||
gradle clean classes testClasses --no-daemon
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when { expression { return params.RUN_TESTS } }
|
||||
steps {
|
||||
sh 'gradle test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모은다.
|
||||
// 이게 없으면 Dependency-Check 가 스캔할 jar 가 없어 "취약점 0건" 이라는 거짓 안심을 준다.
|
||||
stage('Export dependency jars') {
|
||||
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||
steps {
|
||||
sh 'gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon'
|
||||
sh '''
|
||||
set -eu
|
||||
COUNT=$(find build/dependency-check/libs -name '*.jar' -type f | wc -l | tr -d ' ')
|
||||
echo "scan target jars: $COUNT"
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "수집된 jar 가 0개다. 의존성 해석이 실패했는지 확인할 것."
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('OWASP Dependency-Check') {
|
||||
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||
steps {
|
||||
script {
|
||||
// CLI 위치 결정: DC_HOME > Jenkins Tools 등록분 > PATH
|
||||
def dcHome = null
|
||||
if (env.DC_HOME?.trim() && fileExists("${env.DC_HOME}/bin/dependency-check.sh")) {
|
||||
dcHome = env.DC_HOME.trim()
|
||||
echo "Dependency-Check: ${dcHome} (DC_HOME)"
|
||||
} else {
|
||||
def candidates = env.DC_TOOL_NAME?.trim()
|
||||
? [env.DC_TOOL_NAME.trim()]
|
||||
: ['dependency-check', 'Dependency-Check', 'OWASP Dependency-Check', 'dependency-check-cli']
|
||||
for (name in candidates) {
|
||||
try {
|
||||
dcHome = tool name: name,
|
||||
type: 'org.jenkinsci.plugins.DependencyCheck.tools.DependencyCheckInstallation'
|
||||
echo "Dependency-Check tool: '${name}' -> ${dcHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
if (dcHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 Dependency-Check 가 없다. PATH 의 dependency-check.sh 로 진행한다.'
|
||||
}
|
||||
}
|
||||
|
||||
// NVD API Key credential 이 있는지 먼저 확인한다.
|
||||
// (없는 credential 로 withCredentials 를 감싸면 파이프라인 자체가 죽는다)
|
||||
def hasNvdKey = true
|
||||
try {
|
||||
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_PROBE')]) {
|
||||
// 존재 확인만 한다
|
||||
}
|
||||
} catch (ignored) {
|
||||
hasNvdKey = false
|
||||
}
|
||||
|
||||
if (!hasNvdKey) {
|
||||
echo "NVD API Key credential('${env.NVD_API_KEY_CRED}') 이 없다. 키 없이 진행한다."
|
||||
echo 'NVD 갱신이 매우 느려진다(수 시간). https://nvd.nist.gov/developers/request-an-api-key 에서 발급 권장.'
|
||||
}
|
||||
|
||||
def dcScript = '''
|
||||
set -eu
|
||||
|
||||
# Dependency-Check 는 JRE 11 이상 필요 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
mkdir -p "$DC_DATA" "$DC_REPORT_DIR"
|
||||
|
||||
if [ -n "${DC_RESOLVED_HOME:-}" ] && [ -x "$DC_RESOLVED_HOME/bin/dependency-check.sh" ]; then
|
||||
DC="$DC_RESOLVED_HOME/bin/dependency-check.sh"
|
||||
elif command -v dependency-check.sh >/dev/null 2>&1; then
|
||||
DC="$(command -v dependency-check.sh)"
|
||||
else
|
||||
echo "dependency-check.sh 를 찾지 못했다."
|
||||
echo "노드에 CLI 를 설치하고 DC_HOME 을 맞추거나, Manage Jenkins > Tools 에 등록할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "dependency-check: $DC"
|
||||
|
||||
ARGS=""
|
||||
|
||||
# 폐쇄망/오프라인: 캐시된 NVD DB 로만 검사한다.
|
||||
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||
ARGS="$ARGS --noupdate"
|
||||
if [ ! -d "$DC_DATA" ] || [ -z "$(ls -A "$DC_DATA" 2>/dev/null)" ]; then
|
||||
echo "NVD 캐시가 비어 있는데 갱신이 꺼져 있다: $DC_DATA"
|
||||
echo "인터넷 되는 곳에서 1회 적재한 data 디렉터리를 복사해 둘 것."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${NVD_API_KEY:-}" ]; then
|
||||
ARGS="$ARGS --nvdApiKey ${NVD_API_KEY}"
|
||||
fi
|
||||
|
||||
# OSS Index 분석기는 외부 서비스(ossindex.sonatype.org)를 호출한다.
|
||||
# 갱신을 끈 환경(=외부 통신 불가)에서는 같이 끈다.
|
||||
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||
ARGS="$ARGS --disableOssIndex"
|
||||
fi
|
||||
|
||||
# 자바 프로젝트에 불필요한 분석기 — 실행 시간만 늘린다.
|
||||
ARGS="$ARGS --disableAssembly --disableNodeAudit --disableNodeJS"
|
||||
|
||||
SCAN_ARGS="--scan build/dependency-check/libs"
|
||||
if [ "${SCAN_JS_FLAG}" = "true" ]; then
|
||||
# 번들된 JS 라이브러리(jQuery, summernote 등)의 알려진 취약점 — RetireJS
|
||||
[ -d src/main/resources/static/js ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/js"
|
||||
[ -d src/main/resources/static/plugins ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/plugins"
|
||||
else
|
||||
ARGS="$ARGS --disableRetireJS"
|
||||
fi
|
||||
|
||||
SUPPRESSION=""
|
||||
if [ -f ci/dependency-check-suppressions.xml ]; then
|
||||
SUPPRESSION="--suppression ci/dependency-check-suppressions.xml"
|
||||
fi
|
||||
|
||||
# 토큰/키가 콘솔에 남지 않도록 실행 명령 자체는 출력하지 않는다.
|
||||
set +x
|
||||
"$DC" \
|
||||
--project "DJB eAPIM Portal" \
|
||||
$SCAN_ARGS \
|
||||
--out "$DC_REPORT_DIR" \
|
||||
--format HTML --format XML --format JSON \
|
||||
--data "$DC_DATA" \
|
||||
--failOnCVSS "${FAIL_ON_CVSS_VALUE}" \
|
||||
$SUPPRESSION \
|
||||
$ARGS
|
||||
'''
|
||||
|
||||
def dcEnv = [
|
||||
"DC_RESOLVED_HOME=${dcHome ?: ''}",
|
||||
"UPDATE_NVD_FLAG=${params.UPDATE_NVD}",
|
||||
"SCAN_JS_FLAG=${params.SCAN_JS}",
|
||||
"FAIL_ON_CVSS_VALUE=${params.FAIL_ON_CVSS}"
|
||||
]
|
||||
|
||||
// 정책: 기본은 UNSTABLE 로만 표시하고 뒤의 Sonar 분석까지 마친다.
|
||||
// FAIL_BUILD_ON_FINDING 을 켜면 임계치 위반이 빌드 실패가 된다.
|
||||
def onFinding = params.FAIL_BUILD_ON_FINDING ? 'FAILURE' : 'UNSTABLE'
|
||||
catchError(buildResult: onFinding, stageResult: 'FAILURE') {
|
||||
withEnv(dcEnv) {
|
||||
if (hasNvdKey) {
|
||||
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_API_KEY')]) {
|
||||
sh dcScript
|
||||
}
|
||||
} else {
|
||||
sh dcScript
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true,
|
||||
artifacts: 'build/reports/dependency-check/dependency-check-report.*'
|
||||
script {
|
||||
// OWASP Dependency-Check 플러그인이 설치돼 있으면 추이 그래프/경고를 남긴다.
|
||||
// 없어도 리포트는 위에서 아카이브되므로 실패로 보지 않는다.
|
||||
try {
|
||||
dependencyCheckPublisher pattern: 'build/reports/dependency-check/dependency-check-report.xml'
|
||||
} catch (ignored) {
|
||||
echo 'OWASP Dependency-Check 플러그인 미설치 — 아카이브된 HTML 리포트로 확인할 것.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sonar.java.libraries 용 클래스패스 덤프.
|
||||
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
|
||||
stage('Export analysis classpath') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube analysis') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
script {
|
||||
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
|
||||
def candidates = env.SONAR_SCANNER_TOOL?.trim()
|
||||
? [env.SONAR_SCANNER_TOOL.trim()]
|
||||
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
|
||||
|
||||
def scannerHome = null
|
||||
for (name in candidates) {
|
||||
try {
|
||||
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
|
||||
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
|
||||
if (scannerHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
|
||||
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
|
||||
}
|
||||
|
||||
withSonarQubeEnv(env.SONAR_ENV_NAME) {
|
||||
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
|
||||
sh '''
|
||||
set -eu
|
||||
|
||||
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
|
||||
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
|
||||
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
LIBS=""
|
||||
if [ -f build/sonar/java-libraries.txt ]; then
|
||||
LIBS=$(cat build/sonar/java-libraries.txt)
|
||||
else
|
||||
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
|
||||
fi
|
||||
|
||||
TEST_LIBS=""
|
||||
if [ -f build/sonar/java-test-libraries.txt ]; then
|
||||
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
|
||||
fi
|
||||
|
||||
JUNIT_ARG=""
|
||||
if [ -d build/test-results/test ]; then
|
||||
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||
fi
|
||||
|
||||
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
|
||||
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
|
||||
DC_ARG=""
|
||||
if [ -f "$DC_REPORT_DIR/dependency-check-report.json" ]; then
|
||||
DC_ARG="-Dsonar.dependencyCheck.jsonReportPath=$DC_REPORT_DIR/dependency-check-report.json"
|
||||
if [ -f "$DC_REPORT_DIR/dependency-check-report.html" ]; then
|
||||
DC_ARG="$DC_ARG -Dsonar.dependencyCheck.htmlReportPath=$DC_REPORT_DIR/dependency-check-report.html"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
||||
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
||||
elif command -v sonar-scanner >/dev/null 2>&1; then
|
||||
SCANNER="$(command -v sonar-scanner)"
|
||||
else
|
||||
echo "sonar-scanner 실행 파일을 찾지 못했다."
|
||||
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "scanner: $SCANNER"
|
||||
|
||||
"$SCANNER" \
|
||||
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
||||
-Dsonar.java.libraries="$LIBS" \
|
||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||
$JUNIT_ARG \
|
||||
$DC_ARG
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
|
||||
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
|
||||
stage('Quality Gate') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
script {
|
||||
def qg = null
|
||||
try {
|
||||
timeout(time: 15, unit: 'MINUTES') {
|
||||
qg = waitForQualityGate abortPipeline: false
|
||||
}
|
||||
} catch (err) {
|
||||
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
|
||||
return
|
||||
}
|
||||
|
||||
if (qg == null) {
|
||||
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
|
||||
} else if (qg.status != 'OK') {
|
||||
unstable("Quality Gate ${qg.status}")
|
||||
} else {
|
||||
echo 'Quality Gate OK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
|
||||
script {
|
||||
def report = '.scannerwork/report-task.txt'
|
||||
if (fileExists(report)) {
|
||||
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
|
||||
if (url) {
|
||||
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
|
||||
}
|
||||
}
|
||||
if (fileExists('build/reports/dependency-check/dependency-check-report.html')) {
|
||||
echo "Dependency-Check 리포트: ${env.BUILD_URL}artifact/build/reports/dependency-check/dependency-check-report.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ dependencies {
|
||||
// implementation project(':kjb-safedb')
|
||||
|
||||
implementation('org.springframework.boot:spring-boot-starter')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
|
||||
implementation('org.springframework.boot:spring-boot-starter-web')
|
||||
implementation('org.springframework.boot:spring-boot-starter-validation')
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// OWASP Dependency-Check 가 스캔할 서드파티 jar 를 한 디렉터리로 모은다.
|
||||
//
|
||||
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
|
||||
// gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon
|
||||
//
|
||||
// 출력:
|
||||
// build/dependency-check/libs/*.jar runtimeClasspath 의 외부 의존 jar (원본 파일명 유지)
|
||||
// build/dependency-check/jars.txt 수집 목록(경로 1줄씩) — 진단용
|
||||
//
|
||||
// 왜 디렉터리로 모으는가
|
||||
// Dependency-Check CLI 는 --scan <경로> 로 파일/디렉터리를 받는다. Gradle 캐시를 통째로 스캔하면
|
||||
// 이 프로젝트가 쓰지 않는 버전까지 잡히고, 프로젝트 디렉터리만 스캔하면 의존 jar 가 아예 안 잡힌다.
|
||||
// 실제 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모아야 결과가 배포 산출물과 일치한다.
|
||||
//
|
||||
// 파일명을 유지해야 하는 이유
|
||||
// Dependency-Check 는 jar 안의 POM/MANIFEST 외에 파일명에서도 CPE(제품/버전)를 추론한다.
|
||||
// 이름을 바꾸면 탐지 정확도가 떨어진다.
|
||||
|
||||
rootProject { project ->
|
||||
project.plugins.withId('java') {
|
||||
project.tasks.register('exportDependencyJars') {
|
||||
group = 'verification'
|
||||
description = 'Dependency-Check 스캔용 런타임 의존 jar 를 build/dependency-check/libs 에 모은다'
|
||||
|
||||
doLast {
|
||||
def outRoot = new File(project.layout.buildDirectory.get().asFile, 'dependency-check')
|
||||
def outDir = new File(outRoot, 'libs')
|
||||
project.delete(outDir)
|
||||
outDir.mkdirs()
|
||||
|
||||
// 컴포지트/서브프로젝트의 build 디렉터리 산출물(우리가 만든 jar)은 제외한다.
|
||||
// 자체 코드는 SonarQube 가 보는 영역이고, SCA 대상은 외부 라이브러리다.
|
||||
def buildDirs = project.allprojects.collect {
|
||||
it.layout.buildDirectory.get().asFile.absolutePath
|
||||
}
|
||||
def isOwnArtifact = { File f ->
|
||||
buildDirs.any { f.absolutePath.startsWith(it + File.separator) }
|
||||
}
|
||||
|
||||
def jars = project.configurations.runtimeClasspath.files
|
||||
.findAll { it.isFile() && it.name.endsWith('.jar') && !isOwnArtifact(it) }
|
||||
.unique()
|
||||
.sort { it.name }
|
||||
|
||||
project.copy {
|
||||
from jars
|
||||
into outDir
|
||||
}
|
||||
|
||||
new File(outRoot, 'jars.txt').text =
|
||||
jars.collect { it.absolutePath }.join(System.lineSeparator()) + System.lineSeparator()
|
||||
|
||||
def totalMb = (jars.sum { it.length() } ?: 0L) / (1024 * 1024)
|
||||
logger.lifecycle("dependency-check scan target: ${jars.size()} jars, ${totalMb as int} MB -> ${outDir}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
OWASP Dependency-Check 오탐(false positive) 억제 목록.
|
||||
|
||||
규칙
|
||||
1. 억제는 "오탐"에만 쓴다. 진짜 취약점을 조용히 숨기는 용도로 쓰지 않는다.
|
||||
실제 취약하지만 당장 못 올리는 경우는 억제 대신 만료일(until)을 넣어 재검토를 강제한다.
|
||||
2. 항목마다 <notes> 에 판단 근거와 판단자/일자를 남긴다. 근거 없는 억제는 리뷰에서 거절한다.
|
||||
3. 범위를 좁게 잡는다. cve 단건 + 특정 파일(sha1/packageUrl)이 기본이고,
|
||||
cpe 나 정규식 filePath 로 넓게 억제하지 않는다.
|
||||
|
||||
작성법
|
||||
Dependency-Check HTML 리포트의 각 취약점 옆 "Suppress" 버튼을 누르면 해당 항목의
|
||||
<suppress> 블록이 그대로 생성된다. 그것을 이 파일에 붙여넣고 <notes> 만 채우면 된다.
|
||||
|
||||
참고: https://dependency-check.github.io/DependencyCheck/general/suppression.html
|
||||
-->
|
||||
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
|
||||
|
||||
<!-- 예시 (실제 억제 시 주석을 풀고 값 교체)
|
||||
<suppress until="2026-12-31Z">
|
||||
<notes><![CDATA[
|
||||
오탐 근거: 해당 CVE 는 X 기능을 사용할 때만 성립하는데 이 앱은 해당 API 를 호출하지 않음.
|
||||
확인: 홍길동 / 2026-08-13 / 호출부 grep 결과 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.example/example-lib@.*$</packageUrl>
|
||||
<cve>CVE-2026-00000</cve>
|
||||
</suppress>
|
||||
-->
|
||||
|
||||
</suppressions>
|
||||
@@ -51,6 +51,14 @@ sonar.exclusions=\
|
||||
|
||||
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
|
||||
|
||||
# Thymeleaf 인라인 표현식([[${...}]], /*[[${...}]]*/)은 JS 파서가 읽지 못해
|
||||
# "Failed to parse file ... Unexpected token" 으로 분석이 중단된다.
|
||||
# JS 분석에서만 빼고 HTML(web) 센서는 그대로 두어 템플릿 XSS 룰은 유지한다.
|
||||
# 기본값이 **/node_modules/** 이므로 재정의 시 함께 명시해야 한다.
|
||||
sonar.javascript.exclusions=\
|
||||
**/node_modules/**,\
|
||||
src/main/resources/templates/**
|
||||
|
||||
# --- SCM -------------------------------------------------------------------
|
||||
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
|
||||
sonar.scm.provider=git
|
||||
|
||||
+12
-1
@@ -22,8 +22,19 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* API 그룹(전시) 조회 서비스.
|
||||
*
|
||||
* <p>주 조회 대상 {@link ApiGroup} 은 <b>AGW(게이트웨이) 데이터소스</b> 엔티티라
|
||||
* 트랜잭션 매니저를 {@code gatewayTransactionManager} 로 지정한다. 기본
|
||||
* {@code transactionManager}(EMS) 를 쓰면 게이트웨이 EntityManager 가 리포지토리 호출 단위로
|
||||
* 닫혀 {@code ApiGroup.apiGroupApiList} 지연 로딩에서 LazyInitializationException 이 난다.</p>
|
||||
*
|
||||
* <p>내부에서 호출하는 {@code ApiSpecInfoService} 는 EMS 쪽이며 자체 {@code @Transactional}
|
||||
* (기본 매니저)로 독립 트랜잭션을 연다. {@code ApiSpecInfo} 에는 지연 연관이 없어 문제되지 않는다.</p>
|
||||
*/
|
||||
@Service("apiServiceService")
|
||||
@Transactional
|
||||
@Transactional("gatewayTransactionManager")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiServiceService {
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AdminGatewayClient {
|
||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||
|
||||
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
||||
|
||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* base URL 끝의 {@code '/'} 를 모두 걷어낸다.
|
||||
*
|
||||
* <p>{@code replaceAll("/+$", "")} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||
* 선형 스캔으로 대체했다. 동작은 동일하다.</p>
|
||||
*/
|
||||
private static String stripTrailingSlashes(String url) {
|
||||
int end = url.length();
|
||||
while (end > 0 && url.charAt(end - 1) == '/') {
|
||||
end--;
|
||||
}
|
||||
return url.substring(0, end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.apps.auth;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@@ -12,10 +10,18 @@ import org.springframework.stereotype.Component;
|
||||
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
||||
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
||||
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
||||
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||
* {@link com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties} 와 동일한
|
||||
* {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||
*
|
||||
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
||||
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
||||
* <p><b>운영(prod)에서도 DB 값만으로 켤 수 있다.</b> 대신 종료일
|
||||
* {@code auth.test-notice.prod-until}({@code yyyy-MM-dd})을 함께 지정해야 하며, 날짜가 지나면
|
||||
* {@link TestNoticeWindow} 가 자동으로 닫는다. 종료일 미설정/형식 오류면 운영에서는 열리지 않는다.
|
||||
* 운영 오픈 전 UMS 실발송 미연계 기간을 위한 한시 스위치다.</p>
|
||||
*
|
||||
* <p>이 판정은 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice} 의
|
||||
* {@code @ModelAttribute} 에서 <b>모든 페이지 요청마다</b> 호출된다. PortalPropertyService 에 캐시가
|
||||
* 없어 호출마다 DB 조회가 발생하므로 {@value #CACHE_TTL_MILLIS}ms 짧은 캐시를 둔다.
|
||||
* PTL_PROPERTY 를 바꾸면 최대 그 시간만큼 반영이 늦다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,21 +29,45 @@ public class AuthNoticeProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "auth.test-notice.prod-until";
|
||||
|
||||
/** 판정 결과 캐시 유효시간(ms) */
|
||||
static final long CACHE_TTL_MILLIS = 30_000L;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final Environment environment;
|
||||
private final TestNoticeWindow testNoticeWindow;
|
||||
|
||||
private volatile boolean cachedEnabled;
|
||||
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
|
||||
private volatile long cachedAt;
|
||||
|
||||
/**
|
||||
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false.
|
||||
* 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
long now = System.currentTimeMillis();
|
||||
long at = cachedAt;
|
||||
if (at != 0L && now - at < CACHE_TTL_MILLIS) {
|
||||
return cachedEnabled;
|
||||
}
|
||||
boolean enabled = resolveEnabled();
|
||||
cachedEnabled = enabled;
|
||||
cachedAt = now;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private boolean resolveEnabled() {
|
||||
// 기본값 false - row 가 없는 환경(신규 운영 DB 등)에서 자동으로 켜지지 않도록 한다.
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "false",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
if (value == null || !"true".equalsIgnoreCase(value.trim())) {
|
||||
return false;
|
||||
}
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
String until = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||
"운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.eactive.apim.portal.apps.auth;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 인증번호 화면 노출(테스트 안내) 스위치의 <b>운영 환경 사용 기한</b> 판정기.
|
||||
*
|
||||
* <p>운영 오픈 전에는 UMS/EAI 실발송 연계가 끝나지 않아 인증번호가 실제로 도달하지 않을 수 있다.
|
||||
* 이 기간에는 prod 에서도 인증번호를 화면에 노출해야 테스트가 가능하다. 다만 스위치 끄기를 잊으면
|
||||
* 인증번호가 상시 노출되므로, prod 에서는 <b>종료일을 명시한 경우에만</b> 열어 주고 그 날짜가 지나면
|
||||
* PTL_PROPERTY 값과 무관하게 자동으로 닫는다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>비운영(!prod) — 항상 열림. 종료일을 보지 않는다.</li>
|
||||
* <li>운영(prod) — 종료일이 없거나 형식이 잘못되면 <b>닫힘</b>(fail-safe). 종료일 당일까지 열림.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>이메일/휴대폰 인증({@link AuthNoticeProperties})과 2차 인증
|
||||
* ({@code TwoFactorProperties})이 같은 규칙을 쓰도록 판정을 이 클래스로 모은다.
|
||||
* 종료일 프로퍼티 값은 {@code yyyy-MM-dd} 형식이며, 미설정을 뜻하는 기본값은 {@value #UNSET}
|
||||
* ({@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 빈 문자열을 기본값으로 쓸 수 없다).</p>
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TestNoticeWindow {
|
||||
|
||||
/** 종료일 미설정을 뜻하는 기본값. prod 에서는 이 값이면 닫힘 */
|
||||
public static final String UNSET = "none";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public TestNoticeWindow(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param untilValue 종료일 문자열({@code yyyy-MM-dd}). PTL_PROPERTY 값 원본
|
||||
* @param subject 로그 식별용 스위치 이름
|
||||
* @return 지금 인증번호를 화면에 노출해도 되는 기간인지 여부
|
||||
*/
|
||||
public boolean isOpen(String untilValue, String subject) {
|
||||
if (!environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
LocalDate until = parseDate(untilValue);
|
||||
if (until == null) {
|
||||
return false;
|
||||
}
|
||||
if (LocalDate.now().isAfter(until)) {
|
||||
return false;
|
||||
}
|
||||
// 운영에서 인증번호가 화면에 노출되는 상태 - 흔적을 남긴다.
|
||||
log.warn("운영 환경 인증번호 노출 활성 상태 - {} (종료일 {})", subject, until);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("인증번호 노출 종료일 형식 오류(yyyy-MM-dd 필요) - {}", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.TestNoticeWindow;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -25,9 +26,11 @@ public class TwoFactorProperties {
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "two-factor.test-notice.prod-until";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final TestNoticeWindow testNoticeWindow;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
@@ -91,9 +94,21 @@ public class TwoFactorProperties {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
/**
|
||||
* 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용).
|
||||
*
|
||||
* <p>운영(prod)에서는 종료일 {@link #KEY_TEST_NOTICE_PROD_UNTIL}({@code yyyy-MM-dd})을 함께
|
||||
* 지정한 경우에만 참이 되고, 날짜가 지나면 {@link TestNoticeWindow} 가 자동으로 닫는다.
|
||||
* 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. 이메일/휴대폰 인증
|
||||
* ({@code auth.test-notice.*})과 동일한 규칙이다.</p>
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
if (!parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"))) {
|
||||
return false;
|
||||
}
|
||||
String until = resolve(KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||
"운영(prod)에서 2차 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
*
|
||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
||||
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
||||
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
||||
* 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
||||
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
||||
"PTL_USER", "ptl_faq", "ptl_org",
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
||||
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
||||
PortalPropertyService portalPropertyService) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
this.portalPropertyService = portalPropertyService;
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@Transactional("transactionManager")
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertAllowedIp(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
if (includeAudit) {
|
||||
for (String table : AUDIT_TABLES) {
|
||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
int totalSkipped = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
totalSkipped += (int) r.get("skipped");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("damoMode", resolveDamoMode());
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
||||
response.put("totalSkipped", totalSkipped);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
||||
return response;
|
||||
}
|
||||
|
||||
private String resolveDamoMode() {
|
||||
if (converter.isBypassMode()) {
|
||||
return "BYPASS";
|
||||
}
|
||||
return converter.isFakeMode() ? "FAKE" : "REAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("table", table);
|
||||
r.put("column", column);
|
||||
|
||||
List<String> values;
|
||||
try {
|
||||
values = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
||||
String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("skipped", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
int skipped = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
||||
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
||||
skipped++;
|
||||
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
||||
table, column, value.length());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
||||
normalized, value);
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
r.put("skipped", skipped);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
||||
*/
|
||||
private void assertNotBypass() {
|
||||
if (converter.isBypassMode()) {
|
||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
||||
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
||||
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
||||
*/
|
||||
private void assertAllowedIp(HttpServletRequest request) {
|
||||
String remote = canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
|
||||
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
||||
.map(String::trim)
|
||||
.filter(ip -> !ip.isEmpty())
|
||||
.map(LegacyEncryptionMigrationController::canonicalize)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (viaProxy || !allowed.contains(remote)) {
|
||||
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
||||
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
|
||||
/** IPv6 loopback 표기 통일 */
|
||||
private static String canonicalize(String ip) {
|
||||
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
TargetTable(String table, List<String> columns) {
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import javax.naming.NamingException;
|
||||
import javax.sql.DataSource;
|
||||
@@ -58,11 +59,11 @@ public class BaseDatasourceConfiguration {
|
||||
//public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고
|
||||
properties.put("hibernate.dialect", prop.getHibernateDialect());
|
||||
properties.put("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION");
|
||||
properties.put("hibernate.transaction.jta.platform", "org.hibernate.engine.transaction.jta.platform.internal.AtomikosJtaPlatform");
|
||||
properties.put("hibernate.physical_naming_strategy", prop.getHibernatePhysicalNamingStrategy());
|
||||
properties.put("hibernate.default_schema", prop.getSchema());
|
||||
properties.put("javax.persistence.validation.mode", "none");
|
||||
properties.put("javax.persistence.transactionType", "JTA");
|
||||
// JTA/XA 미사용. EMF 별 로컬 트랜잭션(JpaTransactionManager)으로 처리한다 - PortalConfigTransaction 참고
|
||||
properties.put("javax.persistence.transactionType", "RESOURCE_LOCAL");
|
||||
properties.put("hibernate.format_sql", "true");
|
||||
|
||||
if (prop instanceof EmsDatasourceProperty) {
|
||||
@@ -110,9 +111,22 @@ public class BaseDatasourceConfiguration {
|
||||
return builder
|
||||
.dataSource(dataSource)
|
||||
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
||||
.packages(prop.getEntityPackage().split("\\s*,\\s*"))
|
||||
.packages(splitPackages(prop.getEntityPackage()))
|
||||
.persistenceUnit(persistenceUnit)
|
||||
.properties(properties)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마로 나열된 엔티티 패키지를 분리한다. 앞뒤 공백은 걷어내고 빈 토큰은 버린다.
|
||||
*
|
||||
* <p>구분자 정규식 {@code "\\s*,\\s*"} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||
* 단순 분리 + trim 으로 대체했다.</p>
|
||||
*/
|
||||
private static String[] splitPackages(String entityPackage) {
|
||||
return Arrays.stream(entityPackage.split(","))
|
||||
.map(String::trim)
|
||||
.filter(pkg -> !pkg.isEmpty())
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
@EntityScan(basePackages = {"com.eactive.apim.gateway"})
|
||||
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class, entityManagerFactoryRef = "gatewayEntityManagerFactory")
|
||||
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class,
|
||||
entityManagerFactoryRef = "gatewayEntityManagerFactory", transactionManagerRef = "gatewayTransactionManager")
|
||||
@Slf4j
|
||||
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
|
||||
private final Environment env;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 내부 API 공유 토큰 초기화 — 포탈 기동 시 1회.
|
||||
*
|
||||
* <p>PTL_PROPERTY {@code Portal / internal.api.header-name}, {@code internal.api.token} 이 없으면
|
||||
* 이 시점에 생성된다(토큰은 SecureRandom 난수). eapim-admin 은 이 값을 <b>읽기만</b> 하므로,
|
||||
* 토큰을 처음 만드는 주체는 포탈 한 곳으로 고정된다.</p>
|
||||
*
|
||||
* <p>{@link ApplicationReadyEvent} 를 쓰는 이유: 데이터소스/JTA 초기화가 끝난 뒤여야 프로퍼티를 저장할 수 있다.
|
||||
* WAR 배포(WebLogic)에서도 동일하게 발생한다.</p>
|
||||
*
|
||||
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class InternalApiTokenInitializer {
|
||||
|
||||
private final InternalApiTokenService internalApiTokenService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initialize() {
|
||||
internalApiTokenService.initialize();
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,13 @@ import org.springframework.web.filter.ForwardedHeaderFilter;
|
||||
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
||||
* </pre>
|
||||
*
|
||||
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
|
||||
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
|
||||
* 검사되므로 기존 가드가 설계대로 동작한다.
|
||||
* 즉 {@code MenuInternalController#isAllowedIp} 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다.
|
||||
* 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로 검사되므로 기존 가드가 설계대로 동작한다.
|
||||
*
|
||||
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
||||
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
||||
*
|
||||
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
|
||||
@@ -107,8 +107,12 @@ public class PortalConfigSecurity {
|
||||
.logoutSuccessHandler(logoutSuccessHandler))
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
// /_proxy 는 대응 핸들러가 없어 예외를 해제했다. 경로가 부활하면 아래를 되살릴 것.
|
||||
// .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
// /internal/migration 은 LegacyEncryptionMigrationController 제거와 함께 삭제됨.
|
||||
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
||||
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
||||
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.atomikos.icatch.jta.UserTransactionImp;
|
||||
import com.atomikos.icatch.jta.UserTransactionManager;
|
||||
import javax.persistence.EntityManagerFactory;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import org.springframework.transaction.jta.JtaTransactionManager;
|
||||
|
||||
import javax.transaction.SystemException;
|
||||
import javax.transaction.UserTransaction;
|
||||
|
||||
/**
|
||||
* 트랜잭션 매니저 설정.
|
||||
*
|
||||
* <p>EMS(포털)·AGW(게이트웨이) 두 데이터소스를 쓰지만 <b>JTA/XA 는 사용하지 않는다</b>.
|
||||
* 두 DB 를 한 트랜잭션으로 묶어 쓰는 지점이 없고(게이트웨이 측은 조회 전용),
|
||||
* 이전 Atomikos 구성도 {@code AtomikosDataSourceBean} 없이 JNDI/Hikari 데이터소스를 그대로 넘겨
|
||||
* XA 리소스가 하나도 enlist 되지 않는 형태였다. 즉 2PC 는 실제로 동작한 적이 없고
|
||||
* {@code com.atomikos.icatch.max_actives} 상한(기본 50)만 병목으로 남았다.</p>
|
||||
*
|
||||
* <p>따라서 EntityManagerFactory 별로 로컬 {@link JpaTransactionManager} 를 둔다.
|
||||
* 게이트웨이 리포지토리는 {@code GatewayDatasourceConfiguration} 의
|
||||
* {@code transactionManagerRef = "gatewayTransactionManager"} 로 연결된다.</p>
|
||||
*/
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
public class PortalConfigTransaction {
|
||||
|
||||
@Bean(name = "userTransaction")
|
||||
public UserTransaction userTransaction() throws SystemException {
|
||||
UserTransactionImp userTransactionImp = new UserTransactionImp();
|
||||
userTransactionImp.setTransactionTimeout(30000);
|
||||
return userTransactionImp;
|
||||
}
|
||||
|
||||
@Bean(name = "atomikosTransactionManager", initMethod = "init", destroyMethod = "close")
|
||||
public UserTransactionManager atomikosTransactionManager() {
|
||||
UserTransactionManager userTransactionManager = new UserTransactionManager();
|
||||
userTransactionManager.setForceShutdown(false);
|
||||
return userTransactionManager;
|
||||
}
|
||||
|
||||
/** EMS(포털) 기본 트랜잭션 매니저. {@code @Transactional} 무지정 시 이 매니저가 쓰인다. */
|
||||
@Primary
|
||||
@Bean(name = "transactionManager")
|
||||
@DependsOn({"userTransaction", "atomikosTransactionManager"})
|
||||
public PlatformTransactionManager transactionManager(UserTransactionManager atomikosTransactionManager) throws SystemException {
|
||||
UserTransaction userTransaction = userTransaction();
|
||||
return new JtaTransactionManager(userTransaction, atomikosTransactionManager);
|
||||
public PlatformTransactionManager transactionManager(
|
||||
@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
|
||||
return new JpaTransactionManager(entityManagerFactory);
|
||||
}
|
||||
|
||||
/** AGW(게이트웨이) 트랜잭션 매니저. {@code @Transactional("gatewayTransactionManager")} 로 지정해 쓴다. */
|
||||
@Bean(name = "gatewayTransactionManager")
|
||||
public PlatformTransactionManager gatewayTransactionManager(
|
||||
@Qualifier("gatewayEntityManagerFactory") EntityManagerFactory gatewayEntityManagerFactory) {
|
||||
return new JpaTransactionManager(gatewayEntityManagerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -19,14 +20,20 @@ import java.util.Map;
|
||||
/**
|
||||
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||
*
|
||||
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
||||
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
||||
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
||||
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
||||
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
|
||||
* <p>가드는 두 겹이다.</p>
|
||||
* <ol>
|
||||
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}. 헤더명/토큰은 PTL_PROPERTY
|
||||
* {@code Portal / internal.api.header-name}, {@code internal.api.token} 으로 관리하며
|
||||
* 토큰은 포탈 최초 기동 시 자동 생성된다. 이 경로는 CSRF 예외 대상
|
||||
* (PortalConfigSecurity {@code /internal/menu/**})이라, 커스텀 헤더 요구가 CSRF 를 대신한다 —
|
||||
* 브라우저의 cross-site form POST 는 커스텀 헤더를 붙일 수 없다.</li>
|
||||
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / menu.internal.allow-ips}(기본 loopback)
|
||||
* + X-Forwarded-For 동반 요청 거부. 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
|
||||
* <pre>curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||
* http://127.0.0.1:39130/internal/menu/reload</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -43,14 +50,18 @@ public class MenuInternalController {
|
||||
|
||||
private final MenuService menuService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final InternalApiTokenService internalApiTokenService;
|
||||
|
||||
@PostMapping("/reload")
|
||||
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
||||
if (!isAllowed(request)) {
|
||||
Map<String, Object> denied = new LinkedHashMap<>();
|
||||
denied.put("result", "DENIED");
|
||||
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
|
||||
if (!isAllowedIp(request)) {
|
||||
return denied(HttpStatus.FORBIDDEN,
|
||||
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||
}
|
||||
if (!hasValidToken(request)) {
|
||||
return denied(HttpStatus.UNAUTHORIZED,
|
||||
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
|
||||
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
|
||||
}
|
||||
|
||||
MenuService.MenuSnapshot snapshot = menuService.reload();
|
||||
@@ -63,12 +74,31 @@ public class MenuInternalController {
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
|
||||
Map<String, Object> denied = new LinkedHashMap<>();
|
||||
denied.put("result", "DENIED");
|
||||
denied.put("message", message);
|
||||
return ResponseEntity.status(status).body(denied);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
|
||||
*/
|
||||
private boolean hasValidToken(HttpServletRequest request) {
|
||||
String headerName = internalApiTokenService.ensureHeaderName();
|
||||
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
|
||||
log.warn("메뉴 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
||||
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
||||
*/
|
||||
private boolean isAllowed(HttpServletRequest request) {
|
||||
private boolean isAllowedIp(HttpServletRequest request) {
|
||||
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
|
||||
@@ -58,13 +58,7 @@ spring:
|
||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: false
|
||||
atomikos:
|
||||
properties:
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-name: adp_tx
|
||||
# JTA/Atomikos 제거됨. EMS·AGW 각각 로컬 트랜잭션(JpaTransactionManager) 사용 - PortalConfigTransaction 참고
|
||||
|
||||
thymeleaf:
|
||||
prefix: 'classpath:/templates/views/'
|
||||
@@ -125,7 +119,7 @@ portal:
|
||||
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
|
||||
|
||||
logging:
|
||||
log-path: /logs/eapim
|
||||
log-path: /logs/prod/eapim
|
||||
|
||||
# 내부 사용자(운영자) 판별 설정
|
||||
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# application.yml ? ??
|
||||
# com.atomikos.icatch.log_base_dir=/Log/eapim/portal/
|
||||
# com.atomikos.icatch.log_base_name=adp_tx
|
||||
@@ -87,9 +87,6 @@
|
||||
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="FILE_HIBERNATE" />
|
||||
</logger>
|
||||
<logger name="com.atomikos" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="FILE_HIBERNATE" />
|
||||
</logger>
|
||||
|
||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
||||
<appender-ref ref="HTTP_SESSION" />
|
||||
|
||||
@@ -321,6 +321,14 @@ function insertImageAsDataUri(file, $editor) {
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식 기반 정리를 적용할 HTML 최대 길이(문자).
|
||||
* 아래 VML/네임스페이스 정리 정규식은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||
* 비정상적으로 큰 입력에서는 정규식 단계를 건너뛴다. data-uri 이미지를 포함한 일반적인
|
||||
* Office 붙여넣기는 이 한도 아래다.
|
||||
*/
|
||||
var MAX_REGEX_CLEAN_LENGTH = 500000;
|
||||
|
||||
/**
|
||||
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
|
||||
* - 불필요한 Office 속성 제거
|
||||
@@ -358,6 +366,14 @@ function cleanPastedHtml(html) {
|
||||
|
||||
// VML 태그 제거 (v:, o:, w: 등)
|
||||
var cleanedHtml = $temp.html();
|
||||
|
||||
// 한도 초과 시 정규식 정리를 건너뛴다 — DOM 정리(이미지 속성 제거)는 이미 끝났으므로
|
||||
// 붙여넣기 자체는 동작하고, Office 네임스페이스 잔여물만 남는다. (브라우저 멈춤 방지)
|
||||
if (cleanedHtml.length > MAX_REGEX_CLEAN_LENGTH) {
|
||||
console.warn('붙여넣기 HTML이 너무 커서 Office 태그 정리를 건너뜁니다. length=' + cleanedHtml.length);
|
||||
return cleanedHtml;
|
||||
}
|
||||
|
||||
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
||||
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
var undefined;
|
||||
|
||||
/** Used as the semantic version number. */
|
||||
var VERSION = '4.17.21';
|
||||
var VERSION = '4.18.1';
|
||||
|
||||
/** Used as the size to enable large array optimizations. */
|
||||
var LARGE_ARRAY_SIZE = 200;
|
||||
@@ -20,7 +20,8 @@
|
||||
/** Error message constants. */
|
||||
var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',
|
||||
FUNC_ERROR_TEXT = 'Expected a function',
|
||||
INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`';
|
||||
INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`',
|
||||
INVALID_TEMPL_IMPORTS_ERROR_TEXT = 'Invalid `imports` option passed into `_.template`';
|
||||
|
||||
/** Used to stand-in for `undefined` hash values. */
|
||||
var HASH_UNDEFINED = '__lodash_hash_undefined__';
|
||||
@@ -1752,6 +1753,10 @@
|
||||
* embedded Ruby (ERB) as well as ES2015 template strings. Change the
|
||||
* following template settings to use alternative delimiters.
|
||||
*
|
||||
* **Security:** See
|
||||
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md)
|
||||
* — `_.template` is insecure and will be removed in v5.
|
||||
*
|
||||
* @static
|
||||
* @memberOf _
|
||||
* @type {Object}
|
||||
@@ -2300,7 +2305,7 @@
|
||||
* @name has
|
||||
* @memberOf SetCache
|
||||
* @param {*} value The value to search for.
|
||||
* @returns {number} Returns `true` if `value` is found, else `false`.
|
||||
* @returns {boolean} Returns `true` if `value` is found, else `false`.
|
||||
*/
|
||||
function setCacheHas(value) {
|
||||
return this.__data__.has(value);
|
||||
@@ -3766,7 +3771,7 @@
|
||||
if (isArray(iteratee)) {
|
||||
return function(value) {
|
||||
return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);
|
||||
}
|
||||
};
|
||||
}
|
||||
return iteratee;
|
||||
});
|
||||
@@ -4370,8 +4375,34 @@
|
||||
*/
|
||||
function baseUnset(object, path) {
|
||||
path = castPath(path, object);
|
||||
object = parent(object, path);
|
||||
return object == null || delete object[toKey(last(path))];
|
||||
|
||||
// Prevent prototype pollution:
|
||||
// https://github.com/lodash/lodash/security/advisories/GHSA-xxjr-mmjv-4gpg
|
||||
// https://github.com/lodash/lodash/security/advisories/GHSA-f23m-r3pf-42rh
|
||||
var index = -1,
|
||||
length = path.length;
|
||||
|
||||
if (!length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
while (++index < length) {
|
||||
var key = toKey(path[index]);
|
||||
|
||||
// Always block "__proto__" anywhere in the path if it's not expected
|
||||
if (key === '__proto__' && !hasOwnProperty.call(object, '__proto__')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block constructor/prototype as non-terminal traversal keys to prevent
|
||||
// escaping the object graph into built-in constructors and prototypes.
|
||||
if ((key === 'constructor' || key === 'prototype') && index < length - 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var obj = parent(object, path);
|
||||
return obj == null || delete obj[toKey(last(path))];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6922,7 +6953,7 @@
|
||||
|
||||
/**
|
||||
* Creates an array with all falsey values removed. The values `false`, `null`,
|
||||
* `0`, `""`, `undefined`, and `NaN` are falsey.
|
||||
* `0`, `-0`, `0n`, `""`, `undefined`, and `NaN` are falsy.
|
||||
*
|
||||
* @static
|
||||
* @memberOf _
|
||||
@@ -7461,7 +7492,7 @@
|
||||
|
||||
while (++index < length) {
|
||||
var pair = pairs[index];
|
||||
result[pair[0]] = pair[1];
|
||||
baseAssignValue(result, pair[0], pair[1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -14121,6 +14152,8 @@
|
||||
* **Note:** JavaScript follows the IEEE-754 standard for resolving
|
||||
* floating-point values which can produce unexpected results.
|
||||
*
|
||||
* **Note:** If `lower` is greater than `upper`, the values are swapped.
|
||||
*
|
||||
* @static
|
||||
* @memberOf _
|
||||
* @since 0.7.0
|
||||
@@ -14134,9 +14167,16 @@
|
||||
* _.random(0, 5);
|
||||
* // => an integer between 0 and 5
|
||||
*
|
||||
* // when lower is greater than upper the values are swapped
|
||||
* _.random(5, 0);
|
||||
* // => an integer between 0 and 5
|
||||
*
|
||||
* _.random(5);
|
||||
* // => also an integer between 0 and 5
|
||||
*
|
||||
* _.random(-5);
|
||||
* // => an integer between -5 and 0
|
||||
*
|
||||
* _.random(5, true);
|
||||
* // => a floating-point number between 0 and 5
|
||||
*
|
||||
@@ -14738,6 +14778,10 @@
|
||||
* properties may be accessed as free variables in the template. If a setting
|
||||
* object is given, it takes precedence over `_.templateSettings` values.
|
||||
*
|
||||
* **Security:** `_.template` is insecure and should not be used. It will be
|
||||
* removed in Lodash v5. Avoid untrusted input. See
|
||||
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md).
|
||||
*
|
||||
* **Note:** In the development build `_.template` utilizes
|
||||
* [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)
|
||||
* for easier debugging.
|
||||
@@ -14845,12 +14889,18 @@
|
||||
options = undefined;
|
||||
}
|
||||
string = toString(string);
|
||||
options = assignInWith({}, options, settings, customDefaultsAssignIn);
|
||||
options = assignWith({}, options, settings, customDefaultsAssignIn);
|
||||
|
||||
var imports = assignInWith({}, options.imports, settings.imports, customDefaultsAssignIn),
|
||||
var imports = assignWith({}, options.imports, settings.imports, customDefaultsAssignIn),
|
||||
importsKeys = keys(imports),
|
||||
importsValues = baseValues(imports, importsKeys);
|
||||
|
||||
arrayEach(importsKeys, function(key) {
|
||||
if (reForbiddenIdentifierChars.test(key)) {
|
||||
throw new Error(INVALID_TEMPL_IMPORTS_ERROR_TEXT);
|
||||
}
|
||||
});
|
||||
|
||||
var isEscaping,
|
||||
isEvaluating,
|
||||
index = 0,
|
||||
|
||||
Reference in New Issue
Block a user