Gitlab-ci กล้วยๆ: ใช้ Gitleaks ตรวจจับและป้องกัน secret key ไม่ให้เข้ามาในโค็ด

ในการพัฒนาซอฟแวร์ต่างๆ เรามีความจำเป็นต้องใช้งานพวก secret key หรือรหัสผ่านต่างๆ เพื่อเข้าถึงฐานข้อมูล หรือเข้าถึงระบบต่างๆ จากผู้ให้บริการที่อื่น ซึ่งโดยปกติเราจะทำกันในรูปแบบ .env เพื่อเรียกค่ามาใช้งาน และไม่ควรเขียนรหัสผ่าน หรือ secret key ลงในโค็ดเลย

แต่เราจะมั่นใจได้ยังไงว่าโค็ดเราก่อน Push ขึ้นไปยัง Repo ไม่ได้มีข้อมูลพวกนี้หลุดเข้ามา ผมเลยอยากแนะนำเครื่องที่ผมใช้ประจำนั้นคือ Gitleaks

Gitleaks คืออะไร?

Gitleaks เป็นเครื่องมือโอเพ่นซอร์สสำหรับตรวจจับและป้องกันรหัสผ่านลับ, คีย์ API, โทเค็น, คีย์ส่วนตัว ฯลฯ ในโค็ด โดยมันจะสแกนโค้ดของเราเพื่อหาข้อมูลที่ละเอียดอ่อนที่อาจกระทำโดยไม่ได้ตั้งใจ ข้อมูลลับเหล่านี้อาจเป็นรหัสผ่าน คีย์ API โทเค็น คีย์ส่วนตัว หรือชื่อไฟล์ที่น่าสงสัย หรือนามสกุลไฟล์ เช่น id_rsa, .pem, htpasswd

GitLeaks ทำงานอย่างไร?

GitLeaks ทำงานโดยการสแกนโค้ดของเราเพื่อหาไฟล์ที่ตรงกับชุดรูปแบบที่กำหนดไว้ล่วงหน้า รูปแบบเหล่านี้ได้รับการออกแบบเพื่อให้ตรงกับข้อมูลที่ละเอียดอ่อนประเภทต่างๆ เช่น รหัสผ่าน ข้อมูลลับ ไฟล์คีย์ ฯลฯ เมื่อ GitLeaks พบข้อมูลที่ตรงกัน ระบบจะแจ้งเตือน และเราสามารถดำเนินการเพื่อลบข้อมูลเหล่านี้ออกจากโค็ดได้

Gitleaks + Gitlab-ci

เราสามารถใช้งาน Gitleaks มาทำงานใน Gitlab-ci ได้เพื่อให้มันตรวจโค็ดก่อนเข้ามายัง Repo ของเรา โดยสถานกาณ์จะเป็นในรูปแบบคือ ทุกครั้งที่มีการ Merge request เข้ามา ให้มันทำการแสกนโค็ดก่อน ถ้าผ่านถึงจะยอมให้ Merge request ได้

รูปแบบไฟล์ gitlab-ci.yml จะได้แบบนี้

detect_secrets:
  stage: scan
  image:
    name: "zricethezav/gitleaks"
    entrypoint: [""]
  script:
    - gitleaks detect --source . --report-path reports/detect_secrets.json -v --no-git
  artifacts:
    reports:
      secret_detection: reports/detect_secrets.json
  rules:
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"

ให้เพิ่ม reports/ ในโฟลเดอร์หลักของโปรเจ็กต์ด้วยเพื่อไว้ให้ gitleaks เขียน report ออกมา

สามารถเพิ่มไฟล์ .gitleaks.toml ในโฟลเดอร์หลักของโปรเจ็กต์ได้

title = "Global gitleaks config"

[extend]
# useDefault will extend the base configuration with the default gitleaks config:
# https://github.com/zricethezav/gitleaks/blob/master/config/gitleaks.toml
useDefault = true

[allowlist]
description = "Global allowlisted paths, files, and stopwords"
regexTarget = "line" # gitleaks defaults to matching the Secret, let's change it to match the entire line (added in gitleaks v8.16.0)
paths = [
  '''\.gitleaks\.toml$''', # Ignoring the generated configuration file
  '''(.*?)(jpg|gif|doc|pdf|bin)$''', # Ignoring common binaries
  '''(.*?)_test\.go$''', # Ignoring Go test files
  '''(.*?)\.(spec|test)\.(j|t)s$''', # Ignoring JavaScript and TypeScript test files
  '''go\.(mod|sum)$''', # Ignoring Go manifests
  '''vendor\.json''',
  '''Gopkg\.(lock|toml)''',
  '''package-lock\.json''', # Ignoring Node/JS manifests
  '''package\.json''',
  '''composer\.json''',
  '''composer\.lock''', #Ignoring PHP manifests
  '''yarn\.lock''',
  '''Pipfile''', # Ignoring Python manifests
  '''Pipfile\.lock''',
  '''poetry\.lock''',
  '''node_modules\/''', # Ignoring Node dependencies
  '''vendor\/''', # Ignoring Go dependencies
  '''test(|s)\/''', # Ignoring test directories
]

# A more precise rule to detect Typeform API tokens
[[rules]]
description = "Typeform API token"
id = "typeform-api-token-custom"
regex = '''(?i)tfp_[a-zA-Z1-9]{40,50}_[a-zA-Z1-9]{8,16}'''
tags = [
  "typeform",
]

# A rule to detect hardcoded tokens in HTTP Bearer authentication, like "Bearer: <token>"
[[rules]]
description = "Authorization Bearer tokens"
id = "authorization-bearer-token"
regex = '''(?i)Bearer(?:\s)+(\S{8,})'''
secretGroup = 1
entropy = 3.8
tags = [
  "key",
  "HTTP",
  "bearer"
]

# The following rules look for credentials assigned to variables that its value has an entropy of more than 3 bits.
# To achieve this there\x27s a regexp for each language. The regexp checks for a variable with a suspicious name followed
# by a value assignation (for example, := in Go, = in JS, etc.). Then, looks for a group of non-space characters enclosed
# between quotes. If that group has an entropy higher than 3 bits the rule will trigger.

[[rules]]
description = "Hardcoded credentials in Go files"
id = "credentials-go"
path = '''(.*?)\.go$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:\w|\s*?)(?:=|:=)(?:\s*?)[\"\x27\x60](.{4,120}?)[\"\x27\x60]'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "go",
]

[[rules]]
description = "Hardcoded credentials in JavaScript or TypeScript files"
id = "credentials-javascript"
path = '''(.*?)\.(?:j|t)s$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:\w|\s*?)(?:=){1}(?:\s{0,10})[\"\x27`](.*?)[\"\x27`]'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "js",
]

[[rules]]
description = "Hardcoded credentials in PHP files"
id = "credentials-php"
path = '''(.*?)\.php$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:.{0,20})(?:=){1}(?:.{0,10})[\"\x27`](.{4,120})[\"\x27`]'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "php",
]

[[rules]]
description = "Hardcoded credentials in YAML files as quoted strings"
id = "credentials-yaml-quoted"
path = '''(.*?)\.y(a|)ml$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:.{0,20})(?::){1}(?:\s{0,10})(?:[\"\x27](.{4,120})[\"\x27])'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "yaml",
]
[rules.allowlist]
description = "Skip YAML Serverless variables, grabbed and concatenated values, encrypted secrets, and values with jinja2 placeholders"
regexes = [
  '''\${(?:.)+}''', # Serverless variables
  '''(?i)\(\((?:\s)*?(?:grab|concat)(?:.)*?(?:\s)*?\)\)''', # Grabbed and concatenated values
  '''(?i)!!enveloped:''', # Encrypted secrets
  '''(?:.)*?{{(?:.)*?}}''', # jinja2 placeholders
  '''ENC\[AES256_GCM,data:''', # sops secrets
]

[[rules]]
description = "Hardcoded credentials in YAML files as unquoted strings"
id = "credentials-yaml-unquoted"
path = '''(.*?)\.y(a|)ml$'''
#regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:\w|\s*?)(?::){1}(?:\s*?)((?:\w|\S)+)(?:|(?:\s*?#.*))$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:\w|\s*?)(?::){1}(?:\s*?)((?:\w|\S)+)'''
secretGroup = 1
entropy = 3.5 # A higher entropy is required for this type of match, as unquoted can trigger many false positives
tags = [
  "credentials",
  "hardcoded",
  "yaml",
]
[rules.allowlist]
description = "Skip YAML Serverless variables, grabbed and concated values, encrypted secrets, and values with jinja2 placeholders"
regexes = [
  ''':$''', # It's a YAML key (as in key: value)
  '''\${(?:.)+}''', # Serverless variables
  '''(?i)\(\((?:\s)*?(?:grab|concat)(?:.)*?(?:\s)*?\)\)''', # Grabbed and concatenated values
  '''(?i)!!enveloped:''', # Encrypted secrets
  '''(?:.)*?{{(?:.)*?}}''', # jinja2 placeholders
  '''={{$''', # jinja2 variable assignation
  '''ENC\[AES256_GCM,data:''', # sops secrets
]

[[rules]]
description = "Hardcoded credentials in YAML files as multiline strings"
id = "credentials-yaml-multiline"
path = '''(.*?)\.y(a|)ml$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:.{0,20})(?::){1}(?:\s{0,10})(?:\|(?:-|))\n(?:\s{0,10})(\S{4,120})'''
secretGroup = 1
entropy = 4
tags = [
  "credentials",
  "hardcoded",
  "yaml",
]

[[rules]]
description = "Hardcoded credentials in HCL files (*.tf)"
id = "credentials-terraform"
path = '''(.*?)\.tf$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:.{0,20})(?:=){1}(?:\s)*?"(.{4,120})"'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "hcl",
]
[rules.allowlist]
description = "Skip variable substitution"
regexes = [
  '''\${(?:.)*?}''',
]

[[rules]]
description = "Hardcoded credentials in Python files (*.py)"
id = "credentials-python"
path = '''(.*?)\.py$'''
regex = '''(?i)(?:secret|key|password|pwd|pass|token)(?:.{0,20})(?:=){1}(?:\s)*?["\x27](.{4,120})["\x27]'''
secretGroup = 1
entropy = 3
tags = [
  "credentials",
  "hardcoded",
  "hcl",
]

ถ้าอยากให้มัน ignore ไฟล์หรือโฟลเดอร์ไหนก็ให้ไปเพิ่มใน [allowlist] ในส่วนของ paths = [] ได้เลย

ตัวอย่างผลใน Merge request

เมื่อการ Merge request เข้ามายัง branch ที่เรากำหนดไป มันจะทำการแสกนและถ้าเจอก็จะ Error ออกมา

และเมื่อมาดูหน้า Merge request ก็จะเจอ Error และก็จะไม่ให้ Merge และเราสามารถโหลด report ออกมาอ่านได้ด้วย

Gitleaks + pre-commit

ใช้งานร่วมกับ pre-commit ก็ได้นะ .pre-commit-config.yaml

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks

เพียงเท่านี้ก็เป็นการป้องกันความผิดพลาดของนักพัฒนาไม่ให้นำไฟล์ Secret key หรือรหัสผ่านต่างๆ เข้ามาใน Repo ได้แล้ว...