[Go] 정규식 엔진 RE2와 PCRE의 차이점 - 보안 스캐너 개발 트러블슈팅
Go의 regexp 패키지가 RE2 기반이라 Lookaround를 지원하지 않는 문제와 해결 방법
문제 상황 (Problem)
보안 스캐너를 Go로 개발하던 중, PASSWORD_PLAINTEXT 패턴을 검증하는 과정에서 예상치 못한 에러가 발생했다.
1
2
3
4
5
6
7
// 실패한 정규식 패턴
regex := `(?i)\b(?:password|passwd|pwd|secret)\b\s*[=:]\s*['"]` +
`((?!(?:test|sample|example|dummy|changeme|todo|placeholder|123456|password))` +
`[^'"\s]{8,})['"]`
compiled, err := regexp.Compile(regex)
// Error: error parsing regexp: invalid or unsupported Perl syntax: (?!)
에러 메시지
1
[WARN] Failed to compile pattern PASSWORD_PLAINTEXT: error parsing regexp: invalid or unsupported Perl syntax: (?!)
더미 데이터(password="password", secret="test123")를 제외하려고 전방 부정 탐색(Negative Lookahead) (?!...)을 사용했는데, Go의 regexp 패키지에서 지원하지 않는다는 것을 알게 되었다.
원인 분석 (Root Cause)
RE2 vs PCRE
| 특징 | RE2 (Go) | PCRE (Perl, Python, JavaScript) |
|---|---|---|
| 엔진 | Google RE2 | Perl Compatible Regular Expressions |
| 복잡도 | O(n) 선형 시간 보장 | 최악의 경우 지수 시간 (Catastrophic Backtracking) |
| Lookaround | ❌ 미지원 | ✅ 지원 ((?=...), (?!...), (?<=...), (?<!...)) |
| Backreference | ❌ 미지원 | ✅ 지원 (\1, \2) |
| 성능 | 빠르고 예측 가능 | 패턴에 따라 느려질 수 있음 |
| 보안 | ReDoS 공격 방어 | ReDoS 취약 |
Go가 RE2를 선택한 이유
- 성능 보장: 입력 크기에 비례하는 선형 시간 복잡도
- 메모리 안정성: 백트래킹이 없어 스택 오버플로우 방지
- 보안: ReDoS(Regular Expression Denial of Service) 공격 방어
하지만 이로 인해 Lookaround와 Backreference 같은 고급 기능을 사용할 수 없다.
해결 방법 (Solution)
1. Lookahead 제거 및 단순화
Lookahead를 사용하지 않고, 패턴을 단순화했다.
1
2
3
4
5
6
7
// Before: PCRE 스타일 (Go에서 작동 안 함)
regex := `(?i)\b(?:password|passwd|pwd|secret)\b\s*[=:]\s*['"]` +
`((?!(?:test|sample|example|dummy))` + // ❌ Negative Lookahead
`[^'"\s]{8,})['"]`
// After: RE2 호환 (Go에서 작동)
regex := `(?i)\b(?:password|passwd|pwd|secret)\b\s*[=:]\s*['"]([^'"\s]{8,})['"]`
2. 후처리 필터링 (Post-processing)
정규식으로 넓게 매칭한 후, Go 코드에서 더미 값을 필터링한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var dummyPasswords = []string{
"test", "sample", "example", "dummy",
"changeme", "todo", "placeholder",
"123456", "password",
}
func isDummyPassword(value string) bool {
lower := strings.ToLower(value)
for _, dummy := range dummyPasswords {
if strings.Contains(lower, dummy) {
return true
}
}
return false
}
// 매칭 후 필터링
if compiled.MatchString(line) {
match := compiled.FindString(line)
if !isDummyPassword(match) {
// 실제 패스워드로 간주
findings = append(findings, finding)
}
}
3. 실제 프로젝트 적용 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// scanner.go:54-64
func NewScanner(patterns []Pattern, scanDirs []string, maxSize int, exclude []string) *Scanner {
s := &Scanner{
Patterns: patterns,
ScanDirectories: scanDirs,
MaxFileSizeMB: maxSize,
ExcludePatterns: exclude,
compiledPatterns: make(map[string]*regexp.Regexp),
}
// Pre-compile regex patterns
for _, p := range patterns {
if p.Enabled {
compiled, err := regexp.Compile(p.Regex)
if err != nil {
logger.Warn(fmt.Sprintf("Failed to compile pattern %s: %v", p.ID, err))
continue // ❌ 에러 발생 시 해당 패턴 건너뛰기
}
s.compiledPatterns[p.ID] = compiled
}
}
return s
}
언어별 정규식 엔진 비교
| 언어 | 엔진 | Lookaround | Backreference | 특징 |
|---|---|---|---|---|
| Go | RE2 | ❌ | ❌ | 선형 시간 보장, ReDoS 방어 |
| Python | re (PCRE) | ✅ | ✅ | re 모듈은 백트래킹 사용 |
| Python | re2 (optional) | ❌ | ❌ | pip install re2 사용 시 RE2 엔진 |
| JavaScript | V8 Regex | ✅ | ✅ | PCRE 스타일, ES2018부터 Lookbehind 지원 |
| Java | java.util.regex | ✅ | ✅ | PCRE 스타일 |
| Rust | regex crate | ❌ | ❌ | RE2 기반 (fancy-regex로 Lookaround 가능) |
| C++ | std::regex | ✅ | ✅ | ECMAScript, POSIX 등 선택 가능 |
실전 팁 (Best Practices)
1. 정규식 테스트 시 엔진 확인
Go 정규식을 테스트할 때는 regex101.com에서 Golang flavor를 선택해야 한다.
2. 복잡한 패턴은 코드로 처리
Lookaround가 필요한 경우:
- Option 1: 정규식을 단순화하고 Go 코드로 후처리
- Option 2:
github.com/dlclark/regexp2라이브러리 사용 (PCRE 호환)
1
2
3
4
5
// Option 2: regexp2 사용 (주의: 성능 저하 및 ReDoS 가능)
import "github.com/dlclark/regexp2"
re := regexp2.MustCompile(`(?!test)password`, 0)
isMatch, _ := re.MatchString("password123")
3. 성능 vs 기능 트레이드오프
| 선택 | 장점 | 단점 |
|---|---|---|
| RE2 (기본) | 빠름, 안전 | Lookaround 불가 |
| regexp2 | PCRE 호환 | 느림, ReDoS 위험 |
| 후처리 | 안전하고 명확 | 코드 길이 증가 |
대부분의 경우 RE2 + 후처리가 가장 좋은 선택이다.
마치며
Go의 regexp 패키지는 RE2 기반으로 Lookaround를 지원하지 않습니다. 이는 성능과 보안을 위한 의도적인 설계 선택입니다.
Go의 정규식은 RE2를 사용하므로 Lookaround((?=...), (?!...))와 Backreference(\1)를 지원하지 않습니다. 선형 시간 보장을 위해 고급 기능을 희생했지만, 정규식을 단순화하고 Go 코드로 후처리하는 방식으로 해결할 수 있습니다. 정규식 패턴을 작성할 때는 사용하는 언어의 정규식 엔진을 반드시 확인해야 합니다.
보안 스캐너처럼 대량의 파일을 빠르게 스캔해야 하는 경우, RE2의 선형 시간 보장은 큰 장점입니다. Lookaround가 필요한 복잡한 로직은 Go 코드로 명확하게 표현하는 것이 더 나은 선택입니다.
도움이 되셨길 바랍니다! 😀
