Git: сканирование репозиториев с Gitleaks и запуск из Jenkins

Автор: | 08/12/2021
 

Утечка конфиденциальных данных, таких как пароли или RSA-ключи в репозиторий Github, даже в приватный — очень неприятное событие, и хотелось бы иметь представление о том, кто и что пушит в репозитории нашей Github-организации.

Утилиты сканирования

Для проверки репозиториев имеется достаточно много утилит:

  • Gittyleaks — выглядит неплохо, но последнее обновление репозитория 2 года тому
  • Repo Supervisor — есть WebUI, можно потрогать, требует AWS Lambda, классно интегрируется с Github
  • Truffle Hog — CLI only, можно потрогать
  • Git Hound — плагин для git, умеет сканировать только перед коммитом, репозитории в самом Github не умеет
  • Gitrob — последнее обновление три года тому, увы
  • Watchtower — и вроде бы интересное решение, вроде бы даже с WebUI, но на сайте даже прайса нет, так что в пролёте
  • GitGuardian — замечательное решение, но стоимость зашкаливает
  • gitleaks — CLI only, можно потрогать

Из списка остаются только Truffle Hog и gitleaks, но Truffle Hog как-то не зашёл по своей документации.

Repo Supervisor интересен, потрогаем его попозже.

Из них:

  • Gitleaks: просто сканер — указываем URL репозитория, он его сканирует, генерирует отчёт в JSON
  • Repo Supervisor: два варианта:
    • просто сканер локальных каталогов
    • сканирование репозитория по PullRequest/push/etc

Значит, для Gitleaks можно сделать кронджобу в Jenkins или Kubernetes, которая будет брать список репозиториев, сканировать и генерировать отчёты, которые потом можем слать в Slack.

Но тут вопрос куда именно в Slack слать — репозиториев много, команды в проекте разные. Сделать общий канал Слака, в который будут слаться уведомления обо всех репозиториях — девелоперы начнут их игнорить.

Кроме того, Gitleaks умеет в Github Actions — см. тут>>>, но у нас не все команды пользуются ими. Также, умеет в pre-commit hooks.

Планирование

Пока остановимся на варианте с Jenkins, хотя тут тоже возможны варианты:

  • триггерить джобу через GitHub Pull Request Builder
  • триггерить через GitHub hook trigger for GITScm polling или Poll SCM
  • просто запускать по расписанию

Для начала, сделаем вариант с запуском по расписанию, а в будущем будем посмотреть.

Что у нас есть:

  • порядка 200 репозитриев
  • около 10 команд разработчиков — бекенд, фронтенд, аналитика, iOS и Android приложения, гейминг, девопсы

Что мы можем сделать с Gitleaks:

  • для каждой команды создадим задачу в Jenkins
  • джоба принимает параметром список репозиториев, которые относятся к команде
  • сделаем отдельные слак-каналы на каждую команду, куда заинвайтим нужных людей из конкретной команды
  • раз в сутки выполняем проверку, и шлём алерты в нужный канал

Сначала посмотрим руками как оно работает, а потом приступим к автоматизации.

Ручной запуск Gitleaks

Устанавливаем. В Arch Linux — из AUR:

yay -S gitleaks

Github token

Переходим в настройки пользователя, создаём токен:

Разрешаем доступ к repo:

 

Запускаем — передём токен, URL репозитория, добавляем --verbose, результаты сохраняем в файл:

gitleaks --access-token=ghp_C6h***3z5 --repo-url=https://github.com/example/BetterBI --verbose --report=analytics-repo.json
...
INFO[0036] scan time: 32 seconds 756 milliseconds 672 microseconds
INFO[0036] commits scanned: 1893
WARN[0036] leaks found: 111

Проверяем:

less analytics-repo.json

Например:

...
 {
  "line": "  \"private_key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADA***CCaM=\\n-----END PRIVATE KEY-----\\n\",",
  "lineNumber": 5,
  "offender": "-----BEGIN PRIVATE KEY-----",
  "offenderEntropy": -1,
  "commit": "0f047f0cca3994b3465821ef133dbd3c8b55ee7a",
  "repo": "BetterBI",
  "repoURL": "https://github.com/example/BetterBI",
  "leakURL": "https://github.com/example/BetterBI/blob/0f047f0cca3994b3465821ef133dbd3c8b55ee7a/adslib/roas_automation/example-service-account.json#L5",
  "rule": "Asymmetric Private Key",
  "commitMessage": "DT-657 update script for subs and add test\n\nDT-657 create test for check new json (add new subs)",
  "author": "username",
  "email": "example@users.noreply.github.com",
  "file": "adslib/roas_automation/example-service-account.json",
  "date": "2021-05-11T19:46:46+03:00",
  "tags": "key, AsymmetricPrivateKey"
 },
...

Тут:

  • line: что именно обнаружено
  • offender: какое правило затриггирилось
  • commit: ID коммита

Обнаружения выполняются на основе регулярных выражений, описанных в default.go.

Также, можно создать свой конфиг, и передавать его Gitleaks.

К примеру, приватный ключ был найден по правилу Asymmetric Private Key:

[[rules]]
    description = "Asymmetric Private Key"
    regex = '''-----BEGIN ((EC|PGP|DSA|RSA|OPENSSH) )?PRIVATE KEY( BLOCK)?-----'''
    tags = ["key", "AsymmetricPrivateKey"]

Можем сделать отдельные конфиги для каждого репозитория или команды, и в зависимости от того, как будем запускать — передавать их через Kubernetes ConfigMap или просто файлами в Jenkins.

Jenkins job

Pipeline script

Итак, для каждой команды сделаем отдельную Jenkins job, в которую будем передавать список репозиториев команды, которые надо проверить.

Циклы в Groovy

Когда-то делал аналогичное решение, см. Go: проверка списка публичных репозиториев в Github и уведомления в Slack, там тоже в цикле надо было перебрать список, и на Go получилось как-то проще. С Groovy пришлось погуглить.

Создаём новую джобу, назовём её по имени команды, тип Pipeline:

В джобе создаём строковый параметр, в котором перечисляем список репозиториев команды, тут их два:

Пишем скрипт.

Задаём переменную $repos_list, в которую передаём переменную окружения $TEAM_REPOS, а затем через split() по запятой разделяем элементы. Потом с помощью цикла for выводим их список:

node('master') {

  def repos_list = "${env.TEAM_REPOS}".split(',')

  for (repo in repos_list) {
    println repo
  }

}

Запускаем:

Jenkins Docker plugin

Наш стандартный подход к билдам в Jenkins — через Docker-контейнеры, что бы не засорять хост-систему лишним пакетами.

Добавляем ещё один параметр, тип Password, записываем сюда токен Github:

Используем Jenkins Docker Plugin, с помощью которого создаём контейнер с Gitleaks, которому параметрами передаём токен, репозиторий, и куда записывать репорт, при этом файл репорта будет содержать имя репозитория:

node('master') {
      
  def repos_list = "${env.TEAM_REPOS}".split(',')

  for (repo in repos_list) {
    stage("Repository ${repo}") {
      docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
        sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json"
      }
    }
  }
}

Тут для каждого репозитория из списка repos_list создаём отдельный stage, в имени которого тоже используем имя репозитрия — сразу будет видно, в каком именно репозитории возникла проблема.

Запускаем, проверяем:

Ага… И тут возникает проблема — билд останавливается на первом же упавшем stage, так как Gitleaks в репозитории test нашёл секреты, выдал ошибку — код 1, и не проверяет следующие репозитории:

Игнорирование ошибок в Jenkins stage{}

Используем конструкцию try/catch: вызываем каждый stage в отдельном try, в случае ошибок — ловим её с catch, и продолжаем билд:

node('master') {
  
  def repos_list = "${env.TEAM_REPOS}".split(',')
  def build_ok = true
  
  for (repo in repos_list) {
    try {
      stage("Repository ${repo}") {
        docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
          sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json"
        }
      }
    } catch(e) {
        currentBuild.result = 'FAILURE'
    }
  }
}

Запускаем:

Отлично — теперь следующий стейдж выполняется, несмотря на результат работы предыдущего.

Уведомления в Slack из Jenkins

Следующий шаг — отправка уведомлений в Slack.

Используем плагин Slack Notification. Что бы иметь возможность отправки файлов — потребуется Slack Bot, см. документацию тут>>>.

Создание Slack Bot

Переходим в Slack Apps, создаём новое приложение:

Переходим в Permissions:

Для бота добавляем:

  • files:write
  • chat:write

Переходим в OAuth & Permissions, устанавливаем бота в Slack:

Сохраняем полученный токен:

Jenkins credentials

Добавляем токен в Jenkins — переходим в Manage Jenkins > Manage Credentials:

Добавляем новый:

Выбираем тип Secret file, заполняем:

В Slack создаём новый канал:

Инвайтим туда нового бота:

Добавляем в Jenkins-скрипт новую функцию notifySlack(), и вызываем её в catch{}:

def notifySlack(String buildStatus = 'STARTED') {

    // Build status of null means success.
    buildStatus = buildStatus ?: 'SUCCESS'

    def color
    //change for another slack chanel
    def token = 'gitleaks-slack-bot'

    if (buildStatus == 'STARTED') {
        color = '#D4DADF'
    } else if (buildStatus == 'SUCCESS') {
        color = '#BDFFC3'
    } else if (buildStatus == 'UNSTABLE') {
        color = '#FFFE89'
    } else {
        color = '#FF9FA1'
    }

    def msg = "${buildStatus}: `${env.JOB_NAME}` #${env.BUILD_NUMBER}:\n${env.BUILD_URL}"
    slackSend(color: color, message: msg, tokenCredentialId: token, channel: "#devops-alarms-gitleaks-analytics")
}


node('master') {
  
  def repos_list = "${env.TEAM_REPOS}".split(',')
  
  for (repo in repos_list) {
    try {
      stage("Repository ${repo}") {
        docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
          sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json"
        }
      }   
    } catch(e) {
        currentBuild.result = 'FAILURE'
        notifySlack(currentBuild.result)
    } 
  }     
}

jenkins.plugins.slack.StandardSlackService postToSlack Response Code: 404

Запускаем билд, и:

12:42:39 ERROR: Slack notification failed. See Jenkins logs for details.

Смотрим лог в https://<JENKINS_URL>/log/all:

Переходим в Manage Jenkins > Configure System, находим настройки Slack-плагина, ставим Custom slack app bot user:

Не обращаем тут внимания на Credential, это дефолтные, мы их переопределяем в скрипте.

В Advanced убираем Override url:

Пробуем ещё раз — и всё работает:

Загрузка файла в Slack

Теперь надо добавить отправку репорта в Slack.

Используем slackUploadFile(), добавляем:

def notifySlack(String buildStatus = 'STARTED', reportFile) {

    // Build status of null means success.
    buildStatus = buildStatus ?: 'SUCCESS'

    def color
    //change for another slack chanel
    def token = 'gitleaks-slack-bot'

    if (buildStatus == 'STARTED') {
        color = '#D4DADF'
    } else if (buildStatus == 'SUCCESS') {
        color = '#BDFFC3'
    } else if (buildStatus == 'UNSTABLE') {
        color = '#FFFE89'
    } else {
        color = '#FF9FA1'
    }

    def msg = "${buildStatus}: `${env.JOB_NAME}` #${env.BUILD_NUMBER}:\n${env.BUILD_URL}"
    slackSend(color: color, message: msg, tokenCredentialId: token, channel: "#devops-alarms-gitleaks-analytics")
    slackUploadFile(credentialId: token, channel: "#devops-alarms-gitleaks-analytics", filePath: "${reportFile}")
}


node('master') {
  
  def repos_list = "${env.TEAM_REPOS}".split(',')
  
  for (repo in repos_list) {
    try {
      stage("Repository ${repo}") {
        docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
          sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json"
        }
      }   
    } catch(e) {
        currentBuild.result = 'FAILURE'
        notifySlack(currentBuild.result, "analytics-${repo}-repo.json")
    } 
  }     
}

Канал потом вынесем в параметры.

Тут в функцию notifySlack() добавляем второй параметр — reportFile, а затем в его вызове notifySlack() передаём вторым аргументом имя файла с репортом.

Запускаем, проверяем Slack:

Осталось запускать задачу по расписанию:

Настройки Gitleaks

Коммиты для проверки

Сейчас Gitleasks выполняет полную проверку репозитория — все коммиты, всю историю.

Если мы будем запускать его каждый день — то и в репорте будем получать все старые проблемные коммиты.

Как вариант — сделать две джобы: в одной выполнять полное сканирование, и запускать раз в неделю, а во второй — проверять коммиты только за последние сутки.

Т.е. запускаем джобу каждый день в 12.00, что бы все разработчики были на месте, и видели сообщение в Slack, а в джобе выполняем проверку всех коммитов за предыдущий день.

Для этого у Gitleaks есть опция --commit-since — добавляем в наш скрипт создание переменной yesterday с вчерашней датой с помощью вызова метода previous() класса Date(), а потом дату подставляем в --commit-since:

...

node('master') { 
  
  def repos_list = "${env.TEAM_REPOS}".split(',')
  def yesterday = new Date().format( 'yyyy-MM-dd' ).previous()

  println yesterday
  
  for (repo in repos_list) {
    try {
      stage("Repository ${repo}") {
        docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
          sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json --commit-since=${yesterday}"
        }
      }
    } catch(e) {
        currentBuild.result = 'FAILURE'
        notifySlack(currentBuild.result, "analytics-${repo}-repo.json")
    }
  }
}

Файл правил Gitleaks

И второй момент — добавить файл со своими настройками правил для проверки.

Используем опцию --repo-config-path, и в каждом репозитории создадим файл правил.

Включаем туда часть дефолтных, и хочется добавить поиск паролей:

...

[[rules]]
    description = "Plaintext password"
    regex = '''(?i)pass*[a-z]{5}[:|=]? +["|'](.*)["|']'''
    tags = ["password", "PlainTextPassword"]

[allowlist]
    description = "Allowlisted files"
    files = ['''^\.?gitleaks.config$''']

В регулярке (?i)pass*[a-z]{5}[:|=]? +["|'](.*)["|'] ищем строки, начинающиеся с pass, после которой могут быть «:» или «=», за которыми может быть пробел, потом одинарная или двойная кавычка, затем любой текст, и затем снова одинарная или двойная кавычка.

Вроде должно срабатывать на варианты создания переменных с паролями в репозиториях. По крайней мере в этой джобе, у аналитиков, в основном Python, так что подойдёт.

Ага, а вот вариант с именем переменной типа db_password не продумал, потом надо будет обновить.

Хотя нет — вроде тоже сработает:

Сохраняем его в репозиторий как .github/gitleaks.config, а в джобе добавляем его использование:

...
        docker.image('zricethezav/gitleaks').inside('--entrypoint=""') {
          sh "gitleaks --access-token=${GITHUB_TOKEN} --repo-url=https://github.com/example/${repo} --verbose --report=analytics-${repo}-repo.json --commit-since=${yesterday} --repo-config-path=.github/gitleaks.config"
        }
...

В целом, на этом всё.