diff --git a/.gitignore b/.gitignore index 074c3af..1e040dc 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,9 @@ file_upload/ # Ignore Gradle build output directory build +# SonarScanner 작업 디렉터리 +.scannerwork/ + build_knbank.sh diff --git a/Jenkinsfile.sonar b/Jenkinsfile.sonar new file mode 100644 index 0000000..41bd444 --- /dev/null +++ b/Jenkinsfile.sonar @@ -0,0 +1,228 @@ +// SonarQube 정적 보안 분석 전용 파이프라인. +// +// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다) +// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME) +// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (이름: SONAR_SCANNER_TOOL) +// 3) Quality Gate 대기를 쓰려면 SonarQube 서버에 +// Administration > Configuration > Webhooks 에 /sonarqube-webhook/ 등록 +// 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다. +// +// 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다. +// (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError) +pipeline { + agent { label 'djb-vm' } + + triggers { + pollSCM('H/30 * * * *') + } + + options { + timestamps() + disableConcurrentBuilds() + buildDiscarder(logRotator(numToKeepStr: '20')) + } + + parameters { + booleanParam( + name: 'RUN_TESTS', + defaultValue: false, + description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' + ) + } + + environment { + // --- Jenkins 설정 이름 (환경에 맞게 수정) --- + SONAR_ENV_NAME = 'SonarQube' + SONAR_SCANNER_TOOL = 'SonarScanner' + + // --- 툴체인 --- + JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8) + JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용 + 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' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + // eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다. + // 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다. + 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 JDK ---" + if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then + echo "JDK 17 이 없다: $JAVA_HOME_SCANNER" + echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것." + exit 1 + fi + "$JAVA_HOME_SCANNER/bin/java" -version + ''' + } + } + + // clean 을 반드시 붙인다. build/generated 잔여 산출물이 남으면 + // MapStruct Impl / QueryDSL Q클래스가 중복 생성되어 컴파일이 깨진다. + stage('Compile') { + steps { + sh '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' + } + } + } + + // sonar.java.libraries 용 클래스패스 덤프. + // 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다. + stage('Export analysis classpath') { + steps { + catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { + sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon' + } + } + } + + stage('SonarQube analysis') { + steps { + script { + def scannerHome = tool name: env.SONAR_SCANNER_TOOL, + type: 'hudson.plugins.sonar.SonarRunnerInstallation' + + 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)에 노출시키지 않고 환경변수로만 넘긴다. + # 플러그인 버전에 따라 SONAR_AUTH_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 + + "$SONAR_SCANNER_HOME/bin/sonar-scanner" \ + -Dsonar.projectVersion="${BUILD_NUMBER}" \ + -Dsonar.java.libraries="$LIBS" \ + -Dsonar.java.test.libraries="$TEST_LIBS" \ + $JUNIT_ARG + ''' + } + } + } + } + } + + // SonarQube 웹훅으로 게이트 결과를 받아 판정한다. + // 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다. + stage('Quality Gate') { + 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())}" + } + } + } + } + } +} diff --git a/ci/sonar-classpath.gradle b/ci/sonar-classpath.gradle new file mode 100644 index 0000000..378df52 --- /dev/null +++ b/ci/sonar-classpath.gradle @@ -0,0 +1,41 @@ +// SonarScanner 의 sonar.java.libraries / sonar.java.test.libraries 로 넘길 +// 컴파일 클래스패스를 파일로 덤프한다. +// +// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다. +// gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon +// +// 출력(콤마 구분, 절대경로): +// build/sonar/java-libraries.txt main 컴파일 클래스패스 +// build/sonar/java-test-libraries.txt main + test 컴파일 클래스패스 +// +// 이 파일이 없으면 Sonar Java 분석기가 타입을 해석하지 못해 +// 보안 룰(SQL Injection, XSS 등) 상당수가 침묵한다. 선택 사항이 아니다. + +rootProject { project -> + project.plugins.withId('java') { + project.tasks.register('exportSonarClasspath') { + group = 'verification' + description = 'SonarScanner 용 컴파일 클래스패스를 build/sonar 에 덤프한다' + + doLast { + def outDir = new File(project.layout.buildDirectory.get().asFile, 'sonar') + outDir.mkdirs() + + def toLine = { files -> + files.findAll { it.exists() } + .collect { it.absolutePath } + .unique() + .join(',') + } + + def mainCp = project.sourceSets.main.compileClasspath.files + def testCp = project.sourceSets.test.compileClasspath.files + + new File(outDir, 'java-libraries.txt').text = toLine(mainCp) + new File(outDir, 'java-test-libraries.txt').text = toLine(mainCp + testCp) + + logger.lifecycle("sonar classpath dumped: ${outDir}") + } + } + } +} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..a697608 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,61 @@ +# SonarQube 분석 설정 (SonarScanner CLI 용) +# +# Jenkinsfile.sonar 가 이 파일을 그대로 읽고, 아래 항목만 실행 시점에 -D 로 덮어쓴다. +# sonar.projectVersion : 빌드 번호 +# sonar.java.libraries : ci/sonar-classpath.gradle 이 덤프한 컴파일 클래스패스 +# sonar.java.test.libraries : 위 + 테스트 클래스패스 +# sonar.junit.reportPaths : 테스트를 실행한 경우에만 +# +# 로컬에서 수동 분석 시(스캐너는 JDK 17 필요): +# gradle clean classes testClasses +# gradle -I ci/sonar-classpath.gradle exportSonarClasspath +# sonar-scanner -Dsonar.host.url=... -Dsonar.token=... \ +# -Dsonar.java.libraries="$(cat build/sonar/java-libraries.txt)" + +sonar.projectKey=djb-eapim-portal +sonar.projectName=DJB eAPIM Portal +sonar.sourceEncoding=UTF-8 + +# --- 분석 대상 ------------------------------------------------------------- +# src/main/resources 를 포함시켜 Thymeleaf 템플릿(XSS), yml/properties(하드코딩 시크릿)까지 검사한다. +sonar.sources=src/main/java,src/main/resources +sonar.tests=src/test/java + +# --- Java ------------------------------------------------------------------ +# 소스는 Java 8. 스캐너 실행 JRE 는 별개로 17 이상이어야 한다(Jenkinsfile.sonar 참고). +sonar.java.source=8 +sonar.java.binaries=build/classes/java/main +sonar.java.test.binaries=build/classes/java/test + +# --- 제외 대상 ------------------------------------------------------------- +# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다. +sonar.exclusions=\ + **/build/**,\ + **/node_modules/**,\ + **/lib/**,\ + **/libs/**,\ + **/local_jars/**,\ + src/main/resources/static/plugins/**,\ + src/main/resources/static/js/egovframework/**,\ + src/main/resources/static/js/**/*.min.js,\ + src/main/resources/static/js/lodash.js,\ + src/main/resources/static/js/popper.js,\ + src/main/resources/static/js/daterangepicker.js,\ + src/main/resources/static/js/json-lint.js,\ + src/main/resources/static/html/**,\ + src/main/resources/static/css/**,\ + src/main/resources/static/sass/**,\ + src/main/resources/static/font/**,\ + src/main/resources/static/webfonts/**,\ + src/main/resources/static/img/** + +# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다. + +# --- SCM ------------------------------------------------------------------- +# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다. +sonar.scm.provider=git + +# --- 브랜치 ---------------------------------------------------------------- +# sonar.branch.name 은 SonarQube Developer Edition 이상에서만 동작한다. +# Community Edition 에서 지정하면 분석이 실패하므로 기본적으로 설정하지 않는다. +# sonar.branch.name=feats/ci-test