LIMB
Вход Регистрация

тема

Строим пайплайн поиска уязвимостей в TypeScript.

lisa99 · · 4 ответов · 215 просмотров

Часть I​



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

Стимулировал меня конкурс на одном из мелких форумов, с мелкими же призами, где в том числе в тематику входил
и автоматический поиск уязвимых гаджетов (цепочек) в JS-фреймворках. И заодно понимание, что найденные уязвимости это $$ по-любому.

Зачем нужен такой формат? Хотелось бы развития форума именно как форума, площадки дял обсуждений, а не как маркетплейса или библиотеки.
Пару человек обязались  покритиковать содержание. Так что - поехали..

   

Введение​


Появление дешевых/бесплатныхAPI для LLM (например, GLM-4-Flash) рождаетиллюзию, что безопасность кода можнопроанализировать, просто закинуврепозиторий в нейросеть.
Наивный подход «LLM пофайлам» захлебывается в false positives. Чтобыполучить реальный инструмент, нужнозаставить нейросеть не «думать», а строго отвечать на вопросы по доказаннымфактам.
Как это было =) Пошли по порядку.
  1. Получение токенов GLM-4. И    других моделей GLM - 2млн халявных токенов простой модели.
    Подробно (регистрация, обходкапчи и скриншоты халявы — здесь: Как получить миллионы токенов API ИИ нахаляву [мануал] )

  2. Затем я выбрала несколько фреймворков для анализа. Почему ts, js?
    Считала что найти уязвимостипрестижней, на фоне массовых CVE10 появился азарт, ну и распространенность немалая. Отточить ядро и модифицировать под другие цели будет проще.

  3. Обратилась в чате к GLM  5.2  с просьбой применить полученные токены. 
Что же предложила мне «умная модель»?  Опишу ниже с целью негативного опыта  (как не нужно делать, имхо).
Быстро стало понятно, что флагман GLM простоват. Пришлось дальше общаться с Qwen3.7 plus

   

1. Наивные подходы и почему они не работают​

На форуме есть статьи о применении LLM к анализу кода. Вот два направления.

1.1. Чанки и статика: «LLM по файлам»​

Как делается:  Берем .ts файлы, бьем на чанки по 1000 токенов, отправляем в LLM с промптом «найди уязвимости».
Чем это плохо:
Потеря контекста:  Переменная определяется в  fileA.ts, передается через  fileB.ts, а используется в  fileC.ts. LLM видит только кусок и фантазирует.​
Галлюцинации:  Модель видит  fetch  и  http://  в payloads и тут же репортит SSRF, не понимая, статический это URL или пользовательский ввод.​
Вывод:  LLM не может быть Static Analysis движком. Она не умеает строить графы потоков данных (data flow).​
Кто-то может возразить «мне помогло». Да, наверное в частном случае что-то можно найти. Но упустить можно еще больше

#Пример раз (как красиво упаковать и продать лабуду):

"AI-Scanner на стероидах: Свой SAST на базе Local LLM v1.0":
Тема с использованием AI для анализа кода не новая, но 90% скриптов в паблике - это детский сад из трех строк: "прочитай файл -> отправь в чат". Это не работает в реальных условиях.
Если вы пробовали скармливать нейронке реальные исходники, вы знаете боль:
  • Она теряет контекст на больших файлах.
  • Она галлюцинирует с номерами строк.
  • Она не видит баги, если они "разрезаны" между частями запроса.
Сегодня мы напишем взрослый инструмент на Python. Это будет CLI-утилита, которая умеет нарезать код на чанки с перекрытием (overlap), нумерует строки перед отправкой и сохраняет результат в JSON-отчет.

#Пример два: - отставим


1.2. Два прохода со скорингом: «Попытка отфильтровать в динамике»​

Вот это и предложил мне сделать  GLM 5.2

Как делали:  Первый проход — LLM ищет всё подозрительное и ставит  risk_score.
Под спойлером два файла -

Спойлер: hunter.py
Python:
# hunter.py
import os
import json
import sys
from zhipuai import ZhipuAI
from dotenv import load_dotenv

load_dotenv()
client = ZhipuAI(api_key=os.getenv("ZHIPU_API_KEY"))

SYSTEM_PROMPT = """Ты автоматический сканер безопасности Red Team.
Тебе передается исходный код. Найди цепочки от точек входа (Source) до опасных функций (Sink), ведущие к RCE, SSRF или Prototype Pollution.
ВАЖНО: Определяй контекст! Если код использует Node.js/Express/NestJS (например, class-transformer, req.body), это серверный код. Для RCE на сервере используй пейлоады типа require('child_process').exec('command'), а НЕ браузерный alert(1).
Ответь ТОЛЬКО валидным JSON, без каких-либо других текстов до или после:
{
 "risk_score": 0-10,
 "vuln_type": "string",
 "source": "string",
 "sink": "string",
 "exploit_payload": "string (JSON body to send to the server)"
}
Если уязвимости нет, верни: { "risk_score": 0, "vuln_type": "none", "source": "", "sink": "", "exploit_payload": "" }"""

def analyze_file(filepath: str) -> dict:
    try:
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            code = f.read()
    except Exception as e:
        return {"error": f"Ошибка чтения файла: {e}"}
 
    response = client.chat.completions.create(
        model="glm-4-flash",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Проанализируй файл {filepath}:\n\n{code[:4000]}"}
        ],
        temperature=0.1
    )

    content = response.choices[0].message.content

    # Очистка от markdown-оберток
    if "```json" in content:
        content = content.split("```json")[1].split("```")[0].strip()
    elif "```" in content:
        content = content.split("```")[1].split("```")[0].strip()
    else:
        start = content.find('{')
        end = content.rfind('}')
        if start != -1 and end != -1:
            content = content[start:end+1]

    try:
        return json.loads(content)
    except json.JSONDecodeError:
        return {"error": "LLM вернула невалидный JSON", "raw_response": content}

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Использование: python hunter.py <путь_к_файлу>")
        sys.exit(1)
    target = sys.argv[1]
    result = analyze_file(target)

    if 'error' in result:
        print("\n❌ ОШИБКА ПАРСИНГА:")
        print(result.get('raw_response', result.get('error')))
    else:
        print(f"\n📄 Файл: {target}")
        print(f"🔥 Риск: {result.get('risk_score', 'N/A')}/10")
        print(f"🎯 Тип: {result.get('vuln_type', 'N/A')}")
        print(f"📥 Источник (Source): {result.get('source', 'N/A')}")
        print(f"📤 Сток (Sink): {result.get('sink', 'N/A')}")
        print(f"🚀 Эксплойт:\n{result.get('exploit_payload', 'N/A')}")


Python:
import os
import json
from pathlib import Path
from hunter import analyze_file
from rich.console import Console

console = Console()

# Папка, где лежат скачанные репозитории
REPOS_DIR = "./repos"

# Слова-маркеры для первичной фильтрации (экономим токены!)
SUSPICIOUS_KEYWORDS = [
    "plainToInstance", "plainToClass", "@Transform", "deserialize",
    "exec(", "execSync(", "spawn(", "fetch(", "axios", "serialize",
    "JSON.parse", "child_process"
]

# Папки, которые мы полностью игнорируем (там мусор и тесты)
IGNORE_DIRS = ["node_modules", ".next", "dist", "test", ".hygen", "__tests__", ".git"]

def scan_repository(repo_path: str):
    """Сканирует один репозиторий"""
    repo_name = Path(repo_path).name
    console.print(f"\n[bold blue]📂 Сканируем репозиторий: {repo_name}[/bold blue]")
 
    vulnerable_files = []
    error_files = [] # Список для логирования ошибок API
    files_to_scan = []
    skipped_count = 0
    scanned_count = 0

    # Собираем JS/TS файлы
    for path in Path(repo_path).rglob("*"):
        # Игнорируем мусорные и тестовые папки!
        if any(x in str(path) for x in IGNORE_DIRS):
            continue
    
        if path.suffix in [".ts", ".js"]:
            files_to_scan.append(str(path))

    console.print(f"[green]Найдено файлов (без тестов и мусора): {len(files_to_scan)}. Фильтруем по ключевым словам...[/green]\n")

    for filepath in files_to_scan:
        try:
            with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
                code = f.read()
        except:
            continue

        # Бесплатная проверка на ключевые слова
        if not any(keyword in code for keyword in SUSPICIOUS_KEYWORDS):
            skipped_count += 1
            continue

        scanned_count += 1
        # Показываем относительный путь для красоты
        relative_path = filepath.replace(repo_path, "")
        console.print(f"⏳ [{scanned_count}] Анализируем: [cyan]{relative_path}[/cyan]...", end=" ")

        # Отправляем в ИИ
        result = analyze_file(filepath)
 
        # Если API упало (500, таймаут и т.д.)
        if 'error' in result:
            console.print(f"[yellow]⚠️ Ошибка ИИ[/yellow]")
            error_files.append({
                "file": filepath,
                "error": result['error']
            })
            continue

        # Добавляем мета-информацию
        result["file"] = filepath
        result["repo"] = repo_name

        risk = result.get("risk_score", 0)
 
        # Сортируем результаты
        if risk >= 8:
            console.print(f"[bold red]🔥 КРИТИЧЕСКИЙ RISK {risk}/10 | {result.get('vuln_type')}[/bold red]")
            vulnerable_files.append(result)
        elif risk >= 5:
            console.print(f"[yellow]🟡 СРЕДНИЙ RISK {risk}/10 | {result.get('vuln_type')}[/yellow]")
            vulnerable_files.append(result)
        else:
            console.print(f"[dim]✅ Чисто (Риск {risk})[/dim]")

    # Статистика сканирования
    console.print(f"\n[bold]📊 Статистика по {repo_name}:[/bold]")
    console.print(f"   Всего файлов: {len(files_to_scan)}")
    console.print(f"   Скипнуто (нет ключевых слов): {skipped_count}")
    console.print(f"   Отправлено в AI: {scanned_count}")
 
    # Сохраняем лог ошибок, если они были
    if error_files:
        error_filename = f"errors_{repo_name}.json"
        with open(error_filename, "w", encoding="utf-8") as f:
            json.dump(error_files, f, indent=2, ensure_ascii=False)
        console.print(f"[red]   Ошибок API: {len(error_files)} (сохранено в {error_filename})[/red]")

    return vulnerable_files

def main():
    if not Path(REPOS_DIR).exists():
        console.print(f"[red]❌ Папка {REPOS_DIR} не найдена! Сначала склонируй репозитории.[/red]")
        return

    all_vulnerabilities = []
 
    # Проходим по всем папкам внутри ./repos/
    for repo_dir in Path(REPOS_DIR).iterdir():
        if repo_dir.is_dir():
            vulns = scan_repository(str(repo_dir))
            all_vulnerabilities.extend(vulns)

    # Финальный вывод
    console.print(f"\n[bold green]✅ МАССОВОЕ СКАНИРОВАНИЕ ЗАВЕРШЕНО![/bold green]")
    console.print(f"🔴 Всего обнаружено уязвимостей (Risk >= 5): [bold red]{len(all_vulnerabilities)}[/bold red]")
 
    # Сохраняем финальный красивый отчет
    with open("final_report.json", "w", encoding="utf-8") as f:
        json.dump(all_vulnerabilities, f, indent=2, ensure_ascii=False)
    console.print(f"💾 Полный отчет сохранен в [bold]final_report.json[/bold]")

if __name__ == "__main__":
    main()




Второй проход — LLM перепроверяет находки с  verified_risk.

Методология: Трехуровневый AI-Пайплайн​

Уровень 1: AST + Shallow LLM (То, что уже работает)

Действие: Парсим код, ищем пары Source-Sink (JSON.parse -> plainToInstance, fetch(userInput)).
Результат: JSON-отчет с сотнями кандидатов (много False Positives).

Уровень 2: Context Verifier (Фильтрация шума)

Проблема: ИИ не понимает, запускается ли код на сервере (Web) или на компьютере разработчика (CLI/Script).
Решение: Пишем скрипт verifier.py, который берет final_report.json и делает второй проход только по найденным файлам, но уже с другим, строгим промптом.

Промпт для Уровня 2 (Context Verifier):
Ты эксперт по безопасности на уровне архитектуры. Тебе дан файл, в котором найден подозрительный паттерн.​
Твоя задача — определить КОНТЕКСТ ВЫПОЛНЕНИЯ.​
1. Находится ли этот код в веб-эндпоинте (HTTP handler, @Get, @post, app.get, req.body)?​
2. Или это CLI-утилита, тест, build-скрипт или локальный воркер?​
Если код НЕ доступен через сеть (нет HTTP входа), уязвимость нерелевантна для удаленной атаки.​
Ответь ТОЛЬКО JSON:​
Code:
{
  "is_web_accessible": true/false,
  "http_entry_point": "string (например, 'req.query.data' или 'None')",
  "adjusted_risk_score": 0-10 (если нет HTTP входа - ставь 0!),
  "reason": "string"
}


Уровень 3: Cross-File Taint Tracking (Глубокий анализ)

Проблема: Данные приходят в req.body (Хэндлер), но утекают в exec() где-то глубоко в Сервисе. Файл Хэндлера выглядит безопасно, файл Сервиса выглядит безопасно, но их цепочка — RCE.

Решение: Скрипт tracer.py.

Когда Уровень 2 подтверждает Web-доступность, скрипт:
 1.    Ищет импорты в уязвимом файле (например, import { findOneOperation } from './ops.js').
 2.    Находит файл ./ops.js локально в репозитории.
 3.    Скармливает ИИ ОБА файла сразу с промптом трекинга.

Промпт для Уровня 3 (Taint Tracker):

Ты анализируешь поток данных (Taint Analysis) между двумя файлами.​
В Файле 1 (Handler) пользовательские данные из веба попадают в функцию X.​
В Файле 2 находится реализация функции X.​
Проследи, доходят ли данные из Файла 1 до опасного Sink (exec, merge, ORM save) в Файле 2 без санитизации.​
Ответь JSON:​
Code:
{
"chain_confirmed": true/false,
"full_source": "string (откуда пришли данные)",
"full_sink": "string (где они нанесли урон)",
"exploit_path": "string (кратко: req.query.data -> findOne -> Object.assign)"
}


Не нужно писать сложный AST-парсер для трекинга. Мы заставим GLM-4-Flash работать вместо AST
Вот готовый скрипт Второго уровня (verifier.py), который пройдется по  final_report.json и отфильтрует мусор.
Скрипт возьмет final_report.json, достанет пути к файлам из поля "file", прочтет каждый файл и спросит ИИ: "Эй, это реально веб-эндпоинт или локальный скрипт?"
ИИ сразу прихлопнет scripts/seed-app-store.ts (поставит 0), и оставит только те файлы, где есть req.body или роутеры Express/NestJS.
В verified_report.json останутся только реалистичные векторы. Автоматизация процентов на 70- не более. Ибо оставшееся нужно верифицировать в динамике.
Спойлер: verifer.py
Python:
import os
import json
from zhipuai import ZhipuAI
from dotenv import load_dotenv
from rich.console import Console

load_dotenv()
console = Console()
client = ZhipuAI(api_key=os.getenv("ZHIPU_API_KEY"))

VERIFIER_PROMPT = """Ты эксперт по безопасности на уровне архитектуры. Тебе дан файл, в котором ранее ИИ нашел подозрительный паттерн (Source -> Sink).
Твоя задача — определить КОНТЕКСТ ВЫПОЛНЕНИЯ этого кода.
1. Находится ли этот Sink в коде, доступном через сеть (HTTP handler, @Get, @Post, app.get, req.body, req.query)?
2. Или это CLI-утилита, скрипт сборки (scripts/, test/, cli), воркер или локальная утилита, до которой удаленный атакующий не может достучаться через HTTP?
Если код НЕ доступен через сеть, уязвимость НЕ представляет интереса для Red Team.
Ответь ТОЛЬКО валидным JSON:
{
  "is_web_accessible": true/false,
  "http_entry_point": "string (укажи точку входа или 'None')",
  "adjusted_risk_score": 0-10 (если нет HTTP входа - ставь 0!),
  "reason": "string (почему доступен или нет)"
}"""

def verify_finding(finding: dict) -> dict:
    filepath = finding.get("file")
    if not filepath or not os.path.exists(filepath):
        return {**finding, "verified_risk": 0, "is_web_accessible": False, "reason": "File not found"}

    try:
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            code = f.read()
    except Exception as e:
        return {**finding, "verified_risk": 0, "reason": str(e)}

    try:
        response = client.chat.completions.create(
            model="glm-4-flash",
            messages=[
                {"role": "system", "content": VERIFIER_PROMPT},
                {"role": "user", "content": f"Файл: {filepath}\nКод:\n```\n{code}\n```"}
            ],
            temperature=0.1
        )
        content = response.choices[0].message.content
        if "```json" in content: content = content.split("```json")[1].split("```")[0].strip()
 
        verification = json.loads(content)
 
        # Обновляем находку данными из верификатора
        finding["is_web_accessible"] = verification.get("is_web_accessible", False)
        finding["verified_risk"] = verification.get("adjusted_risk_score", 0)
        finding["verification_reason"] = verification.get("reason", "")
 
    except Exception as e:
        finding["verified_risk"] = finding.get("risk_score", 0) # Оставляем как есть при ошибке API
        finding["verification_reason"] = f"API Error: {str(e)}"

    return finding

def main():
    report_path = "final_report.json"
    if not os.path.exists(report_path):
        console.print("[red]❌ Файл final_report.json не найден![/red]")
        return

    with open(report_path, "r", encoding="utf-8") as f:
        findings = json.load(f)

    console.print(f"[cyan]🔍 Начинаем верификацию {len(findings)} находок (Уровень 2)...[/cyan]")

    verified_findings = []
    for i, finding in enumerate(findings):
        filepath = finding.get("file", "Unknown")
        console.print(f"⏳ [{i+1}/{len(findings)}] Верифицируем: {filepath.split('/')[-1]}...", end=" ")
 
        verified = verify_finding(finding)
 
        if verified.get("verified_risk", 0) >= 7:
            console.print(f"[bold green]✅ ПОДТВЕРЖДЕНО (Web-вход: {verified.get('http_entry_point')})[/bold green]")
            verified_findings.append(verified)
        else:
            console.print(f"[dim]❌ Отклонено (Причина: {verified.get('verification_reason', 'Low risk')})[/dim]")

    # Сохраняем чистый отчет
    with open("verified_report.json", "w", encoding="utf-8") as f:
        json.dump(verified_findings, f, indent=2, ensure_ascii=False)

    console.print(f"\n[bold green]✅ Верификация завершена![/bold green]")
    console.print(f"🏆 Реальных Web-уязвимостей (Risk >= 7): [bold cyan]{len(verified_findings)}[/bold cyan]")
    console.print(f"💾 Чистый отчет сохранен в [bold]verified_report.json[/bold]")

if __name__ == "__main__":
    main()

Что произойдет: Скрипт возьмет final_report.json, достанет пути к файлам из поля "file", прочтет каждый файл и спросит ИИ: "Эй, это реально веб-эндпоинт или локальный скрипт?"
ИИ сразу прихлопнет scripts/seed-app-store.ts (поставит 0), и оставит только те файлы, где есть req.body или роутеры Express/NestJS.
В verified_report.json  останутся только боевые, реалистичные векторы. Это и есть та самая автоматизация, для начального уровня

Получили файл отчета  verified_report.json. Прилагаю в аттаче.
Вот этим я собираюсь заняться "вручную" несколько позже. фразмент из отчета "третьего уровня":

JSON:
{
    "risk_score": 8,
    "vuln_type": "SSRF",
    "source": "req.body.config",
    "sink": "configToJSONSchema",
    "exploit_payload": {
      "config": {
        "url": "http://attacker.com"
      }
    },
    "file": "repos\\payload-main\\packages\\plugin-mcp\\src\\mcp\\getMcpHandler.ts",
    "repo": "payload-main",
    "is_web_accessible": true,
    "verified_risk": 7,
    "verification_reason": "The code is part of a plugin for a web application, and it uses the '/api' path for handling requests. This indicates that the code is accessible via the web and can be targeted by an attacker."
  },



Как вообще выглядит построенная архитектура?

Не удалось прогрузить медиа




Дальше я опускаю беседы с GLM 5.2 виду их непродуктивности. Обращаюсь с файлом к Qwen3.7 plus. Его ответ
   
Не удалось прогрузить медиа



ТОП-4 КРИТИЧЕСКИХ НАХОДКИ (Risk 8-10)​

:first_place: #1:​

Файл: repos\payload-main\packages\payload\src\uploads\endpoints\getFileFromURL.ts

Цепочка:  req.url (HTTP endpoint) → fetch(fileURL) → загрузка файла с внешнего URL

Почему это Critical:
  • Это HTTP-эндпоинт (/api/uploads/getFileFromURL)
  • Атакующий контролирует URL через req.url
  • Сервер делает fetch() на этот URL
  • Если загруженный файл потом выполняется (например, как JS/JSON с eval или импортируется) — это RCE

Что проверить прямо сейчас:
  1. Открой файл getFileFromURL.ts
  2. Найди, что происходит с загруженным файлом после fetch()
  3. Если есть eval(), new Function(), import(), или файл сохраняется и потом выполняется — это RCE
После тычка пишет:

Я провел автоматический трассировочный анализ (Taint Analysis) файла getFileFromURL.ts. Вот жесткий, технический разбор того, что происходит с данными после fetch(), и почему это НЕ RCE, но может быть SSRF-прокси.

Автоматический анализ потока данных (Data Flow Analysis)
1. Источник (Source):

Code:
const { searchParams } = new URL(req.url)
const src = searchParams.get('src') // Пользовательский ввод

2. Ворота безопасности (Gates):
Code:
if (!req.user) throw new Forbidden(req.t) // Требуется авторизация
const accessResult = await executeAccess({ req }, config.access.update) // Проверка прав

3. Сток (Sink) - Выполнение запроса:
Code:
// Если есть allowList и URL в нем -> обычный fetch (обход safeFetch!)
if (hasAllowList && isURLAllowed(fileURL, config.upload.pasteURL.allowList)) {
  response = await fetch(fileURL, { headers: { 'Accept-Encoding': 'identity' }, redirect: 'manual', signal: AbortSignal.timeout(30_000) })
} else {
  // Иначе -> safeFetch (блокирует localhost, 169.254.x.x и внутренние IP)
  response = await safeFetch(fileURL, { headers: { 'Accept-Encoding': 'identity' }, signal: AbortSignal.timeout(30_000) })
}

4. Пост-обработка (Post-Sink Processing):
Code:
const rawFileName = decodeURIComponent(new URL(fileURL).pathname.split('/').pop() || '')
const safeFileName = sanitizeFilename(rawFileName) // Защита от Directory Traversal (../)

5. Финальный вывод (Final Output):
Code:
return new Response(response.body, {
  headers: {
    'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodedFileName}`,
    'Content-Type': response.headers.get('content-type') || 'application/octet-stream',
    // ...
  }
})

Вердикт анализатора: ПОЧЕМУ ЭТО НЕ RCE​

Автоматический анализ показывает отсутствие опасного гаджета после fetch().
  1. Файл НЕ сохраняется на диск сервера. Нет вызовов fs.writeFileSync или загрузчиков Payload (payload.create({ collection: 'media' })).
  2. Файл НЕ выполняется. Нет eval(), new Function(), require() или парсинга JSON на стороне сервера.
  3. Тело ответа просто стримится обратно клиенту. new Response(response.body) действует как простой прокси-сервер.

Вывод: Превратить это в RCE невозможно. Это тупик для эксплойта.

Я опускаю перебор, практически ручной "бриллиантов", после трех скриптов и одного флэш-рояля (ИИ).
Очень трудоемкий процесс. и сильно ручной.

С другой стороны Мифоса, который развернет код на своих серверах и все проверит сам - нет
Часть 2 будет посвящена автоматизации "на максималках" - я имею ввиду все-таки кастомные скрипты, а не мощные профессиональные решения.

п.с. в аттаче анализ
cal.diy-main - Cal.diy is the open source community edition of Cal.com
payload-main - PayloadCMS


На все-про-все ушло 70 000 токенов из которых почему то потом большая часть вернулась.
Напомню, что подарили 2млн на три месяца.
👍18 ❤️4 😢4 💯4

Часть II​

Введение​

Итак, продолжаем путь к построению автоматического сканера уязвимостей на минималках.
Часть 1. Что удалось сделать. Схема, кратко, была такова

1. Поиск потенциально уязвимых конструкций, эндпойнтов (scaner.py)
2. Парсинг, промпт - вызов LLM (GLM 4-flash) (hunter.py)
3. Создание отчета
4. Отправка результатов из отчета на оценку ИИ (verifier.py) 
5. Отфильтрованный отчет
6. Поднятие сервера с проектом для динамического анализа
7. Оценка отчета мощным ИИ в чате
8. Ручная проверка уязвимостей в динамике  с помощью ИИ (проверка пэйлоадов, ядовитый .yaml, ввод тестовых данных, проверка вебхуков и тд)

Хотя воронка анализа свелась к 4-5 интересным версиям, проверять их было долго, не говоря об упущениях

Цель - максимально автоматизироват процесс. Хотя он будет выполняться статически, попытаться расширить покрытие для анализа, и углубить его).

Мои наивные мысли потекли в сторону готовых велосипедов. я пинала 4 ИИ, и просмотривала статьи.
Что у меня сложилось в отношении профессионального тестирования

Инструментарий профессионалов для поиска уязвимостей​

1. TruffleHog (особенно v3)​

Что это: Один из самых мощных сканеров секретов (API keys, credentials, tokens).
Главное отличие от остальных:
  • TruffleHog v3 полностью переписан на Go и сильно превосходит версию 2.
  • Использует не только regex + entropy, но и семантический анализ (понимает контекст).
  • Отлично работает с Git-историей (может найти секреты, которые уже были удалены).
Сильные стороны:
  • Очень низкий false-positive rate по сравнению с Gitleaks.
  • Поддерживает сканирование не только Git, но и GitHub orgs, S3, Docker images, filesystem.
  • Есть режим --only-verified — проверяет валидность ключей (например, AWS, GitHub, Stripe).
Когда использовать:
  • Везде, где важна безопасность секретов (а это почти всегда).
  • Особенно рекомендуется в финтехе, при работе с облачными провайдерами и в компаниях, где раньше были утечки ключей.
Минус: Может быть относительно медленным при сканировании всей истории большого репозитория.

2. OSV-Scanner (от Google)​

Что это: Современный сканер уязвимостей зависимостей, построенный на базе базы OSV.dev.
Почему его ставят выше среднего:
  • Использует самую актуальную в мире базу уязвимостей open-source (Google её активно наполняет).
  • Понимает call graphs и точные версии лучше, чем многие коммерческие решения.
  • Отлично работает с Go, Rust, Python, JavaScript, Java, C/C++.
  • Поддерживает SBOM (в формате CycloneDX и SPDX).
Сравнение с конкурентами:
  • Часто находит уязвимости, которые пропускают Snyk и Grype.
  • Особенно силён в экосистемах Go и Rust.
  • Полностью open-source и бесплатный.
Рекомендация: Использовать вместе с Grype/Snyk, а не вместо. Они хорошо дополняют друг друга.

3. Bearer​

Что это: Инструмент для поиска чувствительных потоков данных (sensitive data flows) и PII.
Основная специализация:
  • Находит, где в коде обрабатываются персональные данные (email, phone, SSN, credit card, health data и т.д.).
  • Строит data flow graph — откуда данные приходят и куда уходят.
  • Отлично подходит под требования GDPR, CCPA, HIPAA, PCI DSS.
Языки: JavaScript/TypeScript, Python, Java, Ruby, Go.
Сильные стороны:
  • Один из лучших инструментов именно по data privacy и tracing sensitive data.
  • Есть готовые политики под разные регуляции.
  • Может находить сложные случаи утечки PII, которые обычные SAST-инструменты пропускают.
Минус: Пока слабее в поиске классических security-уязвимостей (SQLi, XSS и т.д.), чем Semgrep/CodeQL.
Когда использовать: Если у вас есть требования compliance или вы работаете с большим количеством пользовательских данных.

4. Contrast Security и Seeker (IAST)​

Это инструменты класса IAST (Interactive Application Security Testing) — один из самых эффективных подходов к поиску уязвимостей.

Contrast Security​

  • Один из лидеров рынка.
  • Устанавливается как агент в приложение (Java, .NET, Node.js, Python, Go, Ruby).
  • Работает в runtime и видит реальные потоки данных, SQL-запросы, десериализацию, SSRF и т.д.
  • Отлично отличает реальные уязвимости от false positives.
  • Есть мощный RASP (защита в рантайме).

Seeker (Synopsys)​

  • Очень близок по возможностям к Contrast.
  • Считается одним из самых точных IAST-решений.
  • Сильная аналитика и удобные отчёты.
  • Хорош в enterprise-среде.
Ключевой плюс IAST (Contrast/Seeker):
Они находят уязвимости, которые статический анализ пропускает (особенно бизнес-логику, authorization issues, сложные data flows).
Минусы:
  • Довольно дорогие (enterprise-лицензии).
  • Требуют внедрения агента в продакшен/стейджинг.
Вывод: Если бюджет позволяет — Contrast или Seeker относятся к топ-3 самых эффективных инструментов AppSec после человека с Burp Suite.
5. AFL++, libFuzzer, honggfuzz (Fuzzing)
Это инструменты генеративного фаззинга — один из самых мощных способов находить баги в низкоуровневом коде.
ИнструментЯзыкСложностьСкоростьЛучше всего работаетРекомендацияlibFuzzerC/C++СредняяОчень высокаяCoverage-guided fuzzing№1 выбор в 2025AFL++C/C++, другиеСредняя+ВысокаяСложные парсеры, image formatsЛучший универсальныйhonggfuzzC/C++НизкаяВысокаяСтабильность, простотаХорош для новичков
Кратко о каждом:
  • libFuzzer — сейчас самый популярный. Работает вместе с LLVM Sanitizers (AddressSanitizer, UndefinedBehaviorSanitizer). Очень высокая эффективность.
  • AFL++ — эволюция легендарного AFL. Имеет огромное количество режимов (QEMU, persistent, Unicorn, laf-intel и др.). Часто находит самые глубокие баги.
  • honggfuzz — проще в использовании, очень стабильный, хорошо работает с многопоточностью.
Когда стоит использовать:
  • Парсеры (JSON, XML, Protobuf, image formats)
  • Сетевые протоколы
  • Библиотеки, написанные на C/C++/Rust
  • Криптографический код
  • Всё, что работает с недоверенными данными
Эти фаззеры регулярно находят уязвимости уровня RCE и memory corruption, которые другие методы не видят.


Современные инструменты, где ИИ играет большую роль

GitHub Advanced Security + CopilotLLM + MLОчень глубокоВысокаяSemgrep AssistantLLM (на базе Claude)Сильно (генерация правил, объяснение, исправление)Очень полезноSnykML + LLMГлубоко (особенно в Code + SCA)ХорошаяBearerLLM + MLОдин из лидеров по классификации PII и sensitive data flowsОтличноEndor LabsLLM + Graph MLОчень серьёзный подходОдна из лучших новых системSocketML + Behavioral AIЛучший в анализе рисков open-source пакетовОтличноPhylumML (behavioral analysis)Анализирует поведение пакетовСильныйWizML + LLMВ cloud-native securityОчень сильныйMicrosoft Security CopilotLLM (на базе GPT)Для анализа findings и генерации remediationПолезен для SecOps


Реальная иерархия эффективности (по общему мнению):​

  1. Хороший инженер AppSec + Burp + мозг — находит самые опасные уязвимости
  2. Semgrep + CodeQL + кастомные правила — ловит очень много
  3. Snyk/Grype — зависимости
  4. Остальное — приятное дополнение

Моя идея: строим гибридный анализатор для проектов на PayloadCMS​

 Главная фишка: мы не полагаемся на ИИ для поиска багов. Мы используем классический статический анализ (SAST), чтобы доказать, что данные пользователя могут дойти до опасного места (Sink). А ИИ (GLM-4-Flash) выступает как «умный фильтр», который проверяет контекст и говорит: «Этот код реально можно заэксплуатировать или нет?».
Не пользуемся готовым софтом, пишем свой сканер для SAST.
Правило №1: Финальное решение «это баг» принимает код, а не нейросеть.​

​

Payload CMS — что это и почему сложен для SAST​

Payload CMS (сейчас Payload v3.x) — это open-source headless CMS и прикладной фреймворк на Node.js/TypeScript, нативно интегрируемый в Next.js. Главный принцип — Code-First: вся конфигурация (схемы данных, доступы, хуки) пишется на TypeScript в payload.config.ts, а не через GUI.
Ключевой функционал:

КомпонентОписаниеCollections & GlobalsОпределение сущностей (Users, Posts, Media…) с полями (text, richText/Lexical, relationship, blocks, array, upload). Автоматически генерирует REST + GraphQL API и таблицы в БД (MongoDB/PostgreSQL/SQLite).Access ControlФункциональный ACL на уровне коллекции или поля: access: { read: ({ req }) => Boolean(req.user) }. Можно строить RBAC/ABAC любой сложности.Hooks (жизненный цикл)beforeValidate, beforeChange, afterChange, afterRead— на уровне коллекции или отдельного поля. Позволяют вставлять бизнес-логику, вызывать payload.find/create()(Local API), интегрироваться с внешними сервисами.Local APIpayload.find({ collection, where })— вызов напрямую из Node.js/Server Component без HTTP. По умолчанию bypass-ит access control, если не указан overrideAccess: false.Admin UIАвтогенерируемая панель управления на React, полностью кастомизируемая через свои компоненты.Plugins & BlocksМодульное расширение (SEO, Stripe, Nested Docs). Blocks — типизированные динамические секции контента.Auth & UploadsВстроенная JWT-аутентификация, загрузка файлов с MIME-фильтрацией и imageSizes.


Используется компаниями уровня Microsoft, Sonos для сайтов, e-commerce и SaaS-бэкендов.
Самое интересное для меня.

Почему Payload CMS — сложный объект для статического анализа (SAST)​

Классические SAST-инструменты (Semgrep, ESLint, даже SonarQube) плохо справляются с Payload, и вот почему:

① Динамика через конфиг-объекты (Declaration > Execution)
Уязвимые места часто задаются в объектах-конфигах (hooks, access, fields), которые Payload интерпретирует сам в рантайме. SAST видит просто «стрелочную функцию внутри литерала объекта» и не понимает, что это beforeChange hook, выполняемый при HTTP-запросе к /api/posts.

② Локальный API обходит access control — неявно
Вызов payload.update({ collection: 'users', id, data })внутри beforeChangeбез overrideAccess: falseисполняется с правами суперюзера. SAST должен отслеживать семантику Payload API, а не просто искать exec()или SQL-инъекцию. Без знания фреймворка это выглядит как обычный ORM-вызов.

③ Хуки с побочными эффектами и внешними вызовами
В afterChangeразработчик может слать webhook, делать fetch()к внутреннему сервису или писать в файл. Определить, ведёт ли это к SSRF/IDOR/Information Leak — задача taint-анализа, который должен понимать границы доверия (trusted user input vs server-only).

④ Децентрализованность бизнес-логики
Логика размазана по collections/*/index.ts, plugins/, hooks/, globals/. Нет одного controller, как в Express/NestJS. Чтобы найти цепочку Source→Sink, анализатор должен собрать граф вызовов между конфиг-файлами — это требует whole-program analysis, а не локального паттерн-матчинга.

⑤ Типичные антипаттерны Payload, невидимые для SAST:
  • access: { read: () => true }на чувствительных коллекциях (Users, Orders) — data leak.
  • payload.find({ req })без передачи req— privilege escalation (runAsAdmin).
  • Доверие клиентскому ID в beforeChangeвместо req.user.id— IDOR.
  • upload: { disableExternalFile: false }+ user-supplied URL → SSRF (CVE-2026-27567).
​
Payload CMS - code-first headless CMS на TypeScript/Next.js, где схемы, доступы и бизнес-логика задаются декларативно в конфигах, а не в контроллерах.​
Это даёт гибкость, но делает SAST-анализ нетривиальным: инструмент должен понимать семантику hooks/Local API/access functions, собирать cross-file граф вызовов и отслеживать taint через неявные точки входа.​
Стандартные линтеры пропускают специфичные для Payload уязвимости - некорректные access-функции, bypass прав через Local API и опасные side-effects в хуках.​

Выводы.​

1. Уязвимость в  Payload CMS  - доступ к тысячам проектов, в том числе корпоративным, использующим этот фреймворк.​

2. В силу архитектуры он трудно поддается профессиональному анализу - есть шанс добыть ценное.​


Как я вижу архитектуру своего софта​

​

Этап 1: Подготовка (Parser)​

Сначала мы разбираем проект, чтобы понимать, с чем имеем дело:
  • Зависимости: Смотрим package-lock.json, чтобы знать версии библиотек. Если версия безопасная — сразу отсекаем.
  • Payload-специфика: Парсим конфиги коллекций (*.config.ts). Нам нужно вытащить все публичные точки входа: эндпоинты, хуки (beforeChangeи др.) и функции access.
  • Код: Грузим весь проект через ts-morphдля удобного лазания по AST.

Этап 2: Поиск потоков (Taint Engine)​

Здесь мы ищем пути, по которым данные могут попасть из входа в опасное место.
  • Источники (Sources): req.body, req.query, данные из хуков (data, originalDoc).
  • Приемники (Sinks):Жесткий список опасных функций:
    • fetch, axios(SSRF)
    • .query(), $queryRaw(SQLi)
    • exec, spawn(RCE)
    • eval, new Function(Sandbox Escape)
  • Защита от зависаний: Ставим жесткие лимиты (не больше 500 узлов на путь, не дольше 2 секунд на анализ). Игнорируем node_modules. Если лимит превышен — помечаем как «неполный анализ».


Этап 3: Фильтрация до ИИ (Pre-LLM Filter)​

Это самый важный этап для экономии денег и нервов. Мы сами отсекаем заведомо безопасные пути, не дергая ИИ.

  1. Доступность: Если код доступен из веба — external(высокий приоритет). Если это внутренняя утилита — internal(низкий приоритет, почти не анализируем).
  2. Безопасность вызова:
    • HTTP:Если URL статичный — безопасно.
    • SQL:Если используется параметризация (query(sql, params)) — безопасно. Если строка собирается через ${}или +— опасно.
    • Exec:Если spawn('ls', ['-la'])(массив аргументов) — безопасно. Если exec('ls -la')(строка) — опасно.
  3. Автоматика для Eval: Если данные пользователя попадают в eval()в публичном коде — это автоматический баг. ИИ не спрашиваем.

Этап 4: ИИ-валидатор (GLM-4-Flash)​

ИИ вызываем только для спорных случаев. Мы даем ему маленький кусочек кода (срез пути) и спрашиваем: «Насколько реально это взломать?».

  • Запрещено: Просить ИИ определить тип уязвимости. Он этого не умеет.
  • Разрешено: Просить оценить эксплуатируемость, нужна ли авторизация, какие ограничения.
  • Ответ: Строгий JSON с полями exploitable: true/false, confidence, attack_vector.

Этап 5: Финальное решение (Verdict Engine)​

Наш код принимает решение на основе всех данных:

  • Если статический анализ доказал путь + код доступен из веба + ИИ сказал exploitable: true→ Это баг.
  • Если это fetch со статичным URL → Не баг.
  • Если это eval в публичном коде → Баг (даже если ИИ сказал, что нет).

​

3. Порядок работы (Pipeline)​

  1. Init: Грузим проект, парсим конфиги и зависимости.
  2. Discover: Находим все источники и приемники.
  3. Trace: Запускаем поиск путей (BFS). Вырезаем срезы кода для каждого пути.
  4. Tag: Помечаем, доступен ли код из веба (external/internal).
  5. Filter: Отсекаем безопасные пути (Pre-LLM). Eval-баги сразу в отчет.
  6. Validate: Спорные пути отправляем в ИИ.
  7. Decide: Применяем правила Verdict Engine.
  8. Report: Генерируем отчет.

​

4. Что попадет в отчет ​

Каждый баг должен иметь:

  • Тип: (RCE, SSRF, SQLi...)
  • Где: (Файл, строка)
  • Путь данных: (Как данные дошли от юзера до бага)
  • Доступность: (external— баг в проде, internal— баг в админке)
  • Вектор атаки: (Что нужно отправить в запросе)
  • PoC: (Минимальный curl-запрос)

​

5. Что НЕ делаем в MVP (Границы)​

  • :x: Не считаем Severity (Critical/High/Medium). Просто ищем баги.
  • :x: Не анализируем сложные случаи (Object Merge, Deserialization).
  • :x: Не уходим глубоко в вызовы функций (только 3 уровня вложенности).
  • :x: Не используем ИИ для определения версий библиотек.
  • :x: Не пропускаем internalкод (только помечаем его как низкоприоритетный).

​

6. Когда мы скажем, что всё готово​

  • Анализатор не падает на больших проектах (благодаря лимитам).
  • Pre-LLM фильтр отсекает 80% шума, не дергая ИИ.
  • ИИ никогда не принимает решение в одиночку.
  • Все баги имеют тег reachability(external/internal).
  • Корректно работает с ...spreadи export/import.

​

Схема работы профессионального софта и моего​


Не удалось прогрузить медиа
      
Не удалось прогрузить медиа
👍2

Часть II. Продолжение​


В чем проблема? а вот в чем: "Поиск потоков (Taint Engine)" - 2й пункт програмы.
Полноценный Taint Engine на ts-morph говорят что нереально сложно... Пишут так: " Inter-procedural анализ, generics, conditional types, module resolution  всё это в самописном движке будет ломаться постоянно. Поддержка под новые версии PayloadCMS съест всё время"

Что пишут редтим-еры, лучше: Semgrep → Targeted Slicer → LLM.
Что хочу я (предварительно, спорно): ts-morph Taint → Slicer → LLM.

Почему так? для PayloadCMS Semgrep без кастомных правил бесполезен, а с кастомными правилами -та же работа, что и написать адаптер на ts-morph.
Причина 2: мне не нужно закрыть все уязвимости, я не в блютим.
Что получучилось в проекте?

Гибридный подход, объединили​

  • Классический SAST (ts-morph + taint BFS + Pre-LLM фильтры) — детерминированно доказывает наличие потока данных, отсекает безопасный код и находит очевидные баги.
  • LLM (GLM-4-Flash / Claude) — семантически оценивает контекст, веб-доступность и реальную эксплуатируемость, что SAST сделать не может без огромных затрат.

Пайплайн:​

  • Используем ts-morphдля построения графа вызовов и инвентаризации Payload-специфичных сущностей.
  • Запускаем детерминированный taint-анализ (BFS), чтобы доказать поток данных от источников к стокам.
  • Вырезаем минимальные срезы кода для каждого подозрительного пути, отбрасывая шум.
  • Применяем Pre-LLM фильтры: отсекаем безопасные паттерны (параметризация, массивы аргументов) и автоматически помечаем eval-баги.
  • Отправляем спорные срезы в LLM (GLM-4-Flash) с жестким промптом на валидацию эксплуатируемости.
  • Принимаем финальное решение через Verdict Engine: баг подтвержден только при доказанном потоке, веб-доступности и вердикте ИИ.

В чем минус такого конструктива?
 
Что пропускает мой самопис​
Почему это допустимо для уязв.​
Как компенсировать​
Глубокие межпроцедурные потоки (>3 уровня)​
Обычно уже найдены SAST​
Ручной review критичных модулей​
Alias analysis, heap modeling - с кучей отдельный момент​
Сложные кейсы редки​
LLM видит семантику в срезе​
Полнота покрытия​
Не моя цель​
Фокус на high-impact sinks​
Стандартные паттерны (XSS, SQLi)​
Уже закрыты другими​
Edge: бизнес-логика, auth bypass​

ИИ предложил компенсировать - в 3й колонке


Смущает  то, что всем этим придется заниматься долго с непонятным результатом.

Вопрос. стоит ли оно того? Рабочая ли схема?

Обещали прочитать/отписаться :  @retro, @thomson и кто-то еще.
Велком;)
Сложно полноценно оценить задумку без кода и примеров работы (имею ввиду вторую часть), в целом выглядит классно, но по описанной архитектуре есть вопросы.

Taint Engine тема интересная, но в таких проектах очень легко уйти в оверинжиниринг и лишнее количество кода. Главное тут это не скатиться в излишние и необязательные усложнения. Взять тот же LangChain идея была хорошая, но из-за формата реализации и кучи ненужных абстракций в итоге всё превратилось в тыкву. Пока что весь этот пайплайн выглядит довольно тяжёлым, и есть ощущение, что в реальности он будет часто ломаться или требовать постоянной ручной доводки.

И вот кстати что касается LLM вообще. В текущей схеме он выглядит скорее как дополнительный инструмент валидации, чем как ключевой компонент. По сути LLM здесь выполняет роль более продвинутого алгоритма фильтрации уже найденных статическим анализом результатов. Если цель сделать инструмент, которым будут пользоваться люди (с возможностью использовать LLM как помощника), то ещё терпимо. Но если идея именно в автоматизации поиска уязвимостей через LLM, то текущая роль ИИ выглядит слабовато. Получается, что мы строим сложную статическую систему, а LLM используем как «обёртку и руки» для неё, хотя по факту он мог бы быть более центральным элементом.
В итоге складывается впечатление, что сложность архитектуры высокая, а реальная польза от добавления LLM не такая уж значительная.

Я бы скорее смотрел в сторону более лёгких и модульных решений, например по аналогии с MCP-инструментами (есть кстати интересный инструмент HexStrike). Плюс имеет смысл разделять категории уязвимостей на отдельные специализированные агенты/модули (отдельный для SSRF, отдельный для RCE, для auth bypass и т.д.), а не пытаться всё запихнуть в один большой пайплайн. Но это конкретно я, подход у всех всегда разный, и тот что обычно реализую в своих проектах не факт что уложится под твою идею.

Ну пока что тут правда особо расписывать нечего, так как проект еще на этапе формирования только. Но советую определиться сразу - нужен проект как инструмент для LLM или инструмент в котором LLM лишь валидатор?
👍3
Я сейчас мыслю так
1. фиксируем результаты предыдущего эксперимента:

Таблица 1: Cal.com ​


Тип уязвимостиНайдено всего (Raw)Осталось после фильтрации (Verified)ИзмененияSSRF9268-24. Отсеяны скрипты сборки, тесты (e2e), внутренние утилиты и CLI-инструменты, не имеющие прямого веб-доступа.RCE40-4. Все найденные RCE были в скриптах автоматизации (scripts/, playwright/), которые не доступны удаленно через сеть.Prototype Pollution50-5. Найденные загрязнения прототипов были в тестах или внутренних утилитах парсинга, не подверженных внешнему вводу.Command Injection30-3. Инъекции команд найдены только в процессах сборки и миграций БД.ИТОГО10468Фильтр отсек ~35% "шума", оставив только веб-векторы.

Таблица 2: Payload CMS​


Тип уязвимостиНайдено всего (Raw)Осталось после фильтрации (Verified)ИзмененияSSRF4232-10. Убраны находки в примерах (examples/), шаблонах (templates/) и внутренних плагинах, не используемых в ядре по умолчанию.RCE61-5. Большинство RCE было в инструментах релиза (tools/releaser) и CLI. Остался один критический вектор в плагине импорта/экспорта.Prototype Pollution61-5. Отсеяны загрязнения в тестах (*.spec.ts) и внутренних утилитах. Остался один подтвержденный риск в настройках предпочтений.Command Injection71-6. Почти все инъекции были в скриптах Docker и сборки. Остался один вектор в процессе релиза.SQL Injection10-1. Ложное срабатывание или вектор, закрытый на уровне ORM/валидации.ИТОГО6235Фильтр отсек ~43% находок, сосредоточившись на ядре CMS.

Напомню: логика была такая Часть I):

1. SCANNER.PY (Оркестратор):
   Читает репозитории → Фильтрует мусор → Находит targets.

2.HUNTER.PY (Анализатор):
Сканер → Охотник (на каждый файл) → GLM-4-Flash → Сырая оценка риска (JSON).

3. VERIFIER.PY (Аналитик):
Итоговый отчет → Проверка контекста (Доступно из веба?) → Скорректированная оценка.

4. Qwen 3.7 MAX - консультации в чате


Что делаем дальше? Выполняем план из Части II, строим пайплайн с другой парадигмой (да, я ее меняю)

Ответretro

И вот кстати что касается LLM вообще. В текущей схеме он выглядит скорее как дополнительный инструмент валидации, чем как ключевой компонент. По сути LLM здесь выполняет роль более продвинутого алгоритма фильтрации уже…

Именно.
Мне нужна кнопочка "подай на блюдечке".
В этой версии пайплайн работает на автоматику, а не в роли "помощника хакера". 

Чем я рискую? Посмотрим опытным путем. 
Самое плохое - потеря уязвимостей.

Результаты я сравню с результатами в таблице 2. Столько, больше, меньше. Используя эти шумные отчеты как отправную точку.
Как допишу и помучаю скрипты - опубликую.
👍2

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.