Post

[Go] filepath.Walk로 대용량 디렉토리 스캔 최적화하기

빌드 캐시 제외와 glob 패턴 처리로 152개 오탐을 0개로 줄인 실전 경험

문제 상황 (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

문제 원인

  1. 빌드 캐시 스캔: /home/ubuntu/.cache 디렉토리의 바이너리 파일 검사
  2. 바이너리 오탐: 무작위 바이트 코드가 우연히 패턴과 일치
  3. 과도한 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%

성능 향상

지표BeforeAfter개선율
스캔 파일 수15,2343,23478% 감소
False Positive1520100% 제거
스캔 시간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개로 줄일 수 있었던 핵심은 빌드 캐시 디렉토리 제외였습니다. 보안 스캐너처럼 파일 시스템을 다루는 애플리케이션에서는 제외 로직이 성능과 정확도 모두에 결정적입니다.

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

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