문제 상황 (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
|
진짜 개인정보와 오탐을 구분할 수 없었다.
보안 패턴 종류
개인 식별 정보
| 패턴 | 예시 | 심각도 |
|---|
| 주민등록번호 (KRN) | 900101-1234567 | CRITICAL |
| 이메일 주소 | user@example.com | MEDIUM |
| 전화번호 (한국) | 010-1234-5678 | MEDIUM |
| SSN (미국) | 123-45-6789 | HIGH |
2. PCI (Payment Card Industry)
신용카드 정보
| 패턴 | 예시 | 심각도 |
|---|
| 신용카드 번호 (Luhn) | 4532-0151-1283-0366 | CRITICAL |
| CVV | 123 | HIGH |
3. CREDENTIAL
인증 정보
| 패턴 | 예시 | 심각도 |
|---|
| AWS Access Key | AKIAIOSFODNN7EXAMPLE | CRITICAL |
| AWS Secret Key | wJalrXUtnFEMI/K7MDENG/… | CRITICAL |
| API Key | sk_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, 날짜, 더미 값 필터링)이 필수입니다.
도움이 되셨길 바랍니다! 😀