OWASP Dependency-Check 통합 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- Jenkinsfile.security: 라이브러리 취약점 검사용 파이프라인 작성 (SAST/SCA 병행)
- dependency-check-classpath.gradle: 스캔 대상 의존성 jar 분리 로직 작성
- dependency-check-suppressions.xml: 오탐 억제 규칙 파일 추가
This commit is contained in:
Rinjae
2026-08-13 17:53:14 +09:00
parent 45e8fa2903
commit 2bae0e350c
3 changed files with 590 additions and 0 deletions
+501
View File
@@ -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"
}
}
}
}
}