Аудит и публикация приватного репозитория: секреты в дереве/истории, персданные, следы заказчиков, NDA. Брать для "выложить репозиторий", "открыть публично". Черновик без истории избыточен.
8 stars
0 votes
0 copies
0 views
Added September 29, 2026
developmentpythonbashnodegitapi
Works with
vscode
api
Security analysis
A96/100
mediumInstalls packages at runtime which could introduce malicious dependencies
Installs into .claude/skills of the current project.
Are you the author of Repo Publication?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/dewil-repo-publication)
---
name: repo-publication
description: 'Аудит и публикация приватного репозитория: секреты в дереве/истории, персданные, следы заказчиков, NDA. Брать для "выложить репозиторий", "открыть публично". Черновик без истории избыточен.'
---
# Подготовка репозитория к публикации
Задача: довести приватный репозиторий до состояния, в котором его можно открыть публично - без утечки секретов, персональных данных и следов заказчиков. Значения в угловых скобках - из таблицы подстановки в конце.
## Порядок и стоп-правило
Аудит идет по шагам 1-6, и только потом публикация. **Любая находка на шагах 1-3 - стоп:** сначала устранение и, если секрет реально утек, ротация, и лишь затем возврат к аудиту. Публиковать "и потом почистим" нельзя: с момента публикации содержимое считается разошедшимся, а удаление из истории уже не помогает. Два действия необратимы и выполняются **только после явного подтверждения пользователя**: переписывание истории (шаг 4А) и сама публикация (шаг 6).
Аудировать надо ровно то, что публикуется. Для пути Б проверяй рабочее дерево будущего первого коммита; проверки приватной истории можно пропустить. Для пути А перед шагом 1 забери с сервера **все** ветки - именно явным refspec: `git fetch --all` уважает refspec клона, и single-branch клон так и остался бы со своей единственной веткой, - потом сверь и раскрой shallow:
```bash
git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags --prune
git ls-remote --heads origin # сверь глазами: каждая серверная ветка видна локально
git rev-parse --is-shallow-repository # true -> git fetch --unshallow
```
Без этого локальный `git log --all` ничего не говорит о серверных ветках, которых в клоне нет, а смена видимости (шаг 6) открывает их все.
Отдельная дисциплина на весь аудит: **в контекст агента, stdout/stderr инструментов и лог сессии попадают только пути файлов, номера строк и счетчики находок**. Это относится к рабочему дереву, всей истории, секретам, ПД, следам заказчиков и авторству коммитов. Значения, строки совпадений и даже замаскированные фрагменты запрещены. Нельзя сначала вывести сырые находки, а затем скрыть их в ответе. Ограничение числа строк вывода не скрывает данные.
**Значения смотрит только человек локально, вне чата и лога сессии агента.** Агент не читает значения "точечно" и не просит скопировать их в чат. Человек сообщает только адреса, счетчики и результат разбора без значений. Ненулевой счетчик - повод для разбора, а не доказанная утечка; до разбора всех кандидатов аудит не завершен.
## Шаг 1. Секреты в рабочем дереве и в истории
Хук gitleaks не заменяет аудит истории при публикации.
Рабочее дерево - половина дела: удаленный секрет может остаться в старом коммите. Команды ниже выполняет агент из корня репозитория в одной bash-сессии. Функция проверяет рабочее дерево и снимки **всех коммитов всех локальных refs**. Вывод - JSON с путем, номером строки для совпадения в содержимом и счетчиком; у находки по имени файла номера строки нет. Отдельные счетчики дерева и истории включают нули. Содержимое и ошибки дочерних команд перехватываются до вывода. При ошибке проверки выводится только счетчик ошибок, аудит останавливается.
```bash
cd <REPO_PATH>
audit_pattern() {
python3 - "$@" <<'PY'
import json
import os
import re
import subprocess
import sys
from pathlib import Path
def git(*args):
return subprocess.run(["git", *args], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True).stdout
def emit(**fields):
print(json.dumps(fields, ensure_ascii=True))
try:
names = sys.argv[1] == "--names"
if sys.argv[1] == "--file":
source = Path(sys.argv[2]).read_text().strip()
else:
source = sys.argv[2] if names else sys.argv[1]
if not source:
raise ValueError
pattern = re.compile(source, re.I)
totals = {"tree_count": 0, "history_count": 0}
# AUDIT_PATHS - необязательный фильтр путей (regex): паттерн ищется только в совпавших файлах.
only = re.compile(os.environ["AUDIT_PATHS"], re.I) if os.environ.get("AUDIT_PATHS") else None
def scan(path, data, scope):
if only and not only.search(path):
return
if names:
if pattern.search(Path(path).name):
emit(file=path, count=1)
totals[scope] += 1
return
# Бинарные объекты отдельно разбирает человек, их нельзя пропускать.
if b"\0" in data:
emit(file=path, binary_count=1)
return
for line, value in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
count = len(pattern.findall(value))
if count:
emit(file=path, line=line, count=count)
totals[scope] += count
def walk_error(error):
raise error
for root, dirs, files in os.walk(".", onerror=walk_error):
dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "venv", ".venv", "dist", "build"}
and not Path(root, d).is_symlink()]
for name in files:
path = Path(root, name)
if name == ".git" or (path.is_symlink() and not names):
continue
scan(str(path), None if names else path.read_bytes(), "tree_count")
seen = set()
seen_names = set()
blobs = []
for commit in git("rev-list", "--all").splitlines():
for entry in git("ls-tree", "-rz", commit.decode()).split(b"\0"):
if not entry:
continue
meta, path = entry.split(b"\t", 1)
mode, kind, oid = meta.split()
if kind != b"blob":
continue
if names:
if path not in seen_names:
scan(os.fsdecode(path), None, "history_count")
seen_names.add(path)
elif oid not in seen:
seen.add(oid)
blobs.append((oid, path))
if blobs:
result = subprocess.run(
["git", "cat-file", "--batch"],
input=b"".join(oid + b"\n" for oid, _ in blobs),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True,
).stdout
offset = 0
for oid, path in blobs:
end = result.index(b"\n", offset)
actual_oid, kind, size = result[offset:end].split()
if actual_oid != oid or kind != b"blob":
raise ValueError
start = end + 1
offset = start + int(size)
if result[offset:offset + 1] != b"\n":
raise ValueError
scan(os.fsdecode(path), result[start:offset], "history_count")
offset += 1
if offset != len(result):
raise ValueError
emit(**totals)
except Exception:
emit(error_count=1)
sys.exit(1)
PY
}
# 1.1 Секреты в дереве и всей истории, включая удаленные файлы.
audit_pattern '(api[_-]?key|secret|passwd|password|token|Bearer |sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY)'
# 1.2 Подозрительные имена в дереве и истории, в том числе пустые файлы и
# файлы с нестандартным секретом. Совпадение по имени требует разбора человеком.
audit_pattern --names '(^\.env.*$|\.pem$|\.key$|\.p12$|^id_(rsa|ed25519|ecdsa).*$|\.ppk$|^\.netrc$|^\.npmrc$|^\.pypirc$|^\.git-credentials$|credentials)'
```
Счетчик содержимого истории считает каждый blob один раз по oid; для него выводится первый найденный путь. Поиск по именам проверяет каждый уникальный путь, включая переименования одинакового blob. Поиск по имени ловит подозрительные файлы независимо от содержимого. Для локального разбора человек сам находит нужный коммит по пути, не передавая содержимое агенту.
`gitleaks` - основной инструмент этого шага, поиск выше - минимальная сетка, а не гарантия. Если сканер недоступен, аудит не завершен. Подробный отчет сканера не выводить агенту. `--redact` - дополнительная защита, ее одной недостаточно: разрешенные поля извлекаются до любого вывода. Следующий пример проверяет всю историю и рабочее дерево, хранит отчеты только во временном каталоге вне репозитория с правами 700 и удаляет их после чтения. stdout/stderr сканера перехватываются, даже при его отказе.
```bash
python3 - <<'PY'
import json
import subprocess
import sys
import tempfile
from pathlib import Path
try:
with tempfile.TemporaryDirectory(prefix="publication-audit-") as directory:
for mode in ("history", "tree"):
report = Path(directory) / (mode + ".json")
cmd = ["gitleaks", "detect", "--source", ".", "--redact",
"--report-format", "json", "--report-path", str(report)]
cmd += ["--log-opts=--all"] if mode == "history" else ["--no-git"]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode not in (0, 1):
raise RuntimeError
findings = json.loads(report.read_text())
if not isinstance(findings, list):
raise ValueError
for finding in findings:
path, line = finding["File"], finding["StartLine"]
if not isinstance(path, str) or type(line) is not int or line < 1:
raise ValueError
print(json.dumps({"file": path, "line": line, "count": 1}, ensure_ascii=True))
print(json.dumps({mode + "_count": len(findings)}))
except Exception:
print('{"error_count": 1}')
sys.exit(1)
PY
```
Git LFS проверяется по всей истории, даже если `.gitattributes` уже удален. Найти следы настройки без вывода значений:
```bash
audit_pattern 'filter=lfs'
```
При наличии LFS человек локально, вне сессии агента, перечисляет объекты через `git lfs ls-files --all`, получает недостающие объекты и проверяет **их содержимое**, а не только пойнтеры. Агент получает лишь пути и счетчики проверенных объектов и находок. Недоступные объекты означают незавершенный аудит.
**Подтвержденный реальный секрет считается скомпрометированным.** Сначала ротировать (выпустить новый, старый отозвать), затем чистить репозиторий. Удаление из истории без ротации недостаточно. См. `rules/secrets-handling.md`.
## Шаг 2. Персональные данные в тестах и фикстурах
Проверить дерево и всю историю, включая тесты, фикстуры и удаленные документы:
```bash
audit_pattern '(\+7|8)[ (-]?9[0-9]{2}[ )-]?[0-9]{3}[ -]?[0-9]{2}[ -]?[0-9]{2}'
AUDIT_PATHS='test|fixture' audit_pattern '\b[0-9]{10,12}\b' # только тесты и фикстуры: по всему дереву это тысячи id и таймстемпов
audit_pattern '\b(4[0-9]{12,18}|5[1-5][0-9]{14}|2[0-9]{15})\b'
audit_pattern '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
```
Для каждого кандидата человек локально определяет "синтетика или реальные данные". Если для ответа нужны значения, агент их не читает. Признаки синтетики: номера из документации, заведомо невалидные контрольные суммы, вымышленные домены (`example.com` и аналоги). Реальные данные заменить сгенерированными, **включая историю** (шаг 4); в README отметить: "эталонные наборы синтетические".
Документы-образцы, дампы и скриншоты человек просматривает локально вне сессии, включая исторические версии. Нулевые счетчики текстового поиска не заменяют этот разбор.
## Шаг 3. Следы заказчиков и NDA
```bash
# Шаблоны заказчиков и доменов человек готовит локально в файле вне репозитория.
# Не печатать содержимое файла.
audit_pattern --file <ФАЙЛ_ШАБЛОНОВ>
audit_pattern '(10\.[0-9]{1,3}|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)[0-9.]+'
audit_pattern '\.(local|internal|corp|lan)\b'
# Авторство всей истории - только счетчик уникальных пар, без имен и почт.
python3 - <<'PY'
import json
import subprocess
import sys
try:
result = subprocess.run(["git", "log", "--all", "--format=%an <%ae>"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
print(json.dumps({"author_count": len(set(result.stdout.splitlines()))}))
except Exception:
print('{"error_count": 1}')
sys.exit(1)
PY
```
`<ФАЙЛ_ШАБЛОНОВ>` содержит регулярное выражение для заказчиков и внутренних доменов проекта. Содержимое файла остается вне контекста агента. Проверить и то, что текстовый поиск не видит: скриншоты README, входные документы, бинарные вложения. Это делает человек локально вне сессии, по дереву и историческим версиям. Имена боевых серверов в скриптах заменить переменными окружения с нейтральными значениями по умолчанию.
Авторство не имеет адреса "файл:строка", поэтому агент получает только счетчик. Человек локально проверяет корпоративные адреса и посторонних авторов и сообщает решение о публикации без имен и почт. Сам по себе ненулевой счетчик авторов не означает утечку.
## Шаг 4. Что делать, если история грязная
Шаги 1-3 по истории обязательны для пути А. Для пути Б достаточно рабочего дерева, которое станет первым коммитом. Значения исторических находок, бинарные объекты и LFS смотрит **только человек локально вне чата и лога сессии агента**. Агент не выполняет просмотр содержимого исторических файлов. Для пути А человек перечисляет все когда-либо добавленные бинарные файлы, включая файлы без характерного расширения, и проверяет их версии; текстовый поиск и признак нулевого байта не гарантируют обнаружение каждого бинарного формата. Для пути Б тот же разбор ограничен файлами будущего первого коммита. До завершения разбора публикуемых файлов аудит не считается завершенным.
И в обоих путях: правки шагов 1-3 должны быть **закоммичены**, рабочее дерево чистое (`git status`) - и срез, и переписывание работают с коммитами, незакоммиченное обезличивание в публикацию не попадет.
**А. Переписать историю** - `git filter-repo` (актуальная замена `filter-branch`) или BFG. Подходит, когда история ценна: видна эволюция решения, коммиты осмысленные. Выполняется **только после явного подтверждения пользователя** и после свежей резервной копии репозитория целиком: filter-repo меняет историю безвозвратно (и в качестве защиты сам удаляет remote `origin` - это учтено в шаге 6).
```bash
pip install git-filter-repo
git filter-repo --path <ПУТЬ_К_ФАЙЛУ_С_СЕКРЕТОМ> --invert-paths
git filter-repo --replace-text <ФАЙЛ_ПРАВИЛ> # строки вида: старое==>НОВОЕ
```
**Б. Опубликовать чистый срез** - новый репозиторий одним коммитом от текущего состояния HEAD. Быстро и безопасно, история остается приватной. Срез собирай в **новой папке** - оригинал не трогай (удаление `.git` в оригинале уничтожило бы приватную историю безвозвратно):
```bash
PUB=$(mktemp -d)
git -C <REPO_PATH> archive HEAD | tar -x -C "$PUB"
cd "$PUB" && git init && git add -Af && git commit -m "Initial public release"
# контроль полноты: срез обязан совпасть со списком трекнутых файлов оригинала
diff <(git -C <REPO_PATH> ls-files | sort) <(git -C "$PUB" ls-files | sort)
```
`git archive` берет только трекнутые файлы - случайный untracked мусор в срез не попадает. `git add -Af` (не `-A`): в архив приехал и `.gitignore`, и без `-f` он молча выкинул бы из среза файлы, которые в оригинале были трекнуты вопреки ignore-правилам; `diff` по `ls-files` ловит любую другую потерю. Ограничение: сабмодули и содержимое LFS-объектов в `git archive` не попадают - если они есть, срез собирается иначе (например, полный клон + удаление `.git` в клоне) и состав проверяется тем же `diff`. Дальнейшие шаги (5-6) выполняются в `$PUB`.
Для витрины путь Б обычно достаточен: читателю интересен результат и качество кода, а не двести коммитов "fix typo". Путь А выбирают, когда история сама по себе аргумент - регулярность работы, дисциплина коммитов.
## Шаг 5. Витрина: README, лицензия, чистота
Без README публичный репозиторий читается как свалка скриптов, и смысл публикации теряется. README отвечает на четыре вопроса, желательно в этом порядке:
1. **Какую задачу решает** - в двух предложениях, на языке проблемы, а не реализации.
2. **Как устроено** - схема потока данных, ключевые решения и почему именно так.
3. **Критерии качества** - чем доказывается, что решение работает: какие проверки, на каком наборе, какие пороги, что считается провалом. Именно этот раздел превращает репозиторий из скрипта в инженерное решение, и именно его чаще всего подменяют строчкой "запустить тесты" без единой цифры.
4. **Как запустить** - установка, минимальный пример, ограничения и чего проект сознательно не делает.
```bash
# лицензия (для витрины обычно MIT)
[ -f LICENSE ] || echo "нет LICENSE - добавить"
# мусор, который не должен уехать
find . -name '__pycache__' -o -name '.DS_Store' -o -name '*.log' -o -name '.idea' -o -name '.vscode' \
| grep -v node_modules | head
# .gitignore закрывает env, ключи, кэши, рабочие артефакты с данными?
grep -qE '^\.env' .gitignore && echo ".env закрыт" || echo "ДОПИСАТЬ .env в .gitignore"
# тесты проходят на чистом клоне, с dev-зависимостями
CHECK=$(mktemp -d) && git clone . "$CHECK/clone" && cd "$CHECK/clone" && <УСТАНОВКА_DEV> && <КОМАНДА_ТЕСТОВ>
```
Прогон на свежем клоне ловит зависимость от локальных файлов, которые в репозиторий не попали, - типовая причина, по которой у стороннего человека проект не запускается. Ставить при этом надо именно dev-набор: боевое окружение обычно собрано без тест-фреймворка, и "тесты не запускаются" там означает лишь его отсутствие. Если dev-зависимостей в манифесте нет - это тоже находка: снаружи тесты прогнать нечем.
## Шаг 6. Публикация и проверка снаружи
Публикация необратима - явное "ок" пользователя обязательно (см. стоп-правило). Сначала **вернись в публикуемый каталог**: `$PUB`, если делался срез (шаг 5 оставил тебя в клоне для проверки), иначе `<REPO_PATH>`. Затем выбери ветку по тому, что делалось на шаге 4 и есть ли уже удаленный репозиторий (`git remote -v`, видимость на хостинге):
```bash
# удаленного репозитория нет (или делался срез Б - тогда публикуем $PUB как НОВЫЙ)
gh repo create <GITHUB_USER>/<REPO_NAME> --public --source=. --remote=origin --push
# история переписывалась (4А) при существующем remote: filter-repo удалил origin -
# сначала перезалить ПЕРЕПИСАННУЮ историю, и только потом менять видимость.
# Строго одной &&-цепочкой: если force-push отбит (branch protection), видимость
# НЕ меняется - иначе публичной станет старая, грязная серверная история
git remote add origin <URL_РЕПОЗИТОРИЯ> \
&& git push --force --all origin \
&& git push --force --tags origin \
&& gh repo edit <GITHUB_USER>/<REPO_NAME> --visibility public --accept-visibility-change-consequences
# репозиторий есть, история НЕ переписывалась и аудит чист - просто сменить видимость
gh repo edit <GITHUB_USER>/<REPO_NAME> --visibility public --accept-visibility-change-consequences
# описание и топики: пустое описание - первое, что видит человек в поиске и в списке репозиториев
gh repo edit <GITHUB_USER>/<REPO_NAME> --description '<ОПИСАНИЕ>' --add-topic <ТОПИК>
```
Два капкана на этом шаге:
- **После среза (Б) старый приватный репозиторий не открывать вообще** - в нем осталась грязная история; `gh repo edit --visibility public` на нем обесценил бы весь срез. Имя занято старым - публикуй срез под другим именем либо предложи пользователю сначала переименовать/удалить старый (его решение).
- **Смена видимости после force-push - не гарантия**: на GitHub вытесненные коммиты могут оставаться достижимы по прямому SHA (кэши, events, форки) до серверной сборки мусора. Если в вытесненной истории был реальный секрет - он уже считается скомпрометированным и ротирован (шаг 1); если там были ПД или NDA-следы, надежнее опубликовать чистый новый репозиторий (путь Б), а не открывать старый.
После публикации проверить репозиторий **как посторонний**: открыть в браузере без сессии (приватное окно) или `curl -s -o /dev/null -w '%{http_code}' https://api.github.com/repos/<GITHUB_USER>/<REPO_NAME>` - ожидаем 200 (до публикации там 404, это и есть контроль). Посмотреть глазами: README рендерится, ссылки не ведут в приватные репозитории, в списке файлов нет лишнего.
## После публикации
- Зафиксировать факт публикации там, где на репозиторий будут ссылаться (проектные заметки, портфолио, переписка): дальше ссылку можно давать открыто.
- Если проект раньше упоминался обезличенно - теперь можно называть.
## Таблица подстановки
| Заглушка | Что это | Пример |
|---|---|---|
| `<REPO_PATH>` | Локальный путь к репозиторию | `~/git/<имя-проекта>` |
| `<REPO_NAME>` | Имя репозитория на хостинге | совпадает с именем каталога |
| `<GITHUB_USER>` | Аккаунт на хостинге | владелец репозитория |
| `<ОПИСАНИЕ>`, `<ТОПИК>` | Описание и топики репозитория | одна фраза о задаче; `python`, `nlp` |
| `<ФАЙЛ_ШАБЛОНОВ>` | Локальный файл вне репозитория с регулярным выражением для заказчиков и внутренних доменов | готовит человек; содержимое не выводить |
| `<ПУТЬ_К_ФАЙЛУ_С_СЕКРЕТОМ>` | Файл для вычистки из истории | `config/secrets.yml` |
| `<ФАЙЛ_ПРАВИЛ>` | Файл замен для `git filter-repo` | строки `старое==>НОВОЕ` |
| `<УСТАНОВКА_DEV>` | Установка с dev-зависимостями | `pip install -e '.[dev]'` |
| `<КОМАНДА_ТЕСТОВ>` | Запуск тестов проекта | `pytest -q` |
| `<URL_РЕПОЗИТОРИЯ>` | URL существующего remote | `git@github.com:<user>/<repo>.git` |
`$PUB` и `$CHECK` - не заглушки, а временные каталоги, которые создают сами шаги 4Б и 5; пользователь их не подставляет.