생성 코드 보안 점검 - 입력·권한·비밀값·의존성
이 장에서 배우는 것
앞 장에서 장애를 재현할 때는 관찰한 사실과 원인에 대한 가설을 나누었다. 생성 코드의 보안을 살필 때도 같은 구분이 필요하다. 위험해 보이는 문자열을 찾았다는 사실과 실제 공격이 가능하다는 판단은 다르다. 반대로 점검 결과가 비어 있다는 사실이 코드의 안전성을 증명하지도 않는다.
작은 쇼핑몰 저장소에 주문 조회, 영수증 다운로드, 결제 모의 객체를 손보는 변경이 들어왔다고 하자. 기능 설명은 맞고 정상 주문도 조회된다. 그러나 입력을 SQL 문장에 붙이고, 주문 소유자를 확인하지 않으며, 결제 정보를 로그에 남긴다면 정상 동작만으로는 변경을 받아들이기 어렵다. 이 장에서는 이런 검토 지점을 위험 유형과 원본 줄 번호로 정리하는 작은 점검기를 만든다.
- 입력값이 SQL 문장과 파일 경로의 의미를 바꾸는 지점을 찾는다.
- 로그인 여부와 특정 주문에 대한 접근 권한을 구분한다.
- 개인정보와 비밀값이 코드와 로그에 남는 경로를 살핀다.
- 라이브러리 제안을 확인된 사실과 미확인 주장으로 나눈다.
- 규칙 기반 점검기의 탐지 범위와 한계를 실행 가능한 검사로 확인한다.
문제 상황
팀은 주문 상세 화면을 고치는 변경 요청을 받았다. 사용자가 주문 번호를 입력하면 주문을 조회하고, 영수증 파일의 위치를 계산한다. 결제는 외부 서비스 대신 모의 객체로 처리한다. AI는 변경 요약에 다음과 같이 적었다.
주문 조회와 영수증 경로 처리를 추가했다. 로그인한 사용자가 주문을 조회하며, 문제 분석을 위해 고객 이메일을 로그에 남긴다. 결제 연동을 쉽게 만들기 위해 FastPayClient를 가져왔다.
이 설명에는 검증해야 할 주장이 섞여 있다. 로그인했다는 말은 해당 주문을 볼 권한이 있다는 뜻이 아니다. 문제 분석에 도움이 된다는 이유만으로 이메일 전체를 기록할 필요도 없다. 편리해 보이는 라이브러리 이름이 실제로 배포되는 구성 요소라는 보장도 없다.
팀은 우선 여섯 종류의 코드 조각을 점검 대상으로 모은다. SQL 문자열 연결, 입력으로 만든 경로, 소유자 확인이 보이지 않는 주문 조회, 개인정보 로그, 소스에 박힌 비밀값, 검증되지 않은 라이브러리 가져오기다. 완성 프로그램은 이 조각을 실행하지 않고 문자열 자료로 읽는다. 위험한 동작을 재현하는 대신 검토할 위치를 찾는 프로그램이기 때문이다.
코드 조각에는 일부러 위험한 표현을 넣는다. 코드에 들어가는 결제 키는 실제 자격 증명이 아닌 예제용 표식이다. 의존성 이름도 이 장에서 만든 가상의 이름이다. 실행 과정에서는 데이터베이스, 실제 주문 저장소, 네트워크에 접근하지 않는다.
입력이 명령과 자원 선택으로 바뀌는 경계
SQL 문자열 연결의 문제는 따옴표가 눈에 보이느냐에 있지 않다. 사용자가 보낸 값이 SQL 문장의 구조에 섞이는 데 있다. 주문 번호를 문자열로 이어 붙이면 데이터로 취급해야 할 내용이 조건식의 일부가 될 수 있다. 실제 JDBC 코드에서는 PreparedStatement의 매개변수 자리에 값을 바인딩한다. 테이블 이름이나 정렬 방향처럼 매개변수로 바인딩할 수 없는 요소는 허용한 값 중에서 선택해야 한다.
이 장의 점검기는 SQL 실행을 흉내 내지 않는다. SQL로 보이는 문자열과 덧셈 기호가 한 줄에 있으면 검토 후보로 보고한다. 여러 줄에 나누어 만든 문장이나 다른 메서드를 거친 입력은 놓칠 수 있다. 숫자를 더한 SQL 문자열처럼 사용자가 개입하지 않는 경우도 잡힐 수 있다. 따라서 결과의 의미는 “주입 공격이 입증되었다”가 아니라 “문장과 값의 경계를 확인하라”다.
경로 조작(path traversal)은 입력이 파일 선택 범위를 넓히는 문제다. 영수증 이름으로 상위 디렉터리 이동 표현이나 절대 경로를 받으면 지정한 영수증 폴더 밖을 가리킬 수 있다. 경로를 정규화한 뒤 기준 디렉터리에 속하는지 Path.startsWith로 확인하면 문자열 수준의 경계 이탈을 막는 데 도움이 된다. 문자열의 startsWith는 폴더 구성 요소를 이해하지 못하므로 이 용도로 대신 쓰지 않는다.
다만 경로 정규화는 심볼릭 링크의 실제 목적지를 확인하지 않는다. 실제 파일을 여는 서비스라면 링크 정책, 실제 경로 확인, 검사와 사용 사이에 파일이 바뀌는 문제까지 별도로 설계해야 한다. 더 단순한 선택은 요청에서 파일 경로를 받지 않고 주문 번호를 받아 서버가 보관한 영수증 위치를 찾는 것이다. 완성 코드의 경로 예제는 파일을 열지 않으며, 경로 구성 요소의 경계만 검사한다.
권한은 이 경계와 다른 축이다. 주문 번호가 형식에 맞고 SQL도 매개변수로 실행되더라도 다른 사람의 주문을 반환할 수 있다. 로그인 확인은 요청한 사람이 누구인지 판단하는 인증(authentication)이고, 그 사람이 해당 주문을 볼 수 있는지 판단하는 것이 인가(authorization)다. 주문 조회에는 현재 사용자와 주문 소유자의 관계를 확인하는 조건이 필요하다.
검사에 사용하는 현재 사용자 식별자는 서버가 검증한 로그인 문맥에서 가져와야 한다. 요청 본문에 적힌 사용자 식별자를 그대로 믿으면 공격자가 소유자 값을 바꿀 수 있다. 팀은 조회 메서드 이름만 보지 않고 사용자 식별자가 어디서 왔는지, 주문 소유자와 어디서 비교되는지까지 따라가야 한다.
기록과 의존성도 검토 대상이다
개인정보와 비밀값은 구분해서 살핀다. 이메일, 배송지, 전화번호는 사용자와 연결되는 정보다. 결제 키와 접근 토큰은 다른 자원에 접근할 수 있게 하는 비밀값이다. 주문 조회가 성공해도 이 값이 로그나 소스에 남으면 별도의 문제가 생긴다. 저장소에 비밀값이 들어갔다면 최신 파일에서 지우는 조치만으로 이전 노출이 해소되지는 않는다.
로그에는 문제를 연결할 수 있는 최소한의 정보가 필요하다. 예를 들어 내부 주문 식별자와 처리 결과만으로 요청을 추적할 수 있다면 이메일 전체를 함께 적을 이유가 줄어든다. 내부 식별자도 다른 자료와 결합해 개인을 가리킬 수 있으므로 접근 권한과 보관 범위를 정해야 한다. 마스킹 여부를 판단하기 전에 해당 필드가 정말 필요한지 먼저 묻는다.
라이브러리 검토에서는 이름의 그럴듯함을 증거로 삼지 않는다. AI가 제안한 가져오기 문장이 컴파일되지 않는다면 오타, 잘못된 버전, 누락된 의존성, 존재하지 않는 이름 등 여러 가능성이 있다. 저장소에 없다는 이유만으로 세상에 존재하지 않는다고 단정할 수 없다. 반대로 같은 이름의 배포물이 검색된다는 이유만으로 팀이 의도한 구성 요소라고 믿어서도 안 된다.
| 위험 유형 | 점검 신호 | 후속 확인 |
|---|---|---|
| SQL 문자열 연결 | SQL 문자열과 덧셈 기호 | 입력 유입과 매개변수 바인딩 |
| 경로 조작 | 입력 이름으로 경로 구성 | 기준 폴더 이탈과 링크 정책 |
| 권한 확인 누락 | 소유자 조건 없는 조회 호출 | 검증한 사용자와 주문 소유자 비교 |
| 개인정보 로그 | 로그 호출에 이메일 변수 | 기록 필요성, 접근 범위, 보관 기간 |
| 비밀값 하드코딩 | 키 변수에 문자열 직접 대입 | 실제 비밀 여부와 노출 이력 |
| 미확인 의존성 | 확인 목록 밖의 가져오기 | 배포 출처, 좌표, 버전, 유지 상태 |
이 장에서는 네트워크를 사용하지 않으므로 의존성 존재 여부를 조회하지 않는다. 대신 팀이 확인한 이름 목록을 입력으로 제공한다. 목록에 없는 이름은 “미확인”이라고 보고한다. 이 결과를 받은 사람은 실제 작업 환경에서 배포 출처와 사용 근거를 조사해야 한다. 목록은 확인 기록을 대신하는 영구적인 승인장이 아니라 점검기에 주는 제한된 자료다.
규칙의 결과는 판정문이 아니라 검토 목록이다
완성 프로그램은 파일 이름과 원문을 가진 Source를 입력받고, 파일 이름·줄 번호·유형을 가진 Finding을 반환한다. 줄 번호는 각 조각의 첫 줄을 1로 계산한다. 예제의 첫 줄은 설명 주석이고 두 번째 줄이 점검 대상이므로 여섯 결과 모두 2번 줄을 가리킨다.
규칙은 의도적으로 작다. 한 줄씩 순회하며 정해 둔 표현을 찾는다. Java 구문을 해석하거나 메서드 사이의 데이터 흐름을 추적하지 않는다. 특히 AUTH 규칙은 권한 검사의 부재를 증명하는 규칙이 아니다. 이 예제 저장소에서 검토 대상으로 정한 orders.find(orderId) 호출을 찾는 규칙이다. 상위 계층에서 이미 권한을 확인한 경우도 사람이 판단해야 한다.
결과 순서는 입력 조각 순서, 줄 순서, 규칙 선언 순서로 고정한다. 하나의 줄이 여러 규칙에 걸리면 여러 결과를 남긴다. 비밀값을 포함한 줄 전체는 출력하지 않는다. 점검 도구 자체가 로그를 통해 민감한 원문을 다시 퍼뜨리지 않도록 위치와 유형만 출력한다.
검사는 탐지 예제뿐 아니라 경계도 확인한다. 정상 입력에서는 수정 전후 경로가 같아야 하고, 다른 소유자의 주문은 수정 후 거절되어야 한다. 로그를 줄여도 주문 처리 결과는 같아야 한다. 여기에 의도적으로 놓치는 SQL 작성 예제를 넣어 탐지 범위를 드러낸다. 이 검사가 통과한다는 말은 탐지기의 한계를 예상대로 재현했다는 뜻이다.
완성 코드
Main.java 하나에 저장한다. 코드 조각 문자열은 점검기의 입력 자료이며, Java 코드로 컴파일하거나 실행하지 않는다. 실제 실행되는 부분은 점검 규칙과 경로·권한·로그의 전후 비교다. 경로는 상대 경로 구성 요소로만 계산하므로 운영체제별 임시 디렉터리 이름이 출력에 섞이지 않는다. 파일을 만들거나 읽는 동작도 없다.
Main.java
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
public class Main {
record Source(String name, String text) {}
record Finding(String file, int line, String type) {}
record Rule(String type, Predicate<String> matches) {}
record Order(String id, String owner) {}
record Outcome(String result, String log) {}
private static int failures;
static final List<Rule> RULES = List.of(
new Rule("SQL", line ->
line.contains("\"SELECT ") && line.contains("+")),
new Rule("PATH", line ->
line.contains("root.resolve(fileName)")),
new Rule("AUTH", line ->
line.contains("orders.find(orderId)")),
new Rule("PII_LOG", line ->
line.contains("log.info(") && line.contains("email")),
new Rule("SECRET", line ->
line.contains("paymentKey = \""))
);
// 01: 入力を順番に走査し、原文を出力せず位置を返す。
static List<Finding> scan(
List<Source> sources, Set<String> verifiedImports) {
List<Finding> findings = new ArrayList<>();
for (Source source : sources) {
String[] lines = source.text().split("\\R", -1);
for (int i = 0; i < lines.length; i++) {
String line = lines[i].strip();
for (Rule rule : RULES) {
if (rule.matches().test(line)) {
findings.add(new Finding(
source.name(), i + 1, rule.type()));
}
}
if (line.startsWith("import ") && line.endsWith(";")) {
String name = line.substring(
"import ".length(), line.length() - 1).strip();
if (!verifiedImports.contains(name)) {
findings.add(new Finding(
source.name(), i + 1, "DEPENDENCY_UNVERIFIED"));
}
}
}
}
return List.copyOf(findings);
}
// 02: 각 조각은 원본 첫 줄부터 번호를 매긴다.
static List<Source> samples() {
return List.of(
new Source("SqlPart.java",
"// order lookup\n"
+ "String sql = \"SELECT id FROM orders WHERE id = '\""
+ " + orderId + \"'\";"),
new Source("PathPart.java",
"// receipt path\n"
+ "Path receipt = root.resolve(fileName);"),
new Source("AuthPart.java",
"// order response\n"
+ "return orders.find(orderId);"),
new Source("LogPart.java",
"// debug output\n"
+ "log.info(\"customer=\" + email);"),
new Source("SecretPart.java",
"// fake example value\n"
+ "String paymentKey = \"DEMO_ONLY_NOT_A_SECRET\";"),
new Source("DependencyPart.java",
"// fictional proposal\n"
+ "import imaginary.pay.FastPayClient;")
);
}
// 03: 수정 전 경로 계산이다.
static Path pathBefore(Path root, String fileName) {
return root.resolve(fileName).normalize();
}
// 04: 구성 요소 기준으로 기준 폴더 이탈을 거절한다.
static Path pathAfter(Path root, String fileName) {
if (fileName.isBlank()) {
throw new IllegalArgumentException("empty name");
}
Path base = root.normalize();
Path candidate = base.resolve(fileName).normalize();
if (!candidate.startsWith(base) || candidate.equals(base)) {
throw new IllegalArgumentException("outside root");
}
return candidate;
}
// 05: 수정 전에는 소유자가 달라도 주문 번호를 반환한다.
static String orderBefore(Order order, String currentUser) {
return order.id();
}
// 06: currentUser는 검증된 로그인 문맥에서 왔다고 가정한다.
static String orderAfter(Order order, String currentUser) {
if (!order.owner().equals(currentUser)) {
throw new SecurityException("owner mismatch");
}
return order.id();
}
// 07: 주문 처리 결과와 기록 내용을 분리한다.
static Outcome logBefore(String orderId, String email) {
return new Outcome("accepted:" + orderId,
"order=" + orderId + ", email=" + email);
}
static Outcome logAfter(String orderId, String email) {
return new Outcome("accepted:" + orderId,
"order=" + orderId + ", status=accepted");
}
// 08: 기대한 예외 유형만 성공으로 인정한다.
static boolean rejects(
Runnable action, Class<? extends RuntimeException> expected) {
try {
action.run();
return false;
} catch (RuntimeException exception) {
return expected.isInstance(exception);
}
}
static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
failures++;
}
}
public static void main(String[] args) {
Set<String> verified = Set.of("java.nio.file.Path");
List<Finding> findings = scan(samples(), verified);
// 09: 식별자와 위치만 출력한다.
for (Finding finding : findings) {
System.out.println(finding.file() + ":" + finding.line()
+ " " + finding.type());
}
check(findings.size() == 6, "six findings");
check(findings.stream().allMatch(f -> f.line() == 2),
"original line numbers");
check(findings.stream().map(Finding::type).toList().equals(
List.of("SQL", "PATH", "AUTH", "PII_LOG", "SECRET",
"DEPENDENCY_UNVERIFIED")), "rule order");
check(scan(List.of(new Source("Known.java",
"import java.nio.file.Path;")), verified).isEmpty(),
"verified import");
Path root = Path.of("receipts");
check(pathBefore(root, "order-7.txt").equals(
pathAfter(root, "order-7.txt")), "path same normal result");
check(!pathBefore(root, "../private.txt").startsWith(root),
"old path escapes");
check(rejects(() -> pathAfter(root, "../private.txt"),
IllegalArgumentException.class), "new path rejects escape");
Order order = new Order("order-7", "user-a");
check(orderBefore(order, "user-a").equals(
orderAfter(order, "user-a")), "owner same normal result");
check(orderBefore(order, "user-b").equals("order-7"),
"old lookup exposes order");
check(rejects(() -> orderAfter(order, "user-b"),
SecurityException.class), "new lookup rejects other owner");
Outcome before = logBefore("order-7", "buyer@example.invalid");
Outcome after = logAfter("order-7", "buyer@example.invalid");
check(before.result().equals(after.result()),
"log change preserves business result");
check(before.log().contains("buyer@example.invalid")
&& !after.log().contains("buyer@example.invalid"),
"email removed from log");
Source missed = new Source("SplitSql.java",
"String prefix = \"SELECT id FROM orders WHERE id = \";\n"
+ "String sql = prefix + orderId;");
check(scan(List.of(missed), verified).isEmpty(),
"known missed SQL case");
Source comment = new Source("Comment.java",
"// old call: orders.find(orderId)");
check(scan(List.of(comment), verified).size() == 1,
"known comment false alarm");
System.out.println("SUMMARY failures=" + failures);
if (failures != 0) {
throw new IllegalStateException("checks failed");
}
}
}
줄별 해설
코드 안의 01부터 09까지 주석을 기준으로 실행 흐름을 읽는다. 점검 결과의 줄 번호는 이 설명용 번호가 아니라 Source에 담긴 원문의 줄 번호다. 두 번호를 혼동하면 리뷰에서 잘못된 위치를 가리키게 된다.
자료형 선언에서는 입력, 결과, 규칙을 각각 record로 표현한다. Source는 원문을 보존하고, Finding은 원문 대신 위치와 유형만 보관한다. Rule은 유형 이름과 한 줄을 판단하는 함수를 묶는다. Predicate의 test가 참이면 해당 줄을 검토 목록에 넣는다.
RULES의 SQL 조건은 큰따옴표 다음에 SELECT가 오는 표현과 덧셈 기호를 동시에 찾는다. 대소문자 변형이나 다른 SQL 명령은 다루지 않는다. PATH와 AUTH 역시 예제 저장소의 특정 호출 형태를 찾는다. PII_LOG는 이메일 변수에만 반응하므로 전화번호나 객체 전체 출력은 잡지 못한다. SECRET도 paymentKey라는 이름과 문자열 대입이라는 좁은 약속에 의존한다.
01에서는 원문을 줄 단위로 나눈다. split의 두 번째 인수 -1은 마지막 빈 줄도 유지한다. 공백을 제거한 문자열은 규칙 검사에만 쓰고, 번호는 원래 배열 인덱스에 1을 더해 만든다. 중간 줄을 삭제하거나 전체 원문을 한 줄로 합치지 않기 때문에 현재 입력에 대한 줄 위치가 유지된다.
의존성 검사는 일반 규칙 뒤에서 수행한다. import로 시작하고 세미콜론으로 끝나는 한 줄에서 이름을 꺼낸다. 정적 가져오기와 와일드카드 가져오기를 별도로 해석하지 않으므로 그런 문장도 확인 목록에 정확히 일치하는 항목이 없으면 미확인으로 나온다. 이 프로그램이 의존성 해석기나 배포물 검증기가 아니라는 점이 드러나는 부분이다.
02는 여섯 입력을 고정한다. 각각 주석 한 줄 다음에 대상 문장이 있으므로 결과 위치를 쉽게 검산할 수 있다. 원문에 있는 결제 키 표식은 출력하지 않는다. 실제 저장소를 연결할 때도 결과 보고서에 코드 전체를 무조건 붙이지 않는 편이 낫다.
03과 04는 같은 입력에 대한 경로 계산을 비교한다. 정상 파일 이름에는 같은 Path를 반환한다. 상위 폴더로 이동하는 이름에는 이전 메서드가 기준 폴더 밖의 경로를 만들고, 이후 메서드는 예외를 던진다. 기준 폴더 자체를 뜻하는 입력도 파일 이름으로 받아들이지 않는다. 이 비교는 실제 파일 접근이나 링크 검사가 포함되지 않은 범위에서만 유효하다.
05와 06은 주문 소유자 검사의 차이다. 정상 소유자에게는 같은 주문 번호를 반환한다. 다른 사용자는 수정 후 메서드에서 거절한다. 실제 서비스에서는 주문을 반환하기 전에 이 조건이 적용되어야 한다. 조회 조건에 소유자 식별자를 함께 넣는 방식도 가능하지만, 서비스가 구분해야 하는 미존재와 접근 거절의 응답 정책은 별도로 정해야 한다.
07은 로그와 업무 결과를 Outcome의 두 값으로 나눈다. 이메일을 제거해도 accepted:order-7이라는 처리 결과는 유지된다. 정상 출력이 같다는 요구는 보안 수정으로 모든 관찰값을 똑같이 만들라는 뜻이 아니다. 필요한 업무 결과는 유지하면서 민감한 기록과 허용하지 않은 접근은 달라져야 한다.
08에서는 예외가 났다는 사실만으로 검사를 통과시키지 않는다. 예상한 예외 계열인지 확인한다. 예를 들어 소유자 검사 도중 NullPointerException이 생겼다면 SecurityException을 기대한 검사는 실패한다. main의 check는 성공과 실패를 모두 출력하고, 실패가 하나라도 있으면 마지막에 예외를 던진다.
09 이후에는 출력 순서와 결과 유형을 확인한다. 마지막 두 검사는 놓침과 잘못된 경고를 문서화한다. SQL을 두 줄에 나누면 탐지하지 못하고, 주석에 적힌 주문 조회 표현에는 경고한다. 이 검사는 부족한 규칙을 좋은 규칙으로 바꾸지 않는다. 현재 도구가 무엇을 보지 못하는지 팀이 같은 사례로 확인하게 한다.
실행 결과
macOS 또는 Linux에서 JDK 17을 사용한다. 아래 두 명령을 차례로 실행한다. 셸 명령과 예상 출력은 실행 안내이며 Java 소스 파일에 넣지 않는다.
javac -d out *.java
java -cp out Main
컴파일 단계에는 경고가 나오지 않으며, 프로그램의 예상 표준 출력은 다음과 같다. 시각이나 난수, 파일 시스템의 실제 경로에 의존하는 출력이 없다.
SqlPart.java:2 SQL
PathPart.java:2 PATH
AuthPart.java:2 AUTH
LogPart.java:2 PII_LOG
SecretPart.java:2 SECRET
DependencyPart.java:2 DEPENDENCY_UNVERIFIED
PASS six findings
PASS original line numbers
PASS rule order
PASS verified import
PASS path same normal result
PASS old path escapes
PASS new path rejects escape
PASS owner same normal result
PASS old lookup exposes order
PASS new lookup rejects other owner
PASS log change preserves business result
PASS email removed from log
PASS known missed SQL case
PASS known comment false alarm
SUMMARY failures=0
old lookup exposes order의 PASS는 이전 구현이 안전하다는 뜻이 아니다. 이전 구현이 다른 사용자에게도 주문 번호를 반환한다는 문제를 확인했다는 뜻이다. 검사 이름과 조건을 함께 읽어야 한다. 같은 이유로 known missed SQL case도 보안 합격 판정으로 해석하면 안 된다.
실무에서 자주 틀리는 것
경로를 정규화하면 폴더 안이라고 생각한다
틀린 코드는 Main.java의 pathBefore다. resolve와 normalize는 경로를 계산할 뿐 접근 범위를 제한하지 않는다. 고친 코드는 pathAfter이며, 정규화한 후보 경로가 기준 Path의 구성 요소 아래에 속하는지 확인한다. 두 메서드는 완성 코드에서 실제로 호출된다.
정상 입력 order-7.txt에는 같은 결과를 내고, ../private.txt에는 동작이 달라진다. 이 두 종류의 입력을 함께 확인해야 한다. 공격성 입력을 거절하는 것만 확인하면 정상 영수증 이름까지 막는 실수를 놓칠 수 있다. 실제 파일을 다룰 때는 이 검사에 더해 링크와 파일 접근 정책을 검토한다.
로그인한 사용자라면 주문 번호로 조회해도 된다고 생각한다
틀린 코드는 orderBefore다. 사용자 인수를 받지만 사용하지 않는다. 메서드 인수에 사용자 정보가 있다는 사실만 보고 권한 확인이 있다고 판단하기 쉽다. 고친 코드인 orderAfter는 주문 소유자와 현재 사용자를 비교한 뒤 결과를 반환한다.
완성 프로그램은 user-a에게 같은 결과가 나오는지 확인하고, user-b의 접근은 거절하는지 확인한다. 보안 수정의 전후 비교에는 허용해야 하는 경우와 거절해야 하는 경우가 함께 필요하다. 실제 코드에서 관리자 예외가 있다면 별도의 정책 조건과 검사가 필요하며, 메서드 이름만으로 그 예외를 추측해서는 안 된다.
로그를 지우면 기능도 바뀐다고 생각한다
틀린 코드는 logBefore다. 처리 결과를 남기는 데 이메일 전체를 함께 사용한다. 고친 코드는 logAfter이며 주문 식별자와 처리 상태를 기록한다. 같은 주문과 이메일을 넣었을 때 업무 결과가 같고, 변경 후 기록에는 이메일이 없는지 각각 확인한다.
실무에서는 객체의 toString이나 예외 메시지를 통해 개인정보가 간접 출력되기도 한다. email이라는 변수만 찾는 현재 규칙은 이를 놓친다. 단순 탐지기에 필드 이름을 계속 추가하는 일과 실제 로그 내용의 필요성을 판단하는 일은 구분해야 한다.
목록에 없는 라이브러리를 존재하지 않는다고 단정한다
잘못된 해석은 DEPENDENCY_UNVERIFIED를 “가짜 라이브러리”로 바꿔 읽는 것이다. 고친 해석은 “현재 확인 자료로는 출처를 검증하지 못했다”다. 완성 코드에서 확인된 가져오기 문장은 경고 없이 지나가고, 가상의 제안 이름은 미확인으로 남는다.
실제 작업에서는 정확한 배포 좌표와 버전, 유지 주체, 사용 목적을 확인한다. 이름을 맞춰 설치부터 하는 행동은 피한다. 잘못 제안된 이름과 같은 배포물이 나중에 생겼더라도 그것이 원래 의도한 구현이라는 근거는 없다. 이 장의 프로그램은 이런 확인이 필요한 위치를 알려 주는 데서 역할을 끝낸다.
한눈에 보기
| 대상 | 프로그램이 하는 일 | 사람이 판단할 일 | 실행으로 확인한 것 |
|---|---|---|---|
| SQL | 한 줄의 연결 표현 탐지 | 입력 흐름과 바인딩 확인 | 두 줄 구성은 놓침 |
| 경로 | 입력 경로 구성 호출 탐지 | 실제 파일과 링크 정책 | 정상 결과 유지, 이탈 거절 |
| 권한 | 정해 둔 조회 호출 탐지 | 검증된 사용자와 소유자 관계 | 소유자 허용, 다른 사용자 거절 |
| 개인정보 | 이메일 로그 표현 탐지 | 필요한 기록 범위 | 업무 결과 유지, 이메일 제거 |
| 비밀값 | 키 문자열 직접 대입 탐지 | 실제 값 여부와 노출 대응 | 원문 없이 위치만 출력 |
| 의존성 | 확인 목록과 이름 비교 | 출처와 사용 근거 검증 | 미확인 이름을 별도 보고 |
탐지 결과를 수정할 때는 경고 개수를 줄이는 일보다 경계가 실제로 바뀌었는지를 확인한다. 변수 이름만 바꾸면 경고가 사라질 수 있지만 SQL 문자열 연결은 그대로 남는다. 반대로 안전한 경로 검사 뒤에도 동일한 resolve 호출이 있으면 경고가 남을 수 있다. 보고서와 코드의 의미가 일치하는지 검토해야 한다.
수정 후에는 허용한 입력의 업무 결과와 거절할 입력의 처리를 함께 남긴다. 다음 장에서 작업 기록을 정리할 때도 발견 위치, 판단 근거, 남은 한계를 구분해 두면 다음 사람이 같은 경고를 처음부터 해석하는 일을 줄일 수 있다.
연습 문제
- samples의 SqlPart.java에서 설명 주석을 한 줄 추가하라. SQL 결과의 줄 번호가 어떻게 바뀌어야 하는지 설명하고, 기존 줄 번호 검사를 해당 입력별 기대값 비교로 바꾸라.
- PATH 규칙이 pathAfter와 같은 안전 검사 뒤의 resolve 호출에도 반응할 수 있는 이유를 설명하라. 경고를 삭제하기 전에 사람이 확인할 근거를 두 가지 적으라.
- 주문 소유자 비교에 사용할 사용자 식별자를 요청 본문에서 받도록 바꾸면 어떤 문제가 생기는지 설명하라. 사용자가 소유자 식별자를 사칭하는 입력과 기대 결과를 적으라.
- DEPENDENCY_UNVERIFIED가 나온 이름을 확인 목록에 추가하려 한다. 추가 전에 남길 확인 기록을 정하고, 현재 프로그램이 그 기록의 진위를 검증할 수 있는지 설명하라.
정답과 해설
- SQL 문장은 3번 줄로 이동하고 나머지 다섯 결과는 2번 줄에 남는다. 모든 결과가 2번 줄인지 확인하던 조건은 더 이상 맞지 않는다. 결과에서 파일 이름과 줄 번호를 함께 비교하거나, 입력 순서에 따른 기대 줄 번호 목록 3, 2, 2, 2, 2, 2와 비교한다. 줄 번호를 규칙 안에 고정하지 않고 원문 순회에서 계산하기 때문에 탐지기 자체는 바꿀 필요가 없다.
- 현재 규칙은 한 줄의 호출 형태만 보며 앞뒤 제어 흐름을 해석하지 않는다. 정규화한 후보를 기준 Path와 비교하는지, 검사를 통과한 바로 그 후보를 실제 접근에 사용하는지 확인해야 한다. 파일을 연다면 링크 정책도 추가 확인 대상이다. 경고가 남았다는 사실만으로 안전 검사를 제거하거나 메서드 이름을 바꾸어 숨기면 안 된다.
- 공격자가 요청 본문의 사용자 값을 주문 소유자인 user-a로 적으면 비교가 통과할 수 있다. 실제 로그인 사용자가 user-b이고 요청에는 user-a가 들어 있는 상황을 검사해야 한다. 기대 결과는 접근 거절이다. 비교 기준은 요청자가 주장한 값이 아니라 서버가 검증한 로그인 사용자여야 한다.
- 정확한 이름과 배포 좌표, 버전, 출처를 확인한 위치, 사용 목적과 확인 근거를 남긴다. 현재 프로그램은 문자열 집합에 이름이 있는지만 검사하므로 기록의 진위나 배포물의 내용을 검증하지 못한다. 목록에 넣었다는 사실과 구성 요소를 검증했다는 사실을 같은 것으로 취급하지 않아야 한다.