문제 상황 (Problem)
보안 스캐너로 /home 디렉토리 전체를 스캔했더니 152개의 오탐(False Positive)이 발생했다. 대부분 Go 빌드 캐시(/home/ubuntu/.cache/go-build/)의 바이너리 파일에서 나왔다.
에러 로그
1
2
3
4
5
6
7
8
9
| [WARN] Failed to compile pattern PASSWORD_PLAINTEXT: error parsing regexp: invalid or unsupported Perl syntax: (?!)
File: /home/ubuntu/.cache/go-build/00/0012345abcdef...
Context: **************** (Credit Card Luhn)
File: /home/ubuntu/.cache/go-build/01/01abcdef...
Context: _RSA_ (SSH Private Key)
Total Findings: 152
|
문제 원인
- 빌드 캐시 스캔:
/home/ubuntu/.cache 디렉토리의 바이너리 파일 검사 - 바이너리 오탐: 무작위 바이트 코드가 우연히 패턴과 일치
- 과도한 CPU 사용: 수천 개의 불필요한 파일 읽기
원인 분석 (Root Cause)
1. 디렉토리 제외 로직 누락
최초 코드는 파일명만 체크했고, 디렉토리 경로는 체크하지 않았다.
1
2
3
4
5
6
7
8
9
10
| // ❌ 잘못된 코드 (파일명만 체크)
func (s *Scanner) shouldExclude(filename string) bool {
for _, pattern := range s.ExcludePatterns {
matched, _ := filepath.Match(pattern, filename) // 👈 파일명만
if matched {
return true
}
}
return false
}
|
2. Backend 설정은 정확했지만 무시됨
backend-portal의 설정에는 제외 패턴이 정의되어 있었다.
1
2
3
4
5
6
7
8
9
10
11
| {
"scan_config": {
"exclude_directories": [
"/home/*/.cache", // 👈 Go 빌드 캐시
"/home/*/.npm", // npm 캐시
"/home/*/.yarn", // Yarn 캐시
"/root/.cache",
"/var/lib/docker" // Docker 이미지
]
}
}
|
하지만 Agent는 이를 무시하고 모든 파일을 스캔했다.
3. 바이너리 파일의 우연한 패턴 매칭
1
2
3
4
5
6
7
8
| 바이너리 파일 내부:
0x4D 0x59 0x5F 0x52 0x53 0x41 0x5F → "MY_RSA_"
↓
SSH_PRIVATE_KEY 패턴 매칭! (오탐)
0x34 0x35 0x33 0x32 0x30 0x31 ... (16자리 숫자)
↓
CREDIT_CARD 패턴 매칭! (오탐)
|
해결 방법 (Solution)
1. 디렉토리 경로 전체 체크 (shouldExcludePath)
파일명이 아닌 전체 경로를 체크하도록 수정했다.
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
36
37
38
39
40
41
42
43
44
45
46
| // ✅ 올바른 코드 (전체 경로 체크)
func (s *Scanner) shouldExcludePath(path string) bool {
for _, pattern := range s.ExcludePatterns {
// 1. 정확히 일치
if path == pattern {
return true
}
// 2. 하위 디렉토리인지 확인 (와일드카드 없는 경우)
if !strings.Contains(pattern, "*") {
if strings.HasPrefix(path, pattern+"/") || path == pattern {
return true
}
continue
}
// 3. Glob 패턴 처리 (예: /home/*/.cache)
parts := strings.Split(pattern, "*")
if len(parts) != 2 {
continue // 단순 패턴만 지원 (/prefix/*/suffix)
}
prefix := strings.TrimSuffix(parts[0], "/")
suffix := strings.TrimPrefix(parts[1], "/")
// 경로가 prefix로 시작해야 함
if !strings.HasPrefix(path, prefix+"/") && path != prefix {
continue
}
// prefix 이후 부분 추출
afterPrefix := strings.TrimPrefix(path, prefix+"/")
// suffix가 즉시 나타나는지 확인 (예: /home/.cache)
if strings.HasPrefix(afterPrefix, suffix) {
return true
}
// suffix가 경로 중간에 나타나는지 확인 (예: /home/ubuntu/.cache/...)
if strings.Contains(afterPrefix, "/"+suffix+"/") ||
strings.HasSuffix(afterPrefix, "/"+suffix) {
return true
}
}
return false
}
|
2. filepath.Walk에서 디렉토리 건너뛰기
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
36
37
38
39
40
41
| err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // 접근 권한 없는 파일 건너뛰기
}
// ✅ 디렉토리 체크 (먼저!)
if info.IsDir() {
if s.shouldExcludePath(path) {
logger.Debug(fmt.Sprintf("Excluding directory: %s", path))
return filepath.SkipDir // 👈 하위 디렉토리 전체 건너뛰기
}
return nil
}
// ✅ 파일 경로 체크
if s.shouldExcludePath(path) {
return nil
}
// ✅ 특수 파일 제외 (device, socket, pipe 등)
if !info.Mode().IsRegular() {
logger.Debug(fmt.Sprintf("Skipping non-regular file: %s (mode: %s)", path, info.Mode()))
return nil
}
// ✅ 파일 크기 체크
if info.Size() > int64(s.MaxFileSizeMB*1024*1024) {
return nil
}
// ✅ 바이너리 파일 제외
if s.isBinaryFile(filepath.Ext(path)) {
return nil
}
// 실제 스캔
fileFindings := s.scanFile(path)
findings = append(findings, fileFindings...)
return nil
})
|
3. 바이너리 파일 확장자 필터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| func (s *Scanner) isBinaryFile(ext string) bool {
binaryExts := map[string]bool{
// 실행 파일
".exe": true, ".dll": true, ".bin": true, ".so": true,
".a": true, ".o": true,
// 압축 파일
".zip": true, ".tar": true, ".gz": true,
// 이미지/미디어
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".pdf": true, ".mp4": true,
// 컴파일된 코드
".pyc": true, ".class": true,
}
return binaryExts[strings.ToLower(ext)]
}
|
Glob 패턴 처리 상세
지원하는 패턴 형태
| 패턴 | 예시 | 매칭 경로 |
|---|
| 정확한 경로 | /var/lib/docker | /var/lib/docker, /var/lib/docker/containers/... |
| 단순 Glob | /home/*/.cache | /home/ubuntu/.cache, /home/user/.cache/go-build/... |
| 복잡한 Glob | /home/*/.*/** | ❌ 지원 안 함 (성능 이슈) |
Glob 패턴 처리 로직
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // 패턴: /home/*/.cache
parts := strings.Split("/home/*/.cache", "*")
// parts = ["/home/", "/.cache"]
prefix := "/home" // 앞부분
suffix := ".cache" // 뒷부분
// 테스트 경로: /home/ubuntu/.cache/go-build/abc
afterPrefix := "ubuntu/.cache/go-build/abc" // prefix 제거
// 체크 1: suffix로 시작하는가?
strings.HasPrefix("ubuntu/.cache/go-build/abc", ".cache") // false
// 체크 2: 중간에 /suffix/가 있는가?
strings.Contains("ubuntu/.cache/go-build/abc", "/.cache/") // true! ✅
|
실제 매칭 예시
1
2
3
4
5
6
7
8
9
10
11
12
| Pattern: /home/*/.cache
✅ Matches:
/home/ubuntu/.cache
/home/ubuntu/.cache/go-build
/home/user/.cache/npm
/home/root/.cache/yarn
❌ Does NOT match:
/home/.cache (와일드카드 부분이 빈 문자열)
/var/cache (prefix 불일치)
/home/ubuntu/project/.cache (중간 경로가 너무 김)
|
성능 최적화 효과
Before: 디렉토리 제외 없음
1
2
3
4
5
6
| Scan /home directory:
├── Total files scanned: 15,234
├── Build cache files: 12,000+ (불필요)
├── False positives: 152
├── Scan duration: 4분 23초
└── CPU usage: 85%
|
After: 디렉토리 제외 적용
1
2
3
4
5
6
| Scan /home directory:
├── Total files scanned: 3,234
├── Build cache files: 0 (제외됨)
├── False positives: 0
├── Scan duration: 58초
└── CPU usage: 25%
|
성능 향상
| 지표 | Before | After | 개선율 |
|---|
| 스캔 파일 수 | 15,234 | 3,234 | 78% 감소 |
| False Positive | 152 | 0 | 100% 제거 |
| 스캔 시간 | 263초 | 58초 | 78% 단축 |
| CPU 사용률 | 85% | 25% | 71% 감소 |
filepath.Walk Best Practices
1. 디렉토리 먼저 체크
1
2
3
4
5
6
7
8
9
10
11
12
| // ✅ 좋은 예: 디렉토리를 먼저 체크해서 SkipDir
if info.IsDir() {
if shouldExclude(path) {
return filepath.SkipDir // 하위 수천 개 파일 건너뛰기
}
return nil
}
// ❌ 나쁜 예: 파일마다 일일이 체크
if shouldExclude(path) {
return nil // 디렉토리 내 모든 파일 체크함
}
|
2. 특수 파일 제외
1
2
3
4
| // Linux에는 device file, socket 등 다양한 파일 타입이 존재
if !info.Mode().IsRegular() {
return nil // 일반 파일만 처리
}
|
특수 파일을 읽으려 하면 무한 대기 또는 에러가 발생할 수 있다.
3. 에러 무시 패턴
1
2
3
4
5
6
7
8
| err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// 접근 권한 없는 파일 등 무시
logger.Debug(fmt.Sprintf("Access denied: %s", path))
return nil // 👈 nil 반환으로 계속 진행
}
// ...
})
|
return err를 하면 전체 Walk가 중단된다.
4. 심볼릭 링크 처리
1
2
3
4
5
6
7
| // 심볼릭 링크는 IsRegular()가 false를 반환
info.Mode().IsRegular() // false for symlinks
// 실제 파일인지 확인
if info.Mode()&os.ModeSymlink != 0 {
return nil // 심볼릭 링크 건너뛰기 (순환 참조 방지)
}
|
제외해야 할 디렉토리 목록
Linux 빌드/캐시 디렉토리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| excludeDirs := []string{
// 빌드 캐시
"/home/*/.cache",
"/home/*/.npm",
"/home/*/.yarn",
"/root/.cache",
// 패키지 매니저
"/home/*/.local",
// 컨테이너
"/var/lib/docker",
"/var/lib/containerd",
// 임시 파일
"/tmp",
"/var/tmp",
}
|
Windows 제외 디렉토리
1
2
3
4
5
6
7
| $excludeDirs = @(
"$env:LOCALAPPDATA\Temp",
"$env:USERPROFILE\AppData\Local\Temp",
"$env:USERPROFILE\.nuget",
"$env:USERPROFILE\.vscode",
"C:\Windows\Temp"
)
|
실전 디버깅 팁
1. 로그로 제외 확인
1
2
3
4
| if s.shouldExcludePath(path) {
logger.Debug(fmt.Sprintf("Excluding directory: %s", path))
return filepath.SkipDir
}
|
2. 스캔 통계 출력
1
2
3
4
5
6
7
8
9
| type ScanStats struct {
TotalFiles int
SkippedDirs int
SkippedFiles int
ScannedFiles int
}
logger.Info(fmt.Sprintf("Scan stats: Total=%d, Scanned=%d, Skipped=%d",
stats.TotalFiles, stats.ScannedFiles, stats.SkippedFiles))
|
3. 오탐 패턴 분석
1
2
3
4
5
6
| for _, finding := range findings {
if strings.Contains(finding.FilePath, "/.cache/") {
logger.Warn(fmt.Sprintf("Cache file matched (False Positive?): %s",
finding.FilePath))
}
}
|
트러블슈팅
문제 1: .cache 디렉토리가 여전히 스캔됨
원인: shouldExcludePath가 호출되지 않거나 패턴 불일치
1
2
3
4
5
6
7
8
| // 디버깅
func (s *Scanner) shouldExcludePath(path string) bool {
fmt.Printf("Checking path: %s\n", path) // 👈 로그 추가
for _, pattern := range s.ExcludePatterns {
fmt.Printf(" Pattern: %s\n", pattern)
// ...
}
}
|
문제 2: 너무 많은 파일이 제외됨
원인: Glob 패턴이 너무 광범위
1
2
3
4
5
6
| // ❌ 나쁜 예: 모든 숨김 디렉토리 제외
"/home/*/.*"
// ✅ 좋은 예: 특정 캐시만 제외
"/home/*/.cache"
"/home/*/.npm"
|
문제 3: SkipDir 후에도 하위 디렉토리 스캔됨
원인: SkipDir를 반환하기 전에 하위 디렉토리가 이미 Walk에 추가됨
1
2
3
4
5
6
7
| // ✅ 올바른 순서
if info.IsDir() {
if shouldExclude(path) {
return filepath.SkipDir // 👈 디렉토리 체크 즉시 반환
}
return nil
}
|
마치며
filepath.Walk로 대용량 디렉토리를 스캔할 때는 제외 디렉토리 처리가 필수입니다.
디렉토리를 우선 체크하여 SkipDir로 하위 전체를 건너뛰고, /home/*/.cache 같은 Glob 패턴을 지원합니다. IsRegular() 체크로 특수 파일을 제외하고, 확장자로 바이너리를 사전 필터링하면 78%의 파일을 줄이고 시간도 78% 단축할 수 있습니다.
152개 오탐을 0개로 줄일 수 있었던 핵심은 빌드 캐시 디렉토리 제외였습니다. 보안 스캐너처럼 파일 시스템을 다루는 애플리케이션에서는 제외 로직이 성능과 정확도 모두에 결정적입니다.
도움이 되셨길 바랍니다! 😀