# Аудит оригинального кода — `main` @ `8c720e6`

**Объект:** `Bash_WP-CLI_Update.sh` v5.0 (1 569 строк) и `Find_WP_Senior.sh` v1.01 (406 строк)
**Оригиналы:** [`legacy/Bash_WP-CLI_Update.v5.0.sh`](legacy/Bash_WP-CLI_Update.v5.0.sh) и [`legacy/Find_WP_Senior.v1.01.sh`](legacy/Find_WP_Senior.v1.01.sh) — побайтовые копии, сверены через `git hash-object` с `main:Bash_WP-CLI_Update.sh` и `main:Find_WP_Senior.sh`.
**Ссылки:** `main:396` — строка в legacy-копии основного скрипта, `finder:267` — в legacy-копии скрипта поиска.

Документ описывает **исходное** состояние кода. Что из него сделано и что отброшено — в [`REFACTORING.md`](REFACTORING.md); что изменилось по версиям — в [`CHANGELOG.md`](CHANGELOG.md).

---

## 0. Резюме

| | Было | Стало |
|---|---|---|
| Выполнение WP-CLI | склейка shell-строки и передача её в `su - user -c` → **выполнение произвольного кода от root** | bash-массив, который становится argv; единственная строка, попадающая в shell, — переключение пользователя, и там аргументы идут позиционными параметрами |
| Скрипт поиска | `--output FILE` и вообще любой вызов, где последний аргумент не флаг, → **тихий выход 1 с пустым результатом** | работает; коды возврата 0/1/2/3/5 |
| `--help` | у менеджера выход **1**; дискавер `--help` **не распознаёт** и начинает сканировать файловую систему | 0 у обоих |
| Секреты | ключ Astra правится в файле скрипта; попадает в debug-вывод и в argv дочернего процесса | ключ не является аргументом ни одного процесса; `redact()` вымарывает его из любого сообщения |
| Конфигурация | отсутствует: пути, список плагинов и домены зашиты в код | три слоя (файл → окружение → командная строка), файл парсится как данные и **не исполняется** |
| Эксплуатация | без dry-run, бэкапов, таймаутов, блокировки, ротации логов, trap на INT/TERM | всё перечисленное плюс `--check`, `--status`, `--verify`, `--list-sites`, `-j N`, JSON Lines |
| Линт | ShellCheck 0.11.0: 0 error / **4 warning** / **7 info**; 901 строка с табуляцией; 98 вызовов `echo -e` | **0 / 0 / 0** по всем одиннадцати shell-файлам дерева |
| Тесты | отсутствуют | шесть наборов, **532 проверок**, `bash tests/run_tests.sh` → exit 0 |

---

## 1. Как проверялось

* Bash 5.2.15, `/bin/sh` → **dash** (Debian 12). ShellCheck 0.11.0.
* Стенд: синтетическое дерево сайтов (`siteA`, `siteB`, `with space`, `skipC` с маркером `.no_wp_cli`, `notwp` без `wp-config.php`), подставной `wp`, протоколирующий свой argv, cwd, пользователя и окружение, пользователь `wptest`/`siteuser` с настоящей оболочкой.
* Каждый дефект ниже **воспроизведён запуском**, а не найден чтением. Для критических приведён вывод.
* Инъекционные зонды: имя каталога `siteA;touch /tmp/PWNED;echo `, `--name 'zz"; touch /tmp/PWNED2; echo "'`, враждебный заголовок плагина `Safe; $(touch /tmp/PWNED3) "quoted"`, jq-payload в `--name`.

---

## 2. Критические дефекты (блокеры)

### 2.1 Выполнение произвольного кода от root — `main:376`, `main:386–396`

Весь обмен с WP-CLI построен на склейке строки и передаче её в `su - user -c`:

```bash
# main:376
local wp_command="${WP_CLI_PATH} --path=\"${site_path}\" ${cmd[*]} --skip-plugins=saphali-woocommerce-lite,… --quiet --allow-root"
# main:387
local full_command="cd \"${site_path}\" && ${export_vars} && ${wp_command}"
# main:396
output=$(su - "${user}" -c "${full_command}" 2>&1) || exit_code=$?
```

`${cmd[*]}` — неквотированная склейка, и в неё попадают данные извне: путь сайта из `wp-found.txt`, имя плагина из вывода `wp plugin list`, значения `--site` и `--name`. Скрипт обязан работать от root (`main:1445`), поэтому полезная нагрузка исполняется **от root**.

**Что показано замером.** Debug-вывод самого скрипта печатает собранную строку:

```
🔧 full_command: cd "/srv/wp/alpha;touch /tmp/PWNED2;echo " && export DOCUMENT_URI="PWNED2;echo " && …
🎯 EXECUTING COMMAND: su - "wptest" -c "cd "/srv/wp/alpha;touch /tmp/PWNED2;echo " && …"
```

Содержимое имени каталога оказывается **вне кавычек** в строке, которую разбирает shell. Это инъекция по построению, независимо от того, сработала ли конкретная полезная нагрузка в конкретном окружении: её исполнение определяется только тем, какие ещё символы встретились в пути.

Отдельно: `export DOCUMENT_URI="${domain}"` получает `domain="$(basename "${site_path}")"`, то есть **имя каталога сайта становится значением переменной окружения**, а при приложении, читающем `DOCUMENT_URI`/`HTTP_HOST`, — и частью его входных данных.

### 2.2 `Find_WP_Senior.sh` не работает вообще — `finder:367–369`

```
$ ./Find_WP_Senior.sh --output /srv/out.txt /srv/wp
INFO: WordPress discovery started...
INFO: Cleaned up temporary files.
$ echo $?
1
$ wc -c < /srv/out.txt
wc: /srv/out.txt: No such file or directory
```

Функция `parse_args` заканчивается проверкой `[[ ${#USER_EXCLUDE_PATTERNS[@]} -gt 0 ]]`, которая возвращает 1, когда `--exclude` не передавали. При `set -euo pipefail` этот код возврата убивает скрипт **до вызова `discover_wordpress`**, без сообщения об ошибке. Воспроизводится на любом вызове, где последний аргумент не флаг, то есть практически всегда.

### 2.3 `--help` у дискавера — это каталог для поиска — `finder:361`

```
$ ./Find_WP_Senior.sh --help
INFO: WordPress discovery started...
```

`--help` проваливается в ветку «это путь» и добавляется в `SEARCH_DIRS`. У менеджера `--help` распознаётся, но выходит с кодом **1**; флага `--version` нет вовсе.

### 2.4 Ключ Astra предполагается вписать в код — `main:45`

```bash
readonly ASTRA_KEY="${ASTRA_KEY:-YOUR_KEY}"   # never commit the key; export ASTRA_KEY instead
```

Комментарий говорит одно, механика — другое: `ASTRA_KEY` встречается в `main:892` как `debug_echo "🔑 Activating license with key: ${ASTRA_KEY}"` и в `main:825`/`main:894` как аргумент `brainstormforce license activate astra-addon "${ASTRA_KEY}"`, то есть попадает и в debug-вывод, и в argv дочернего процесса (виден в `ps`).

### 2.5 Молчаливый успех при нулевой работе — `main:1004–1008`, `main:1471`

Если владельца сайта определить не удалось, сайт пропускается, а итог печатает:

```
│ Sites processed:  1
│ Successful ops:   0
│ Errors:           0
✓ All operations completed successfully
$ echo $?
0
```

Для cron это означает «всё хорошо» в ситуации, когда не было сделано ничего.

---

## 3. Серьёзные дефекты

| # | Что | Где | Последствие |
|---|---|---|---|
| 3.1 | Нет блокировки одновременных запусков | — | два cron-запуска обновляют одни и те же сайты параллельно; `db optimize` одновременно на одной базе |
| 3.2 | Нет ротации логов | `main:26–28` | `wp_cli_manager.log` растёт бесконечно |
| 3.3 | Нет dry-run | — | нельзя посмотреть, что будет выполнено, не выполняя |
| 3.4 | Нет таймаута на команду WP-CLI | — | зависший `wp` блокирует прогон всего парка |
| 3.5 | `trap` только на EXIT | `main` | при SIGINT/SIGTERM временные файлы и состояние не убираются, итог не печатается |
| 3.6 | Жёстко зашитый `--skip-plugins` в **каждом** вызове `wp` | `main:376`, `main:469`, `main:812` | список плагинов одной установки применяется ко всем; в том числе к `db optimize` и `cron event run`, где он бессмыслен и WP-CLI на него ругается |
| 3.7 | Жёстко зашитые пути | `main:26` (`WP_CLI_PATH=/usr/local/bin/wp`), `main:26` (`SITES_FILE=${SCRIPT_DIR}/wp-found.txt`) | `readonly`, переопределить нельзя ничем, кроме правки файла |
| 3.8 | Цвета пишутся всегда | `main:88–104` | ANSI-коды в лог-файле и в выводе CI; `echo -e` (98 вызовов) непереносим |
| 3.9 | Маски исключений по умолчанию отсекают боевые сайты | `finder:77–101` | `*/old*`, `*/test*`, `*/tests*`, `*/backup*`, `*/staging*` молча исключают `/var/www/olddomain.com` и `/var/www/testimonials` |
| 3.10 | Прогресс-бар и таблицы пишутся через `echo -e` и `printf` с управляющими последовательностями | `main:138–166` | мусор в C-локали и в перенаправленном выводе |
| 3.11 | Посторонний e-mail и персональные пути | `finder:47` (`paul@pmtech.com`), история `main` (`/var/www/md/data/www/paulman.ru/...`) | чужие персональные данные в публичном репозитории |
| 3.12 | Русскоязычный комментарий в коде | `main:1495` (`# ✅ Подсчёт сайтов`) | два языка в одном файле при английском UI |

---

## 4. Что проверено и дефектом НЕ является

Отдельный раздел, чтобы следующий читатель не переоткрывал это заново.

* **`su - user -c` с логин-оболочкой** сам по себе не ошибка: WP-CLI действительно нужно окружение владельца сайта, а `su -` его даёт. Ошибка — в способе сборки строки (2.1), а не в выборе `su`.
* **`--allow-root` передаётся всегда** — при запуске от root это ровно то, что нужно WP-CLI. Проблема не в флаге, а в отсутствии политики (`auto|always|never`).
* **Список сайтов читается через `while IFS= read -r`** — корректно; пути с пробелом в самом списке работают. Ломается не чтение, а передача пути в shell-строку (2.1).
* **`stat -c '%U'` для определения владельца** — корректный и дешёвый способ. Неправильно то, что `root` отбрасывается как неподходящий владелец (`main:268`): в контейнере все сайты принадлежат root, и скрипт молча пропускает весь парк.
* **Отсутствие `jq` как жёсткой зависимости** — правильное решение, а не упущение: парсинг JSON средствами bash для плоского массива объектов WP-CLI достаточен и не добавляет зависимость в парк.
* **`printf '%s\n'` вместо `echo`** в новых скриптах — не «стиль», а переносимость: `echo -e` в `sh` и в ряде других оболочек печатает `-e`.

---

## 5. Что сделано в v6.2.0 / v2.1.0

Полный перечень — в [`CHANGELOG.md`](CHANGELOG.md). Коротко по разделам:

* **Безопасность:** argv-массивы везде; переключение пользователя позиционными параметрами (`runuser -u U -- /bin/sh -c 'cd -- "$1" || exit 127; shift; exec "$@"' sh DIR PROG ARGS…`), где экранирование не нужно в принципе; конфигурация парсится как `KEY=VALUE` и отклоняется целиком при любом метасимволе; ключ Astra передаётся через временный файл, который читает дочерний shell, и не является аргументом ни одного процесса; `redact()` вымарывает зарегистрированные секреты из любого сообщения.
* **Надёжность:** блокировка через `flock` с откатом на pid-файл и снятием устаревшей; `trap` на EXIT/INT/TERM/HUP с частичным итогом; ротация обоих логов; таймаут на команду с `--signal` и `--kill-after`; атомарная запись списка сайтов с сохранением прав существующего файла.
* **Контракт:** коды возврата 0/1/2/3/4 (+5 у дискавера), соблюдены включая `--help` → 0; «проза в stderr, данные в stdout», поэтому `--format json | jq` и `--format csv > f.csv` работают.
* **Парк:** `-j N` пакетами с буферизацией и упорядоченным воспроизведением; бэкапы `db|full` с ротацией; `--verify`, `--check`, `--status`, `--list-sites`, `--print-config`; `--only-active`, `-e`, `-U`, `--strict`, `--no-user-switch`; JSON Lines.
* **Проверяемость:** шесть наборов, 531 проверок; `legacy/` с побайтовыми оригиналами; `ANALYSIS.md`, `REFACTORING.md`, `AGENT.md`, `PROJECT_MAP.md`.

---

## 6. Остаточные ограничения

Перечислены в [`REFACTORING.md`](REFACTORING.md) §5: окно доступности файла передачи лицензии (0644 на время одного вызова), пакетный барьер вместо непрерывного пула в `-j N`, рост лог-файла на барьере, стоимость `--backup full`, ширина таблицы в байтах, `scan-secrets.sh` как инструмент формы, а не вердикта, и `root` как владелец последней надежды.
