Post

[Security] 정규식 패턴으로 보안 위협 탐지하기 - False Positive 해결 실전

다수의 이메일 오탐을 제거하고 실제 위협만 탐지하도록 패턴을 개선한 경험

문제 상황 (Problem)

보안 스캐너로 시스템을 스캔했더니 3,152개의 이메일 주소가 탐지되었다. 문제는 대부분이 시스템 로그의 localhost@0-10-1-2 같은 형식이었다는 것이다.

1
2
3
4
5
6
7
=== Scan Results ===
Total findings: 3,152

Findings by type:
  PII : 3,001  (대부분 이메일 오탐)
  CREDENTIAL : 84
  PCI : 67

진짜 개인정보와 오탐을 구분할 수 없었다.


보안 패턴 종류

1. PII (Personally Identifiable Information)

개인 식별 정보

패턴예시심각도
주민등록번호 (KRN)900101-1234567CRITICAL
이메일 주소user@example.comMEDIUM
전화번호 (한국)010-1234-5678MEDIUM
SSN (미국)123-45-6789HIGH

2. PCI (Payment Card Industry)

신용카드 정보

패턴예시심각도
신용카드 번호 (Luhn)4532-0151-1283-0366CRITICAL
CVV123HIGH

3. CREDENTIAL

인증 정보

패턴예시심각도
AWS Access KeyAKIAIOSFODNN7EXAMPLECRITICAL
AWS Secret KeywJalrXUtnFEMI/K7MDENG/…CRITICAL
API Keysk_live_51HqJk2L3jK4h5…HIGH
SSH Private Key—–BEGIN RSA PRIVATE KEY—–CRITICAL
Password (Plaintext)password=”MyP@ssw0rd”HIGH

이메일 패턴 오탐 문제

문제의 RFC 5322 패턴

1
2
3
4
5
6
7
{
  "id": "EMAIL_PATTERN",
  "type": "PII",
  "name": "Email Address",
  "regex": "\\b[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\\b",
  "severity": "MEDIUM"
}

잡힌 오탐들

1
2
3
4
5
6
7
8
9
✅ 실제 이메일:
user@example.com
admin@company.co.kr

❌ 오탐 (시스템 정보):
localhost@0-10-1-2
root@node-1
systemd@192-168-1-10
package@1.2.3

해결: TLD 필수 요구

1
2
3
4
5
6
7
8
{
  "id": "EMAIL_PATTERN",
  "type": "PII",
  "name": "Email Address (Strict)",
  "regex": "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b",
  "severity": "MEDIUM",
  "version": 2
}

차이점

기존: [a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:...)*
                                          ↑ TLD 선택사항

개선: [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}
                                       ↑ TLD 필수 (.com, .kr 등)

결과

1
2
3
4
Before: 3,001개 이메일 탐지
After: 15개 이메일 탐지 (실제 개인 이메일만)

제거된 오탐: 2,986개 (99.5%)

Password Plaintext 패턴 개선

문제의 원래 패턴

1
2
3
4
5
6
7
{
  "id": "PASSWORD_PLAINTEXT",
  "type": "CREDENTIAL",
  "name": "Plaintext Password",
  "regex": "(?i)(?:password|passwd|pwd|pass)\\s*[=:]\\s*['\"]([^'\"\\s]{6,})['\"]",
  "severity": "HIGH"
}

잡힌 오탐들

1
2
3
4
5
6
7
8
9
✅ 실제 패스워드:
password="MyS3cur3P@ssw0rd"
pwd="C0mpl3xP@ss123"

❌ 오탐 (더미 데이터):
password="password"
secret="test123"
pwd="changeme"
password="TODO: set password here"

해결: 더미 값 필터링

Go의 RE2 엔진은 Negative Lookahead를 지원하지 않으므로, 넓게 매칭 후 코드로 필터링한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var dummyPasswords = []string{
    "test", "sample", "example", "dummy",
    "changeme", "todo", "placeholder",
    "123456", "password", "admin", "root",
}

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)
    }
}

결과

1
2
3
4
Before: 152개 패스워드 탐지
After: 8개 패스워드 탐지 (실제 평문 패스워드만)

제거된 오탐: 144개 (94.7%)

신용카드 패턴 - Luhn 알고리즘

단순 패턴의 문제

// ❌ 나쁜 예: 16자리 숫자만 체크
\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b

// 문제: 1234-5678-9012-3456 같은 무작위 숫자도 매칭

Luhn 알고리즘이란?

신용카드 번호의 마지막 자리(Check Digit)를 검증하는 알고리즘이다.

Luhn 알고리즘 단계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
카드 번호: 4532 0151 1283 0366

1. 오른쪽부터 시작, 홀수 번째 자리는 그대로
   6, 6, 3, 0, 8, 2, 1, 1, 5, 0, 2, 3, 4

2. 짝수 번째 자리는 2배
   6, 6*2=12, 3, 0*2=0, 8, 2*2=4, 1, 1*2=2, 5, 0*2=0, 2, 3*2=6, 4

3. 2자리 수는 각 자릿수 더하기
   6, (1+2)=3, 3, 0, 8, 4, 1, 2, 5, 0, 2, 6, 4

4. 모든 자릿수 합
   6+3+3+0+8+4+1+2+5+0+2+6+4 = 44

5. 10으로 나눈 나머지가 0이면 유효
   44 % 10 = 4 (❌ 무효)

유효한 카드: 4532-0151-1283-0360 (60으로 끝나야 함)

실제 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// JavaScript에서 Luhn 검증
function isValidLuhn(cardNumber) {
    // 숫자만 추출
    const digits = cardNumber.replace(/\D/g, '');

    let sum = 0;
    let isEven = false;

    // 오른쪽부터 시작
    for (let i = digits.length - 1; i >= 0; i--) {
        let digit = parseInt(digits[i]);

        if (isEven) {
            digit *= 2;
            if (digit > 9) {
                digit -= 9;  // 또는 digit = Math.floor(digit / 10) + (digit % 10)
            }
        }

        sum += digit;
        isEven = !isEven;
    }

    return (sum % 10) === 0;
}

// 테스트
isValidLuhn('4532015112830366');  // true (Visa)
isValidLuhn('1234567890123456');  // false (무작위 숫자)

패턴 + Luhn 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Go에서 신용카드 패턴 검증
type Pattern struct {
    ID       string `json:"id"`
    Regex    string `json:"regex"`
    Validator func(string) bool  // 추가 검증 함수
}

// 신용카드 패턴
patterns := []Pattern{
    {
        ID:    "CREDIT_CARD_LUHN",
        Regex: `\b(?:\d{4}[-\s]?){3}\d{4}\b`,
        Validator: func(match string) bool {
            return isValidLuhn(match)  // Luhn 검증
        },
    },
}

// 스캔 시
if compiled.MatchString(line) {
    match := compiled.FindString(line)
    if pattern.Validator != nil && !pattern.Validator(match) {
        continue  // Luhn 실패 시 건너뛰기
    }
    findings = append(findings, finding)
}

결과

1
2
3
4
Before: 67개 신용카드 탐지
After: 3개 신용카드 탐지 (실제 유효한 카드만)

제거된 오탐: 64개 (95.5%)

한국 주민등록번호 검증

패턴

\b\d{6}[-]\d{7}\b

// 900101-1234567
// YYMMDD-GXXXXXX
//   ↑      ↑
//  생년월일 성별(1-4)

날짜 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func isValidKRN(krn string) bool {
    parts := strings.Split(krn, "-")
    if len(parts) != 2 {
        return false
    }

    birthPart := parts[0]  // YYMMDD
    genderPart := parts[1] // GXXXXXX

    // 생년월일 파싱
    year, _ := strconv.Atoi(birthPart[0:2])
    month, _ := strconv.Atoi(birthPart[2:4])
    day, _ := strconv.Atoi(birthPart[4:6])

    // 월 검증 (1-12)
    if month < 1 || month > 12 {
        return false
    }

    // 일 검증 (1-31, 간단히)
    if day < 1 || day > 31 {
        return false
    }

    // 2월 30일, 13월 같은 무효 날짜 거르기
    // (완전한 검증은 윤년 등도 고려해야 함)

    // 성별 자릿수 검증 (1-4)
    genderDigit, _ := strconv.Atoi(string(genderPart[0]))
    if genderDigit < 1 || genderDigit > 4 {
        return false
    }

    return true
}

오탐 예시

1
2
3
4
5
6
7
8
9
10
✅ 유효:
900101-1234567 (1990년 1월 1일 남성)
851225-2456789 (1985년 12월 25일 여성)

❌ 무효:
990230-1234567 (2월 30일은 없음)
991301-1234567 (13월은 없음)
990132-1234567 (32일은 없음)
990101-0234567 (성별 0은 없음)
990101-5234567 (성별 5는 없음)

AWS Credentials 패턴

AWS Access Key

1
2
3
4
5
6
7
{
  "id": "AWS_ACCESS_KEY",
  "type": "CREDENTIAL",
  "name": "AWS Access Key ID",
  "regex": "(?i)aws[_-]?access[_-]?key[_-]?id\\s*[=:]\\s*['\"]?([A-Z0-9]{20})['\"]?",
  "severity": "CRITICAL"
}

특징

  • 항상 대문자 + 숫자
  • 정확히 20자
  • AKIA 또는 ASIA로 시작 (추가 필터링 가능)
1
2
3
4
5
6
7
func isValidAWSAccessKey(key string) bool {
    if len(key) != 20 {
        return false
    }
    // AKIA (장기 키) 또는 ASIA (임시 키)로 시작
    return strings.HasPrefix(key, "AKIA") || strings.HasPrefix(key, "ASIA")
}

AWS Secret Key

1
2
3
4
5
6
7
{
  "id": "AWS_SECRET_KEY",
  "type": "CREDENTIAL",
  "name": "AWS Secret Access Key",
  "regex": "(?i)aws[_-]?secret[_-]?access[_-]?key\\s*[=:]\\s*['\"]?([A-Za-z0-9\\/\\+\\=]{40})['\"]?",
  "severity": "CRITICAL"
}

특징

  • 대소문자 + 숫자 + /, +, =
  • 정확히 40자

SSH Private Key 패턴

패턴

1
2
3
4
5
6
7
{
  "id": "PRIVATE_KEY_CONTENT",
  "type": "CREDENTIAL",
  "name": "RSA/SSH Private Key Content",
  "regex": "-----BEGIN\\s+(?:RSA\\s+|DSA\\s+|EC\\s+|OPENSSH\\s+)?PRIVATE\\s+KEY-----",
  "severity": "CRITICAL"
}

매칭 예시

1
2
3
4
5
6
7
8
9
10
11
✅ 매칭:
-----BEGIN RSA PRIVATE KEY-----
-----BEGIN PRIVATE KEY-----
-----BEGIN DSA PRIVATE KEY-----
-----BEGIN EC PRIVATE KEY-----
-----BEGIN OPENSSH PRIVATE KEY-----

❌ 매칭 안 됨:
-----BEGIN PUBLIC KEY-----
-----BEGIN CERTIFICATE-----
-----BEGIN SSH2 PUBLIC KEY-----

스캔 디렉토리 범위 세분화

문제: /var/log 전체 스캔

1
2
3
4
5
{
  "scan_directories": [
    "/var/log"  //  CPU 부하 폭발
  ]
}

/var/log에는 수 GB의 시스템 로그가 쌓여 있어서 CPU 사용량이 폭발한다.

해결: 필요한 로그만 스캔

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "scan_directories": [
    "/var/log/nginx",
    "/var/log/apache2",
    "/var/log/httpd",
    "/var/log/tomcat",
    "/var/log/mysql",
    "/var/log/postgresql",
    "/var/log/redis",
    "/var/log/secure",      // Linux 인증 로그
    "/var/log/auth.log"     // Debian/Ubuntu 인증 로그
  ]
}

효과

1
2
3
4
5
6
7
Before: /var/log 전체 (5GB, 3만 파일)
→ 스캔 시간: 15분
→ CPU: 95%

After: 특정 로그만 (200MB, 1,500 파일)
→ 스캔 시간: 2분
→ CPU: 30%

민감 데이터 마스킹

탐지된 내용을 로그에 남길 때는 마스킹해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
func (s *Scanner) maskSensitiveData(data string) string {
    if len(data) <= 20 {
        // 짧은 데이터는 전체 마스킹
        return strings.Repeat("*", len(data))
    }

    // 앞 5자 + 가운데 마스킹 + 뒤 5자
    start := data[:5]
    end := data[len(data)-5:]
    middle := strings.Repeat("*", len(data)-10)

    return start + middle + end
}

예시

1
2
3
4
5
6
7
8
원본: password="MyS3cur3P@ssw0rd"
마스킹: passwword="MyS3c**********sw0rd"

원본: 4532-0151-1283-0366
마스킹: 4532-****************0366

원본: AKIAIOSFODNN7EXAMPLE
마스킹: AKIAI***************MPLE

실전 테스트 데이터

유효한 샘플 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 주민등록번호 (유효)
echo "Name: 홍길동, RRN: 900101-1234567" > /tmp/test-krn.txt

# 신용카드 (Luhn 통과)
echo "Card: 4532015112830366" > /tmp/test-card.txt

# AWS Credentials
echo "aws_access_key_id=AKIAIOSFODNN7EXAMPLE" > /tmp/test-aws.txt
echo "aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" >> /tmp/test-aws.txt

# 평문 패스워드
echo 'password="MyS3cur3P@ssw0rd"' > /tmp/test-password.txt

# SSH Private Key
cat > /tmp/test-key.pem <<'EOF'
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF0K9JQYjQwLQ0bGx
-----END RSA PRIVATE KEY-----
EOF

스캔 실행

1
2
3
4
5
# Linux Agent
sudo ./agent-security-scan -config /etc/agent/agent-config.json

# 결과 확인
grep "Total findings" /var/log/agent/agent.log

보고서 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
=== Security Scan Results ===
Scan duration: 02:15
Total findings: 27

Findings by severity:
  CRITICAL : 5
  HIGH : 8
  MEDIUM : 14

Findings by type:
  CREDENTIAL : 10
  PII : 15
  PCI : 2

Sample Findings:
[CRITICAL] AWS_ACCESS_KEY
  File: /home/user/.aws/credentials:3
  Context: AKIAI***************MPLE

[CRITICAL] CREDIT_CARD_LUHN
  File: /var/log/app/payment.log:127
  Context: 4532-***************366

[HIGH] PASSWORD_PLAINTEXT
  File: /opt/app/config/database.yaml:15
  Context: password="MyS3c*************w0rd"

[MEDIUM] EMAIL_PATTERN
  File: /home/user/contacts.txt:8
  Context: user@***********com

실전 팁 (Best Practices)

1. 패턴 버전 관리

1
2
3
4
5
6
7
{
  "id": "EMAIL_PATTERN",
  "name": "Email Address",
  "regex": "...",
  "version": 2,  // 👈 버전 추가
  "updated_at": "2025-12-02T00:00:00Z"
}

2. 점진적 롤아웃

1
2
3
4
5
{
  "id": "NEW_PATTERN",
  "enabled": false,  // 👈 처음엔 비활성화
  "test_mode": true  // 로그만 남기고 알림은  보냄
}

3. False Positive 피드백 루프

1
2
3
4
5
1. 오탐 발견
2. 패턴 개선
3. 재배포
4. 검증
5. 반복

4. 제외 파일 설정

1
2
3
4
5
6
7
8
{
  "exclude_file_types": [
    ".exe", ".dll", ".bin",  // 바이너리
    ".jpg", ".png", ".gif",  // 이미지
    ".zip", ".tar", ".gz",   // 압축 파일
    ".pyc", ".class"         // 컴파일된 코드
  ]
}

마치며

보안 패턴 탐지에서 가장 중요한 것은 False Positive 제거입니다.

이메일 패턴에는 TLD를 필수로 요구하여 99.5%의 오탐을 제거했고, 신용카드는 Luhn 알고리즘 검증으로 95.5%의 오탐을 제거했습니다. 패스워드는 더미 값 필터링으로 94.7%의 오탐을 제거했고, 스캔 범위를 필요한 디렉토리만으로 제한하여 CPU 사용량을 68% 감소시켰습니다.

수천 개에서 수십 개로 줄인 핵심은 “패턴 + 검증 로직”의 조합이었습니다. 정규식만으로는 충분하지 않고, 추가 검증(Luhn, 날짜, 더미 값 필터링)이 필수입니다.

도움이 되셨길 바랍니다! 😀

This post is licensed under CC BY 4.0 by the author.