16.10.2026 20 мин чтения Просмотров:1

Секреты в Unity-проекте: ключи подписи, токены и CI без утечек

Где в Unity-проекте на самом деле утекают пароли и токены и как собрать релиз без ручного ввода ключей. Загрузчик .env, подпись Android, CI на GitHub Actions, проверка билда на утечки и план действий, если ключ уже в истории git.

Секреты в Unity-проекте: ключи подписи, токены и CI без утечек

В типичном Unity-проекте, который дорос до регулярных релизов, секретов больше, чем кажется. Пароль от Android keystore и сам файл .keystore, JSON сервисного аккаунта Google Play для автоматической загрузки, ключ App Store Connect API, логин сборочного аккаунта Steam, токен бота, который пишет в чат команды о новой сборке, ключ платного API для аналитики или генерации контента. Каждое из этого лежит у кого-то в папке «Загрузки», в личных сообщениях или прямо в репозитории.

Проблема обычно всплывает в один из трёх моментов: в команду приходит новый человек и ему нужно «просто собрать билд», репозиторий передают фрилансеру или открывают публично, или ключ находится в истории git через полгода после того, как его «удалили». Ниже разобрано, как организовать работу с секретами в Unity так, чтобы сборка шла без ручного ввода паролей, секреты не попадали ни в git, ни в билд, а утечку можно было быстро закрыть.

Что считать секретом, а что нет

Прежде чем что-то прятать, нужно провести границу. Главный критерий простой: всё, что попадает в сборку игры, публично. Строки из кода лежат в Assembly-CSharp.dll (Mono) или в global-metadata.dat (IL2CPP) в открытом виде, ассеты распаковываются общедоступными инструментами, сетевые запросы видны в любом перехватчике трафика. Если значение должно быть в клиенте, чтобы игра работала, спрятать его невозможно, и считать его секретом бессмысленно.

Поэтому секреты в Unity-проекте почти всегда относятся к инфраструктуре, а не к игре:

  • пароли keystore и alias для подписи Android-сборок, а также сам файл keystore;
  • JSON сервисного аккаунта Google Play, ключ .p8 для App Store Connect API, сертификаты .p12;
  • учётные данные аккаунта для загрузки сборок в Steam через SteamPipe;
  • лицензия Unity и учётные данные аккаунта для CI;
  • токены ботов, вебхуков, SSH-ключи серверов, на которые выкладываются билды;
  • ключи платных API, которые тарифицируются за запрос.

А вот адрес игрового сервера, URL каталога Addressables, идентификатор проекта Firebase, DSN системы сбора ошибок или публичный ключ рекламной сети секретами не являются. Их можно хранить в ScriptableObject прямо в репозитории, по одному на окружение (dev, staging, production). Защищать такие значения нужно на стороне сервиса: ограничить ключ Firebase по идентификатору приложения, настроить в системе сбора ошибок приём событий только от своих сборок.

Отдельная категория — ключи, которые разработчик хотел бы держать в клиенте, но не должен. Ключ платного API, вшитый в игру, будет извлечён, и счёт придёт вам. Об этом подробнее в разделе про серверный прокси.

Где секреты утекают в Unity-проекте

Утечки редко выглядят как «кто-то закоммитил файл passwords.txt». Чаще секрет оседает там, где его никто не ищет.

Сериализованные поля и ScriptableObject

Самый частый случай: в компонент или ScriptableObject добавили поле apiToken, заполнили его в инспекторе, и значение ушло в .asset или .unity файл обычным текстом YAML. Если ассет лежит в Resources или на него ссылается сцена из сборки, токен окажется ещё и в билде. Папка Editor спасает от второго, но не от первого: ассет в Assets/Editor в сборку не попадёт, а в git попадёт.

Файлы, которые приносят SDK

Плагины публикации и сервисов часто просят положить в проект файл с ключом: JSON сервисного аккаунта для автозагрузки в Google Play, конфиги с серверными ключами. Документация SDK пишет «поместите файл в Assets/Plugins», и он уходит в репозиторий вместе с плагином. Перед коммитом нового SDK стоит просмотреть, какие файлы он добавил.

Keystore и ProjectSettings

Unity не сохраняет пароли keystore в ProjectSettings/ProjectSettings.asset, поэтому их приходится вводить заново после каждого перезапуска редактора. Это не баг, а мера защиты, но из-за неё пароли часто записывают в файл рядом с проектом. Путь к keystore при этом сохраняется в настройках, и сам файл .keystore или .jks нередко лежит в корне репозитория.

Логи CI

GitHub Actions и большинство CI маскируют значения секретов в логах, но только точные совпадения. Если скрипт выведет секрет в base64, по частям или в виде URL с подставленным токеном, маскировка не сработает. Сборочный лог Unity в режиме -logFile - пишет много, включая всё, что выводит ваш код через Debug.Log, поэтому сборочные скрипты не должны логировать значения, только факт их наличия.

Файл .env и шаблон в репозитории

Самый простой рабочий формат для локальной разработки — файл .env в корне проекта (рядом с папками Assets и ProjectSettings, а не внутри Assets, иначе Unity начнёт создавать для него .meta). В репозиторий он не попадает, а вместо него коммитится шаблон .env.example с именами переменных и пустыми значениями. Новый участник копирует шаблон, заполняет его или выгружает из хранилища секретов, и всё работает.

# Секреты окружения
.env
.env.*
!.env.example

# Ключи подписи и учётные данные сервисов
*.keystore
*.jks
*.p12
*.p8
*.mobileprovision
service-account*.json

Шаблон стоит сопровождать комментариями: где взять значение и кто выдаёт доступ. Через год никто не вспомнит, откуда брался STEAM_BUILD_USER.

# Подпись Android. Keystore лежит в хранилище секретов, путь — локальный
ANDROID_KEYSTORE_PATH=
ANDROID_KEYSTORE_PASS=
ANDROID_KEYALIAS_NAME=
ANDROID_KEYALIAS_PASS=

# Уведомления о сборках в чат команды
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=

Проверьте, что в проекте уже нет закоммиченных файлов из этого списка: .gitignore не удаляет из репозитория то, что попало туда раньше. Если что-то нашлось, недостаточно выполнить git rm --cached: значение осталось в истории, и это уже ситуация утечки (о ней в отдельном разделе).

Загрузчик секретов для редактора

Сборочным скриптам нужен один источник значений, который работает и на машине разработчика, и в CI. Удобная схема: сначала смотреть переменные окружения процесса, а если там пусто, читать .env. Тогда в CI секреты приходят через переменные окружения (так их передаёт любой CI), а локально берутся из файла, и код сборки об этой разнице не знает.

Класс должен жить в папке Editor или в сборке (asmdef) только для платформы Editor. Так он гарантированно не попадёт в билд и не потянет за собой строки с именами переменных.

using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

namespace Project.Editor.Build
{
    public static class EnvSecrets
    {
        private static Dictionary<string, string> _fileValues;

        private static string ProjectRoot =>
            Directory.GetParent(Application.dataPath).FullName;

        // Переменные окружения важнее файла: в CI значения приходят оттуда.
        public static string Get(string key)
        {
            string value = Environment.GetEnvironmentVariable(key);
            if (!string.IsNullOrEmpty(value))
                return value;

            _fileValues ??= Parse(Path.Combine(ProjectRoot, ".env"));
            return _fileValues.TryGetValue(key, out value) && value.Length > 0 ? value : null;
        }

        public static string Require(string key) =>
            Get(key) ?? throw new InvalidOperationException(
                $"Не задан {key}. Добавьте его в .env или в переменные окружения CI.");

        // Сбросить кэш, если .env поменяли при открытом редакторе.
        public static void Reload() => _fileValues = null;

        private static Dictionary<string, string> Parse(string path)
        {
            var result = new Dictionary<string, string>(StringComparer.Ordinal);
            if (!File.Exists(path))
                return result;

            foreach (string rawLine in File.ReadAllLines(path))
            {
                string line = rawLine.Trim();
                if (line.Length == 0 || line[0] == '#')
                    continue;

                // Строки вида «export KEY=value» встречаются в файлах из shell-скриптов.
                if (line.StartsWith("export ", StringComparison.Ordinal))
                    line = line.Substring(7).TrimStart();

                int eq = line.IndexOf('=');
                if (eq <= 0)
                    continue;

                string key = line.Substring(0, eq).Trim();
                string value = line.Substring(eq + 1).Trim();
                result[key] = Unquote(value);
            }

            return result;
        }

        private static string Unquote(string value)
        {
            if (value.Length >= 2 && (value[0] == '"' || value[0] == '\'')
                && value[value.Length - 1] == value[0])
            {
                string inner = value.Substring(1, value.Length - 2);
                // В двойных кавычках поддерживаем \n, чтобы хранить многострочные ключи.
                return value[0] == '"' ? inner.Replace("\\n", "\n") : inner;
            }

            // Без кавычек всё после « #» считается комментарием.
            int comment = value.IndexOf(" #", StringComparison.Ordinal);
            return comment >= 0 ? value.Substring(0, comment).TrimEnd() : value;
        }
    }
}

Разбор сделан чуть строже, чем «разделить по знаку равенства», не случайно. Пароли часто содержат = и #, а приватные ключи многострочные. Наивный парсер на таких значениях молча обрежет пароль, и сборка упадёт с невнятной ошибкой подписи.

Одного не стоит делать: записывать прочитанные значения в ассеты проекта, например в ScriptableObject с настройками сборки, «чтобы было удобнее». Ассет сохранится на диск, и при следующем коммите секрет окажется в репозитории. Значения должны жить только в памяти на время сборки.

Подпись Android без ручного ввода паролей

Подставлять пароли keystore удобнее всего прямо перед сборкой, через IPreprocessBuildWithReport. Этот колбэк вызывается при любой сборке: из меню File → Build Settings, из своего скрипта или из CI. Значит, нигде не придётся вспоминать, что «перед билдом нужно нажать пункт меню».

using Project.Editor.Build;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;

public sealed class AndroidSigningStep : IPreprocessBuildWithReport, IPostprocessBuildWithReport
{
    public int callbackOrder => 0;

    public void OnPreprocessBuild(BuildReport report)
    {
        if (report.summary.platform != BuildTarget.Android)
            return;

        // Development-сборки можно подписывать отладочным ключом Unity.
        if ((report.summary.options & BuildOptions.Development) != 0)
            return;

        try
        {
            PlayerSettings.Android.useCustomKeystore = true;
            PlayerSettings.Android.keystoreName = EnvSecrets.Require("ANDROID_KEYSTORE_PATH");
            PlayerSettings.Android.keystorePass = EnvSecrets.Require("ANDROID_KEYSTORE_PASS");
            PlayerSettings.Android.keyaliasName = EnvSecrets.Require("ANDROID_KEYALIAS_NAME");
            PlayerSettings.Android.keyaliasPass = EnvSecrets.Require("ANDROID_KEYALIAS_PASS");
        }
        catch (System.InvalidOperationException e)
        {
            // Падаем до начала сборки, а не через десять минут на этапе Gradle.
            throw new BuildFailedException(e.Message);
        }
    }

    public void OnPostprocessBuild(BuildReport report)
    {
        if (report.summary.platform != BuildTarget.Android)
            return;

        PlayerSettings.Android.keystorePass = string.Empty;
        PlayerSettings.Android.keyaliasPass = string.Empty;
    }
}

Здесь два нюанса. Первый: путь к keystore, в отличие от паролей, Unity сохраняет в ProjectSettings.asset. Если у каждого разработчика свой абсолютный путь, файл настроек будет меняться при каждой сборке и засорять диффы. Решение — договориться об одинаковом относительном пути (например, папка Keys рядом с проектом, добавленная в .gitignore) или не трогать путь локально, а задавать его только в CI.

Второй нюанс: OnPostprocessBuild не вызывается, если сборка упала. Пароли в этом случае останутся в памяти редактора до его закрытия. Для локальной машины это приемлемо: в файлы они всё равно не пишутся. Но если сборку запускает ваш собственный скрипт, надёжнее очищать их в блоке finally вокруг BuildPipeline.BuildPlayer.

Сборка из командной строки и в CI

Когда сборка запускается из командной строки, становится видно, что каждый шаг не требует человека. Unity запускается в режиме -batchmode и вызывает статический метод через -executeMethod:

using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;

public static class BuildScript
{
    public static void BuildAndroidRelease()
    {
        EditorUserBuildSettings.buildAppBundle = true;

        var options = new BuildPlayerOptions
        {
            scenes = EditorBuildSettings.scenes
                .Where(s => s.enabled)
                .Select(s => s.path)
                .ToArray(),
            locationPathName = "Builds/Android/game.aab",
            target = BuildTarget.Android,
            options = BuildOptions.None,
        };

        BuildReport report;
        try
        {
            report = BuildPipeline.BuildPlayer(options);
        }
        finally
        {
            // Очищаем пароли даже при упавшей сборке.
            PlayerSettings.Android.keystorePass = string.Empty;
            PlayerSettings.Android.keyaliasPass = string.Empty;
        }

        bool ok = report.summary.result == BuildResult.Succeeded
                  && BuildLeakScanner.Scan(report.summary.outputPath);

        // В batchmode код выхода — единственное, что видит CI.
        if (Application.isBatchMode)
            EditorApplication.Exit(ok ? 0 : 1);
    }
}
Unity.exe -batchmode -nographics -quit -projectPath . -buildTarget Android ^
  -executeMethod BuildScript.BuildAndroidRelease -logFile -

В GitHub Actions удобнее всего воспользоваться готовым действием GameCI game-ci/unity-builder: оно поднимает нужную версию Unity в контейнере, активирует лицензию и умеет само подставлять keystore. Секреты задаются в Settings → Secrets and variables → Actions репозитория, а файл keystore хранится там же в виде строки base64.

name: Android release

on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - uses: game-ci/unity-builder@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
        with:
          targetPlatform: Android
          androidExportType: androidAppBundle
          androidKeystoreName: release.keystore
          androidKeystoreBase64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
          androidKeystorePass: ${{ secrets.ANDROID_KEYSTORE_PASS }}
          androidKeyaliasName: ${{ secrets.ANDROID_KEYALIAS_NAME }}
          androidKeyaliasPass: ${{ secrets.ANDROID_KEYALIAS_PASS }}

      - uses: actions/upload-artifact@v4
        with:
          name: android-build
          path: build/Android

Base64-строку получают из файла командой base64 -w 0 release.keystore в Linux или [Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) в PowerShell. Если вместо встроенной сборки GameCI нужен свой BuildScript, действие принимает параметр buildMethod, а секреты для него передаются через блок env и читаются тем же EnvSecrets.Get: переменные окружения у него в приоритете.

Отдельно о том, кто может запускать такой workflow. Секреты репозитория доступны любому workflow в нём, а значит, и любому, у кого есть право пушить ветки. Для релизных ключей стоит завести Environment (например, production) с обязательным подтверждением запуска и ограничением по веткам, и хранить секреты подписи в нём, а не на уровне репозитория.

Проверка, что секрет не попал в билд

Даже при аккуратной работе секрет может оказаться в сборке: кто-то временно вписал токен в сериализованное поле, отладочный скрипт сохранил конфиг в StreamingAssets. Ловить это лучше автоматически, сразу после сборки. Идея простая: взять значения всех известных секретов и поискать их байты в файлах билда.

Искать нужно в двух кодировках. В IL2CPP строковые литералы хранятся в global-metadata.dat в UTF-8, а в Mono-сборках строки из кода лежат в куче #US в UTF-16. APK и AAB — это ZIP-архивы, поэтому их нужно распаковывать на лету, иначе сжатое содержимое не найдётся.

using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
using Project.Editor.Build;
using UnityEngine;

public static class BuildLeakScanner
{
    private static readonly string[] SecretKeys =
    {
        "ANDROID_KEYSTORE_PASS",
        "ANDROID_KEYALIAS_PASS",
        "TELEGRAM_BOT_TOKEN",
        "STEAM_BUILD_PASSWORD",
    };

    // Возвращает true, если утечек нет.
    public static bool Scan(string outputPath)
    {
        var needles = new List<(string key, byte[] bytes)>();
        foreach (string key in SecretKeys)
        {
            string value = EnvSecrets.Get(key);
            if (value == null || value.Length < 8)
                continue; // короткие значения дадут ложные срабатывания

            needles.Add((key, Encoding.UTF8.GetBytes(value)));
            needles.Add((key, Encoding.Unicode.GetBytes(value)));
        }

        // Для Windows-сборки outputPath указывает на exe, сканируем всю папку.
        IEnumerable<string> files = Directory.Exists(outputPath)
            ? Directory.EnumerateFiles(outputPath, "*", SearchOption.AllDirectories)
            : File.Exists(outputPath) && IsArchive(outputPath)
                ? new[] { outputPath }
                : Directory.EnumerateFiles(Path.GetDirectoryName(outputPath), "*", SearchOption.AllDirectories);

        bool clean = true;
        foreach (string file in files)
        {
            if (IsArchive(file))
            {
                using ZipArchive zip = ZipFile.OpenRead(file);
                foreach (ZipArchiveEntry entry in zip.Entries)
                {
                    using var buffer = new MemoryStream();
                    using (Stream s = entry.Open())
                        s.CopyTo(buffer);
                    clean &= Check($"{file}!{entry.FullName}", buffer.ToArray(), needles);
                }
            }
            else
            {
                clean &= Check(file, File.ReadAllBytes(file), needles);
            }
        }

        return clean;
    }

    private static bool IsArchive(string path)
    {
        string ext = Path.GetExtension(path).ToLowerInvariant();
        return ext == ".apk" || ext == ".aab" || ext == ".zip";
    }

    private static bool Check(string name, byte[] data, List<(string key, byte[] bytes)> needles)
    {
        bool clean = true;
        foreach ((string key, byte[] bytes) in needles)
        {
            if (IndexOf(data, bytes) < 0)
                continue;

            // Само значение в лог не пишем, только имя переменной.
            Debug.LogError($"Секрет {key} найден в сборке: {name}");
            clean = false;
        }
        return clean;
    }

    private static int IndexOf(byte[] haystack, byte[] needle)
    {
        int last = haystack.Length - needle.Length;
        for (int i = 0; i <= last; i++)
        {
            int j = 0;
            while (j < needle.Length && haystack[i + j] == needle[j])
                j++;
            if (j == needle.Length)
                return i;
        }
        return -1;
    }
}

У проверки есть пределы, и о них нужно помнить. Она не видит содержимое AssetBundle и Addressables, сжатых LZ4 или LZMA: строки там сжаты. Она ищет только те секреты, которые перечислены в списке, и только в точном виде. Наивный поиск по байтам медленный на сборках в несколько гигабайт, хотя для мобильного билда на сотню мегабайт он укладывается в секунды. Это страховка от типовых ошибок, а не гарантия, но типовые ошибки она ловит именно тогда, когда их ещё можно исправить без отзыва ключей.

Если при компиляции редактор не находит класс ZipFile, значит, в профиле .NET проекта нет ссылки на System.IO.Compression.FileSystem. Её добавляют строкой -r:System.IO.Compression.FileSystem.dll в файле Assets/csc.rsp.

Хранилище секретов для команды

Файл .env решает задачу для одного человека. Как только разработчиков двое, возникает вопрос, как передать значения и как их потом поменять у всех сразу. Пересылать .env в мессенджере — значит оставить копию секретов в истории переписки навсегда.

Вариантов несколько, и выбор зависит от размера команды.

  • Только секреты CI (GitHub Actions, GitLab CI/CD Variables, Unity Build Automation). Подходит, если релизные сборки собирает только CI, а локально разработчикам ключи подписи не нужны. Это самый простой и часто достаточный вариант для небольшой команды.
  • Менеджер секретов: Infisical, Doppler, 1Password с CLI, HashiCorp Vault. Значения хранятся централизованно, доступ выдаётся по ролям и окружениям, а на локальную машину они выгружаются командой. Подходит командам, где несколько человек собирают релизы или работают с серверами.
  • Зашифрованный файл в репозитории (SOPS с age или GPG). Секреты лежат в git, но в зашифрованном виде, и расшифровать их может только тот, чей ключ добавлен. Удобно для маленьких команд без бюджета на сервисы, но выдача и отзыв доступа требуют перешифровки файла.

У менеджеров секретов похожий рабочий цикл: разработчик входит через CLI, привязывает папку проекта к проекту в хранилище и выгружает значения нужного окружения. В Infisical это infisical login, затем infisical init (создаёт файл .infisical.json только с идентификатором проекта, его можно коммитить) и infisical export --env=dev --format=dotenv > .env. У Infisical облачная версия есть в двух регионах, US и EU, с раздельными аккаунтами, поэтому CLI должен смотреть на тот же регион, где вы регистрировались, иначе он «не найдёт» проект.

Ещё удобнее вообще не создавать .env на диске, а передавать значения процессу Unity через переменные окружения. И Infisical (infisical run -- команда), и 1Password (op run -- команда) умеют запускать команду с подставленными секретами. Загрузчик из раздела выше их подхватит, потому что переменные окружения у него в приоритете.

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

Ключи платных API не должны жить в клиенте

Отдельно стоит сказать о соблазне, который появляется вместе с внешними API: генерация текста или изображений, распознавание речи, платные облачные функции. Ключ кладут в ScriptableObject, игра ходит в API напрямую, всё работает. Через неделю после релиза ключ извлекают из сборки и используют для своих задач, а платите за это вы.

Обфускация и шифрование строки ключа в клиенте не помогают: чтобы сделать запрос, игра должна расшифровать ключ, и его можно перехватить в памяти или в сетевом трафике. Правильная схема — свой сервер между игрой и API. Ключ хранится только на сервере, клиент отправляет туда запрос со своим токеном игрока, а сервер проверяет лимиты, добавляет ключ и пересылает запрос дальше.

using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

public sealed class DialogueService : MonoBehaviour
{
    // Адрес своего сервера — не секрет, его можно хранить в конфиге окружения.
    [SerializeField] private string endpoint = "https://api.example-game.com/v1/dialogue";

    [Serializable]
    private struct DialogueRequest
    {
        public string npcId;
        public string playerLine;
    }

    public IEnumerator RequestReply(string sessionToken, string npcId, string playerLine,
        Action<string> onReply)
    {
        string json = JsonUtility.ToJson(new DialogueRequest { npcId = npcId, playerLine = playerLine });

        using var request = new UnityWebRequest(endpoint, UnityWebRequest.kHttpVerbPOST);
        request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");
        // Токен сессии игрока, выданный вашим сервером при входе. Ключа внешнего API здесь нет.
        request.SetRequestHeader("Authorization", "Bearer " + sessionToken);
        request.timeout = 30;

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
            onReply(request.downloadHandler.text);
        else
            Debug.LogWarning($"Диалог недоступен: {request.responseCode} {request.error}");
    }
}

Кроме безопасности такой прокси даёт контроль: лимит запросов на игрока, кэш одинаковых ответов, возможность сменить поставщика API без обновления клиента. Для небольшого проекта это может быть одна серверная функция на любом облаке. Затраты на неё несопоставимы с риском получить счёт за чужой трафик.

Автоматическая проверка перед коммитом

Лучшая утечка — та, что не дошла до удалённого репозитория. Сканеры секретов вроде gitleaks проверяют изменения перед коммитом и находят типовые форматы: токены GitHub и Slack, ключи облачных провайдеров, приватные ключи в PEM, строки, похожие на пароли с высокой энтропией. Подключить его к проекту проще всего через фреймворк pre-commit:

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

После pre-commit install каждый коммит будет проверяться локально. Хук у разработчика можно обойти или не установить, поэтому ту же проверку стоит продублировать в CI на каждый pull request. На GitHub дополнительно включите Secret scanning и Push protection в настройках безопасности репозитория: они блокируют push с распознанными токенами популярных сервисов.

В Unity-проектах у сканеров бывают ложные срабатывания на длинные GUID в .meta файлах и на сериализованные хеши в ассетах. Их лучше исключить в конфиге .gitleaks.toml по путям, а не отключать проверку целиком.

Если секрет всё-таки утёк

Порядок действий здесь важен, и первым шагом идёт не чистка истории.

  1. Отзовите и замените секрет. Перевыпустите токен, смените пароль, отзовите ключ API. С этого момента утёкшее значение бесполезно, и спешка уходит. Всё остальное делается после.
  2. Проверьте, использовали ли его. Журналы доступа сервиса, расходы по API, входы в аккаунт, загруженные сборки.
  3. Уберите значение из истории git. Обычное удаление файла новым коммитом не помогает: значение доступно в любом старом коммите. Историю переписывают через git filter-repo --invert-paths --path .env или BFG Repo-Cleaner, затем делают force push, а все участники заново клонируют репозиторий. Копии в форках, кэше GitHub и локальных клонах коллег при этом останутся, именно поэтому первый шаг — замена секрета.
  4. Выясните, как это произошло, и закройте путь: добавьте файл в .gitignore, правило в сканер, перенесите значение в хранилище.

С ключами подписи Android ситуация особая. Если приложение использует Play App Signing, сами вы подписываете сборки только ключом загрузки (upload key), а ключ, которым Google подписывает приложение для пользователей, хранится у Google. Утёкший ключ загрузки можно сбросить через Play Console, запросив новый. Если же приложение старое и подписано вашим собственным ключом без Play App Signing, заменить его для существующего приложения очень трудно. Это ещё одна причина включить Play App Signing для всех новых проектов и хранить резервную копию keystore в двух независимых местах: потерять ключ так же неприятно, как допустить его утечку.

С чего начать

Если в проекте пока нет никакой системы, не обязательно внедрять всё сразу. Порядок, который даёт наибольший эффект за минимальное время:

  1. Пройдитесь по репозиторию и истории сканером (gitleaks detect по всей истории) и замените всё найденное.
  2. Добавьте в .gitignore правила для .env, keystore и файлов учётных данных, закоммитьте .env.example.
  3. Положите в Editor загрузчик секретов и шаг подписи Android через IPreprocessBuildWithReport. Пароли больше не придётся вводить руками.
  4. Разделите конфигурацию и секреты: адреса серверов и публичные идентификаторы в ScriptableObject по окружениям, учётные данные только в .env и CI.
  5. Настройте сборку релизов в CI с секретами в защищённом окружении и проверкой билда на утечки.
  6. Когда людей в команде станет больше двух, перенесите секреты в менеджер секретов и выдавайте доступ по ролям.

Хорошая проверка результата: новый разработчик клонирует репозиторий, выполняет одну команду для получения своих секретов и собирает рабочий билд, не задав ни одного вопроса про пароли. А если завтра репозиторий станет публичным, вам не придётся ничего отзывать.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также