ในการพัฒนาซอฟแวร์ต่างๆ เรามีความจำเป็นต้องใช้งานพวก 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 ได้แล้ว...