이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube SQL Injection(S3649)을 87건 정리하고 나서 한숨 돌렸는데, 이번엔 SonarQube Hardcoded Credentials 룰 S2068이 34건이 뜬 거다. Command Injection(S2076) 작업이랑 병행하느라 정신이 없던 시기였다. “Credentials should not be hard-coded”라는 메시지는 짧고 명확한데, 정작 레거시 코드에서 퍼지면 어디를 어떻게 건드려야 하는지 감이 안 잡혔다.
S2068은 CWE-798에 해당하는 취약점이다. 비밀번호·API 키·접속 문자열 같은 민감 정보가 소스 코드나 설정 파일에 평문으로 박혀 있으면 잡는다. 금융권 레거시에서는 application.properties나 XML 설정에 DB 비밀번호를 그냥 써놓는 경우가 꽤 있다. 당시 QA 환경 DB 비밀번호가 Git에 그대로 커밋돼 있었고, SonarQube Hardcoded Credentials 룰이 이걸 전부 잡고 있었다.
처음에는 쉽게 보고 변수 이름만 바꾸면 되겠지 했다. 4가지 시도 끝에 최종 해결까지 반나절을 날렸다. 시도 과정과 왜 실패했는지를 기록한다.

레거시에서 발견된 패턴
34건을 분류하니 크게 세 패턴으로 나뉘었다.
패턴 1. application.properties 직접 기입
# src/main/resources/application.properties
spring.datasource.url=jdbc:oracle:thin:@10.0.1.50:1521:FINDB
spring.datasource.username=fin_user
spring.datasource.password=F1n@nce2021!
smtp.password=mailP@ssw0rd
api.secret-key=sk-live-aBcDeF123456
이게 34건 중 22건이었다. 개발 편의로 QA 환경 비밀번호를 그냥 써놓은 거다. Git에도 그대로 들어가 있었다. SonarQube Hardcoded Credentials 분석기는 속성 키가 password, secret, key 같은 패턴에 매칭되면 값이 빈 문자열이 아닌 이상 모두 잡는다.
패턴 2. Java 코드 내 문자열 리터럴
// FinanceDbConfig.java
public class FinanceDbConfig {
private static final String DB_USER = "fin_user";
private static final String DB_PASS = "F1n@nce2021!"; // S2068 여기서 잡힘
public DataSource dataSource() {
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setUrl("jdbc:oracle:thin:@10.0.1.50:1521:FINDB");
ds.setUsername(DB_USER);
ds.setPassword(DB_PASS); // 이 라인도 잡힘
return ds;
}
}
Java 파일에서 잡힌 게 9건이었다. DB_PASS처럼 상수 이름에 pass가 들어가 있고 그 값이 문자열 리터럴이면 S2068이 떴다.
패턴 3. JSP 주석 안 비밀번호
<%-- JSP 배치 모듈 내 테스트용 주석
DB: jdbc:oracle:thin:@10.0.1.50:1521/FINDB
User: fin_user / Password: F1n@nce2021!
--%>
3건은 JSP 주석에 개발자가 메모처럼 적어둔 DB 접속 정보였다. 주석이라서 안 잡힐 거라고 생각했나본데, SonarQube는 주석도 스캔한다.
시도 1. 변수 분리 — 실패
제일 먼저 떠올린 건 “변수 이름을 애매하게 바꾸면 어떨까”였다. DB_PASS를 DB_CONN_PARAM으로, password 키를 datasource.credential로 바꿔봤다.
# 변수명만 바꿔본 시도
spring.datasource.credential=F1n@nce2021!
결과는 그대로 떴다. S2068은 키 이름이 아니라 값 자체의 컨텍스트를 본다. credential이라는 단어도 민감 정보 키 패턴에 포함돼 있고, 설령 포함이 안 되더라도 값이 평문 비밀번호처럼 생겼으면 잡힌다. 변수명만 바꿔서 해결되는 룰이 아니었다. 30분 날렸다.
Java 코드 쪽도 마찬가지였다. DB_PASS를 DB_CONFIG_VALUE로 리네임해봤는데 S2068은 그 라인에서 계속 떠 있었다.
시도 2. placeholder 치환 — 실패
다음 시도는 “값을 placeholder 형태로 바꾸면 SonarQube가 넘어가지 않을까”였다. 일부 글에서 ${DB_PASSWORD} 형태로 써놓으면 된다고 봤던 것 같다.
# placeholder로 바꿔본 시도
spring.datasource.password=${DB_PASSWORD:defaultSecret}
smtp.password=${SMTP_PASSWORD:mailDefault123}
여기서 함정이 있다. Spring Boot : 뒤에 오는 게 디폴트값이다. ${DB_PASSWORD:defaultSecret}에서 defaultSecret이라는 평문이 들어가 있으니 S2068은 이 디폴트값에서 또 잡았다. placeholder 형식으로 바꿨어도 디폴트값에 실제 비밀번호가 박혀 있으면 의미가 없다.
디폴트값을 아예 빼면 어떻게 되나 싶어서 ${DB_PASSWORD}만 써봤다. SonarQube는 이건 통과시켰다. 그런데 문제는 로컬 개발 환경이었다. 환경변수가 설정돼 있지 않으면 앱이 시작 시 에러를 뱉었고, 팀원들이 각자 환경변수를 세팅해야 한다는 게 컨센서스가 안 됐다. 결국 임시방편으로는 쓸 수 없었다.
시도 3. JNDI lookup — 부분 통과
레거시 톰캣 환경이라 JNDI로 DB 접속 정보를 관리하는 게 가능했다. context.xml에 DataSource를 정의하고 애플리케이션에서 JNDI lookup으로 가져오는 방식이다.
<!-- Tomcat context.xml -->
<Context>
<Resource name="jdbc/FinanceDB"
auth="Container"
type="javax.sql.DataSource"
driverClassName="oracle.jdbc.OracleDriver"
url="jdbc:oracle:thin:@10.0.1.50:1521:FINDB"
username="fin_user"
password="F1n@nce2021!"
maxTotal="20" />
</Context>
// FinanceDbConfig.java — JNDI 방식
@Bean
public DataSource dataSource() throws NamingException {
JndiDataSourceLookup lookup = new JndiDataSourceLookup();
return lookup.getDataSource("java:comp/env/jdbc/FinanceDB");
}
Java 소스 쪽에서는 비밀번호가 사라졌으니 S2068이 잡히던 9건은 전부 해소됐다. 그런데 context.xml도 소스 관리 대상이라 Git에 올라가 있었고, SonarQube는 context.xml의 password 속성도 스캔한다. 결국 context.xml에서 3건이 남았다.
JNDI는 애플리케이션 코드에서 비밀번호를 분리하는 데는 유효하지만, 비밀번호 자체가 어딘가 평문으로 존재하는 한 SonarQube Hardcoded Credentials는 그걸 찾아낸다. 서버 배포 환경의 context.xml을 소스에서 제외하고 서버에만 배치하면 해결이 되지만, 그건 배포 프로세스 전체를 바꾸는 일이라 당장 적용이 어려웠다.
SonarQube XSS(S5131)처럼 데이터 플로우를 끊으면 해결되는 룰과 달리, S2068은 소스 저장소 자체에서 민감 정보를 꺼내야 한다는 게 핵심이었다.
최종 해결 — 환경변수 + Vault
팀 내 논의 끝에 두 트랙으로 나눠서 처리했다.
트랙 A. 로컬·QA 환경 — 환경변수 + .env 파일
트랙 B. 운영 환경 — HashiCorp Vault
트랙 A — 환경변수 주입
# application.properties — 디폴트값 없는 placeholder만
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
smtp.password=${SMTP_PASSWORD}
api.secret-key=${API_SECRET_KEY}
# .env (Git에 올리지 않음 — .gitignore에 추가)
DB_URL=jdbc:oracle:thin:@10.0.1.50:1521:FINDB
DB_USERNAME=fin_user
DB_PASSWORD=F1n@nce2021!
SMTP_PASSWORD=mailP@ssw0rd
API_SECRET_KEY=sk-live-aBcDeF123456
Spring Boot는 spring-dotenv 같은 라이브러리 없이도 OS 환경변수를 바로 읽는다. CI/CD 파이프라인(Jenkins, GitLab CI)에서는 시크릿 변수로 주입한다. 로컬 개발 시에는 IDE run configuration에서 환경변수를 지정하거나, docker-compose.yml의 env_file을 활용한다.
트랙 B — Spring Cloud Config + Vault 연동
# bootstrap.properties (운영 환경)
spring.cloud.vault.host=vault.internal.finco.com
spring.cloud.vault.port=8200
spring.cloud.vault.scheme=https
spring.cloud.vault.authentication=APPROLE
spring.cloud.vault.app-role.role-id=${VAULT_ROLE_ID}
spring.cloud.vault.app-role.secret-id=${VAULT_SECRET_ID}
spring.cloud.vault.kv.enabled=true
spring.cloud.vault.kv.backend=secret
spring.cloud.vault.kv.default-context=finance-api
// Vault 경로: secret/finance-api
// {
// "spring.datasource.password": "F1n@nce2021!",
// "smtp.password": "mailP@ssw0rd",
// "api.secret-key": "sk-live-aBcDeF123456"
// }
// Spring Boot application 코드 — 변경 없음
// @Value("${spring.datasource.password}") 그대로 동작
@Configuration
public class FinanceDbConfig {
@Value("${spring.datasource.url}")
private String dbUrl;
@Value("${spring.datasource.username}")
private String dbUsername;
@Value("${spring.datasource.password}")
private String dbPassword; // Vault에서 주입된 값
@Bean
public DataSource dataSource() {
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setUrl(dbUrl);
ds.setUsername(dbUsername);
ds.setPassword(dbPassword);
return ds;
}
}
Vault까지 붙이는 게 처음엔 오버엔지니어링처럼 보였는데, 운영 환경에서 비밀번호 로테이션이 필요할 때 앱 재시작 없이 갱신할 수 있다는 게 장점이었다. 금융권 보안 감사 대응에도 “비밀번호 관리 정책”을 증빙하기 좋다.
JSP 주석 정리
JSP 주석에 박혀 있던 3건은 단순히 삭제했다. 개발 편의로 적어둔 메모는 소스에 올릴 게 아니다. 해당 접속 정보는 팀 내 위키로 이관했다.
핵심 정리 4가지
- 디폴트값 없는 placeholder —
${DB_PASSWORD}만,:defaultValue형태 금지 - .env는 .gitignore에 — 당연한 말이지만 레거시에서 빠져 있는 경우가 많다
- JNDI는 context.xml도 소스 관리 밖으로 — 소스에 남아 있으면 SonarQube가 잡는다
- Vault AppRole 인증 — VAULT_ROLE_ID와 VAULT_SECRET_ID도 환경변수로 주입해야 비밀번호를 찾는 비밀번호 문제가 안 생긴다
정리
SonarQube Hardcoded Credentials(S2068)의 본질은 소스 저장소에서 민감 정보를 완전히 분리하는 것이다. 변수명을 바꾸거나 디폴트값이 있는 placeholder로 치환해봤자 S2068은 사라지지 않는다. 환경변수로 주입하고, Git에 올라가지 않는 게 기준선이다.
이 룰 시리즈는 S3649 SQL Injection, S2076 Command Injection, S5131 XSS, S2083 Path Traversal, S2755 XXE에 이어 작성 중이다. 다음 편은 SonarQube CSRF 룰 S4502를 다룬다 — http.csrf().disable()을 그대로 두고 배포했다가 Security Hotspot이 쏟아진 케이스다.
룰 본문은 SonarSource 공식 룰 페이지(S2068)에서 확인할 수 있다.