From 0a11d4fd23c3ab31d4ed14641007cd59df7985fe Mon Sep 17 00:00:00 2001 From: testclient-admin Date: Sat, 29 Aug 2026 18:58:55 +0000 Subject: [PATCH] Level 0: security scanning for pull requests --- .gitea/workflows/security.yml | 1223 +++++++++++++++++++++++++++++++++ 1 file changed, 1223 insertions(+) create mode 100644 .gitea/workflows/security.yml diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml new file mode 100644 index 0000000..08c5d95 --- /dev/null +++ b/.gitea/workflows/security.yml @@ -0,0 +1,1223 @@ +# Level 0 Security Engine — детерминированные сканеры на каждый +# pull request. Сгенерировано пайплайном seed-security-scanning +# (platform-infra) — источник правды там, правки сюда переживут +# только до следующего запуска того пайплайна. +name: security + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + domains: + description: "Домены аудита через запятую (пусто — все): auth,authz,injection,ssrf,secrets,business-logic" + default: "" + type: string + +jobs: + scan: + # Шаг ниже коммитит finding-файл обратно в ветку PR от имени + # security-бота — без этой защиты каждый такой коммит сам + # порождает pull_request:synchronize и скан гоняется по кругу + # бесконечно (проверено живьём: 19 итераций за ~20 минут, + # пока PR не закрыли вручную). Ограничение на pull_request — + # без него job ещё и падает на workflow_dispatch (ручной + # запуск deep-audit): PR-контекста нет, gitea.event.pull_request + # пустой (проверено живьём вместе с тем же прогоном). + if: ${{ gitea.event_name == 'pull_request' && !endsWith(gitea.actor, '-security-bot') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + ref: ${{ gitea.head_ref }} + + - name: Установить инструменты + run: | + apt-get update -qq + apt-get install -y -qq python3 python3-pip curl git >/dev/null + pip install --break-system-packages --quiet semgrep pyyaml + + - name: gitleaks (secret scanning) + run: | + ARCH=$(uname -m) + case "$ARCH" in + x86_64) GL_ARCH=x64 ;; + aarch64|arm64) GL_ARCH=arm64 ;; + *) echo "неизвестная архитектура: $ARCH" >&2; exit 1 ;; + esac + curl -sSL -o /tmp/gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_${GL_ARCH}.tar.gz" + tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks + + # curl-auth-header — дефолтное правило gitleaks, ловит ЛЮБОЙ + # "curl -H \"Authorization: Bearer ...\"" по форме, не по + # содержимому. Подтверждено живьём на нескольких клиентских + # README: ни одна формулировка примера (одинаковый токен, + # разные токены, плейсхолдер в угловых скобках) не проходит + # — а HIGH-находка блокирует мерж навсегда, потому что + # документация с примером curl-запроса есть почти у любого + # проекта с API. Точечно исключаем только эту находку на + # markdown-файлах; остальные правила (реальные секреты по + # энтропии/префиксам) продолжают действовать и там. + cat > /tmp/.gitleaks.toml <<'GLCFG' + [extend] + useDefault = true + + [[rules]] + id = "curl-auth-header" + + [rules.allowlist] + paths = ['''(?i)\.md$'''] + GLCFG + + gitleaks detect --source=. --config=/tmp/.gitleaks.toml \ + --report-format json --report-path /tmp/gitleaks.json --exit-code 0 -v || true + [ -f /tmp/gitleaks.json ] || echo '[]' > /tmp/gitleaks.json + + - name: semgrep (SAST) + run: | + semgrep scan --config=p/security-audit --json --output=/tmp/semgrep.json . || true + [ -f /tmp/semgrep.json ] || echo '{"results":[]}' > /tmp/semgrep.json + + - name: Свести в Finding, записать, прокомментировать, выставить статус + env: + SECURITY_BOT_TOKEN: ${{ secrets.SECURITY_BOT_TOKEN }} + GITEA_SERVER: ${{ gitea.server_url }} + REPO_FULL: ${{ gitea.repository }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + COMMIT_SHA: ${{ gitea.event.pull_request.head.sha }} + run: | + python3 <<'PYEOF' + import json, os, sys, time, urllib.error, urllib.request + + SERVER = os.environ["GITEA_SERVER"].rstrip("/") + REPO = os.environ["REPO_FULL"] + TOKEN = os.environ["SECURITY_BOT_TOKEN"] + PR_NUMBER = os.environ.get("PR_NUMBER", "") + COMMIT_SHA = os.environ["COMMIT_SHA"] + + def gitea(method, path, payload=None): + req = urllib.request.Request( + f"{SERVER}/api/v1{path}", method=method, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + body = r.read() + return json.loads(body) if body else {} + except urllib.error.HTTPError as e: + sys.exit(f"FAIL: {method} {path} -> {e.code}: {e.read()[:400]}") + + findings = [] + + try: + leaks = json.load(open("/tmp/gitleaks.json")) or [] + except Exception: + leaks = [] + for i, leak in enumerate(leaks): + findings.append({ + "id": f"gitleaks-{leak.get('Fingerprint', i)}", + "source": "gitleaks", "category": "secret", + "severity": "high", "confidence": 0.9, + "file": leak.get("File", ""), + "line_start": leak.get("StartLine", 0), + "line_end": leak.get("EndLine", leak.get("StartLine", 0)), + "title": f"Potential secret: {leak.get('RuleID','')}", + "description": leak.get("Description", ""), + "rule_id": leak.get("RuleID", ""), "cwe": "CWE-798", + "status": "OPEN", + }) + + try: + sg = json.load(open("/tmp/semgrep.json")) + except Exception: + sg = {"results": []} + + SEV_MAP = {"ERROR": "high", "WARNING": "medium", "INFO": "low"} + CONF_MAP = {"HIGH": 0.85, "MEDIUM": 0.7, "LOW": 0.5} + for r in sg.get("results", []): + extra = r.get("extra", {}) or {} + meta = extra.get("metadata", {}) or {} + cwe = meta.get("cwe") + if isinstance(cwe, list): + cwe = cwe[0] if cwe else None + findings.append({ + "id": f"semgrep-{r.get('check_id','')}-{r.get('start',{}).get('line',0)}", + "source": "semgrep", "category": "sast", + "severity": SEV_MAP.get(extra.get("severity", "WARNING"), "medium"), + "confidence": CONF_MAP.get(meta.get("confidence", ""), 0.7), + "file": r.get("path", ""), + "line_start": r.get("start", {}).get("line", 0), + "line_end": r.get("end", {}).get("line", 0), + "title": (extra.get("message", r.get("check_id", "")) or "")[:120], + "description": extra.get("message", ""), + "rule_id": r.get("check_id", ""), "cwe": cwe, + "status": "OPEN", + }) + + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + for f in findings: + f["created_at"] = now + f["pr_number"] = int(PR_NUMBER) if PR_NUMBER else None + f["commit_sha"] = COMMIT_SHA + + policy = {"block_on": ["critical", "high"], "min_confidence": 0.7} + if os.path.exists("security-analysis/policy.yml"): + try: + import yaml + loaded = (yaml.safe_load(open("security-analysis/policy.yml")) or {}).get("security", {}) + policy.update({k: v for k, v in loaded.items() if k in policy}) + except Exception as e: + print(f"не удалось прочитать policy.yml, беру дефолты: {e}") + + blocking = [f for f in findings if f["severity"] in policy["block_on"] and f["confidence"] >= policy["min_confidence"]] + blocked = len(blocking) > 0 + + # Один и тот же коммит может быть провёрен дважды (см. баг + # Gitea с переиспользованием SHA между разными PR — два + # параллельных прогона на идентичный коммит). Статус в + # Gitea хранит только ПОСЛЕДНЮЮ запись по (sha, context), + # так что более поздний, но менее строгий результат тихо + # затирает более ранний блокирующий. Раз уже была + # зафиксирована блокировка на этом коммите — не снимаем + # её молча, только новым коммитом. + prior_failed = False + try: + prior = gitea("GET", f"/repos/{REPO}/commits/{COMMIT_SHA}/statuses") + prior_failed = any(s.get("context") == "security/scan" and s.get("status") == "failure" for s in prior) + except Exception as e: + print(f"не удалось прочитать прежние статусы, продолжаю без sticky-fail: {e}") + sticky = prior_failed and not blocked + + # Раньше находки ещё и коммитились в ветку PR. Плохая + # идея с точки зрения git-гигиены: чужая ветка растёт + # коммитами от бота, каждый такой коммит сам порождает + # pull_request:synchronize (нужна отдельная защита от + # петли), а когда коммит бота становится головой PR — + # обязательные security/scan и security/review больше + # никогда не выставляются на этой голове, потому что + # job их специально пропускает для бота же — PR + # перестаёт мержиться насовсем (поймано живьём). + # Комментарий в PR (ниже) и так несёт всё нужное + # человеку; отдельного коммита с JSON не требуется. + counts = {} + for f in findings: + counts[f["severity"]] = counts.get(f["severity"], 0) + 1 + order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + top = sorted(findings, key=lambda f: order.get(f["severity"], 4))[:5] + + body = "## Security scan (Level 0)\n\n" + body += "\n".join(f"- **{sev.upper()}**: {n}" for sev, n in sorted(counts.items(), key=lambda kv: order.get(kv[0], 4))) or "Находок нет." + body += "\n\n" + for f in top: + body += f"**{f['severity'].upper()}** `{f['file']}:{f['line_start']}` — {f['title']} ({f['source']})\n\n" + if sticky: + body += "🔴 **Merge заблокирован** — этот же коммит уже был помечен как блокирующий в более раннем прогоне; новый прогон нашёл меньше, но старую блокировку это не снимает (нужен новый коммит).\n" + else: + body += "🔴 **Merge заблокирован** — есть находки выше порога `security-analysis/policy.yml`.\n" if blocked else "✅ Порог не превышен.\n" + + gitea("POST", f"/repos/{REPO}/issues/{PR_NUMBER}/comments", {"body": body}) + + gitea("POST", f"/repos/{REPO}/statuses/{COMMIT_SHA}", { + "state": "failure" if (blocked or sticky) else "success", + "context": "security/scan", + "description": (f"{len(findings)} findings, {len(blocking)} blocking" + (" (sticky: prior run flagged this commit)" if sticky else "")), + }) + + print(f"findings: {len(findings)}, blocking: {len(blocking)}, sticky: {sticky}") + if blocked or sticky: + sys.exit(1) + PYEOF + + review: + # Тот же риск и та же защита, что у scan: шаг ниже коммитит + # finding-файл от имени security-бота — без guard это снова + # самоподдерживающийся pull_request:synchronize. Плюс то же + # ограничение на pull_request, что и у scan — без него падает + # на workflow_dispatch (ручной запуск deep-audit). + if: ${{ gitea.event_name == 'pull_request' && !endsWith(gitea.actor, '-security-bot') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + ref: ${{ gitea.head_ref }} + + - name: Достать opencode и агента-ревьюера (без Docker — раннер клиента и так несёт риск совмещения с его же Gitea) + run: | + curl -fsSL -o ./opencode \ + "https://git.platform.aiagile.pro/platform-admin/artifacts/releases/download/v0.0.1/opencode" + chmod +x ./opencode + ./opencode --version + + mkdir -p "$HOME/.config/opencode/agents" + cat > "$HOME/.config/opencode/agents/security-reviewer.md" <<'AGENTEOF' + --- + description: Level 1 AI Security Review — читает диф PR и ищет уязвимости, которые не ловят детерминированные сканеры. Не пишет и не правит код. + mode: primary + model: litellm/coder-fast + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — security-ревьюер pull request'ов. Тебе НЕЛЬЗЯ писать, править или + исполнять код — только читать файлы, искать по репозиторию (grep/glob) и + переходить по символам (lsp). Если инструмент для записи/выполнения + недоступен — это ожидаемо, не пытайся обойти ограничение. + + Тебе дают диф PR и путь к репозиторию, уже склонированному локально. + Задача — понять, может ли этот диф внести новую security-уязвимость, + которую детерминированные сканеры (semgrep/gitleaks) не ловят: логические + дыры авторизации, IDOR, обход аутентификации, инъекции, SSRF, path + traversal, небезопасную десериализацию, XSS/CSRF, утечку чувствительных + данных, слабую криптографию, небезопасные файловые операции, command + execution, гонки состояний, регрессии безопасности в бизнес-логике. + + Правила: + - Смотри не только на изменённые строки, но и на код, который они вызывают + или которым вызываются (grep по имени функции/класса), если это нужно + для вывода об эксплуатируемости. + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + проверяй себя сам, прежде чем включать finding в ответ: как именно + атакующий этим воспользуется, при каких условиях. + - Низкая уверенность — либо не включай finding вообще, либо ставь + confidence ниже 0.5 и severity не выше "medium". Не заваливай PR + находками ради находок. + - Если уязвимостей, достойных внимания, нет — верни пустой массив. + + Формат ответа — СТРОГО одно финальное сообщение, содержащее ТОЛЬКО JSON- + массив (без пояснений до/после, без markdown-обёртки), каждый элемент: + + { + "category": "auth|authz|idor|injection|ssrf|path-traversal|deserialization|xss|csrf|sensitive-data|crypto|secrets|file-ops|command-exec|business-logic|race-condition", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации: кто, как, при каких условиях", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null, если не применимо" + } + AGENTEOF + + - name: Настроить шлюз + env: + LITELLM_API_KEY: ${{ secrets.SECURITY_REVIEWER_LITELLM_KEY }} + run: | + mkdir -p "$HOME/.config/opencode" + cat > "$HOME/.config/opencode/opencode.json" < /tmp/pr.diff || true + wc -l /tmp/pr.diff + + - name: Запустить security-reviewer + env: + # env: скопирован на шаг, не на весь job — тот же ключ + # нужен здесь снова, не только в шаге "Настроить + # шлюз", где он лишь попадает в текст конфига как + # плейсхолдер {env:LITELLM_API_KEY}, а не значение. + LITELLM_API_KEY: ${{ secrets.SECURITY_REVIEWER_LITELLM_KEY }} + run: | + MESSAGE="Проанализируй диф этого pull request. Репозиторий уже склонирован в текущей директории, на ветке PR. Диф (может быть обрезан до 60000 символов): + + $(head -c 60000 /tmp/pr.diff)" + # Не даём set -e (дефолт шелла в Actions) убить шаг ДО + # того, как мы успеем напечатать диагностику: находки + # разбираются следующим шагом независимо от кода + # возврата, а вот залипание без единой строчки лога — + # чинить нечего. + set +e + ./opencode run --agent security-reviewer --format json "$MESSAGE" > /tmp/review-events.jsonl 2>/tmp/review.log + CODE=$? + set -e + echo "opencode run завершился кодом $CODE" + echo "--- /tmp/review.log (stderr) ---" + tail -60 /tmp/review.log || true + echo "--- /tmp/review-events.jsonl (stdout, событий: $(wc -l < /tmp/review-events.jsonl 2>/dev/null || echo 0)) ---" + tail -20 /tmp/review-events.jsonl || true + + - name: Свести в Finding, записать, прокомментировать, выставить статус + env: + SECURITY_BOT_TOKEN: ${{ secrets.SECURITY_BOT_TOKEN }} + GITEA_SERVER: ${{ gitea.server_url }} + REPO_FULL: ${{ gitea.repository }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + COMMIT_SHA: ${{ gitea.event.pull_request.head.sha }} + run: | + python3 <<'PYEOF2' + import json, os, sys, time, urllib.error, urllib.request + + SERVER = os.environ["GITEA_SERVER"].rstrip("/") + REPO = os.environ["REPO_FULL"] + TOKEN = os.environ["SECURITY_BOT_TOKEN"] + PR_NUMBER = os.environ.get("PR_NUMBER", "") + COMMIT_SHA = os.environ["COMMIT_SHA"] + + def gitea(method, path, payload=None): + req = urllib.request.Request( + f"{SERVER}/api/v1{path}", method=method, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + body = r.read() + return json.loads(body) if body else {} + except urllib.error.HTTPError as e: + sys.exit(f"FAIL: {method} {path} -> {e.code}: {e.read()[:400]}") + + # Последний текстовый ответ агента — по инструкции промпта это + # ЕДИНСТВЕННЫЙ JSON-массив, без пояснений и markdown-обёртки. + last_text = "" + try: + with open("/tmp/review-events.jsonl") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except Exception: + continue + if event.get("type") == "text": + part = event.get("part") or {} + text = part.get("text", "") + if text.strip(): + last_text = text + except FileNotFoundError: + pass + + text = last_text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.startswith("json"): + text = text[4:] + try: + raw_findings = json.loads(text) if text else [] + if not isinstance(raw_findings, list): + raw_findings = [] + except Exception as e: + print(f"не удалось разобрать ответ агента как JSON: {e}") + print(f"сырой ответ: {last_text[:2000]}") + raw_findings = [] + + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + findings = [] + for i, r in enumerate(raw_findings): + findings.append({ + "id": f"ai-review-{i}-{r.get('file','')}-{r.get('line_start',0)}", + "source": "ai-review", "category": r.get("category", "unknown"), + "severity": r.get("severity", "medium"), + "confidence": r.get("confidence", 0.5), + "file": r.get("file", ""), + "line_start": r.get("line_start", 0), + "line_end": r.get("line_end", r.get("line_start", 0)), + "title": (r.get("title", "") or "")[:120], + "description": r.get("description", ""), + "impact": r.get("impact", ""), + "exploit_scenario": r.get("exploit_scenario", ""), + "evidence": r.get("evidence", ""), + "recommendation": r.get("recommendation", ""), + "cwe": r.get("cwe"), + "status": "OPEN", + "created_at": now, + "pr_number": int(PR_NUMBER) if PR_NUMBER else None, + "commit_sha": COMMIT_SHA, + }) + + policy = {"block_on": ["critical", "high"], "min_confidence": 0.7} + if os.path.exists("security-analysis/policy.yml"): + try: + import yaml + loaded = (yaml.safe_load(open("security-analysis/policy.yml")) or {}).get("security", {}) + policy.update({k: v for k, v in loaded.items() if k in policy}) + except Exception as e: + print(f"не удалось прочитать policy.yml, беру дефолты: {e}") + + blocking = [f for f in findings if f["severity"] in policy["block_on"] and f["confidence"] >= policy["min_confidence"]] + blocked = len(blocking) > 0 + + # См. аналогичный комментарий в шаге scan: LLM-ревьюер не + # детерминирован, а один коммит может быть провёрен дважды + # параллельно (баг Gitea с переиспользованием SHA между + # PR). Если более ранний прогон на ЭТОМ ЖЕ коммите уже + # нашёл блокирующую находку, более поздний "пустой" ответ + # модели не должен тихо снимать блокировку. + prior_failed = False + try: + prior = gitea("GET", f"/repos/{REPO}/commits/{COMMIT_SHA}/statuses") + prior_failed = any(s.get("context") == "security/review" and s.get("status") == "failure" for s in prior) + except Exception as e: + print(f"не удалось прочитать прежние статусы, продолжаю без sticky-fail: {e}") + sticky = prior_failed and not blocked + + # Коммит с findings-json в ветку PR убран — та же причина, + # что и в шаге scan: он ломает мержимость PR насовсем, + # как только становится головой (см. комментарий там). + counts = {} + for f in findings: + counts[f["severity"]] = counts.get(f["severity"], 0) + 1 + order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + top = sorted(findings, key=lambda f: order.get(f["severity"], 4))[:5] + + body = "## AI Security Review (Level 1)\n\n" + if not findings: + body += "Находок нет.\n" + else: + body += "\n".join(f"- **{sev.upper()}**: {n}" for sev, n in sorted(counts.items(), key=lambda kv: order.get(kv[0], 4))) + body += "\n\n" + for f in top: + body += f"**{f['severity'].upper()}** (`{f['confidence']:.2f}`) `{f['file']}:{f['line_start']}` — {f['title']}\n\n" + if f.get("exploit_scenario"): + body += f" сценарий: {f['exploit_scenario']}\n\n" + if sticky: + body += "🔴 **Merge заблокирован** — этот же коммит уже был помечен как блокирующий в более раннем прогоне ревью; новый прогон ничего не нашёл, но старую блокировку это не снимает (нужен новый коммит).\n" + else: + body += "🔴 **Merge заблокирован** — есть находки выше порога `security-analysis/policy.yml`.\n" if blocked else "✅ Порог не превышен.\n" + + gitea("POST", f"/repos/{REPO}/issues/{PR_NUMBER}/comments", {"body": body}) + + gitea("POST", f"/repos/{REPO}/statuses/{COMMIT_SHA}", { + "state": "failure" if (blocked or sticky) else "success", + "context": "security/review", + "description": (f"{len(findings)} findings, {len(blocking)} blocking" + (" (sticky: prior run flagged this commit)" if sticky else "")), + "target_url": f"{SERVER}/{REPO}", + }) + + print(f"findings: {len(findings)}, blocking: {len(blocking)}, sticky: {sticky}") + if blocked or sticky: + sys.exit(1) + PYEOF2 + + deep-audit: + if: ${{ gitea.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Достать opencode + run: | + curl -fsSL -o ./opencode \ + "https://git.platform.aiagile.pro/platform-admin/artifacts/releases/download/v0.0.1/opencode" + chmod +x ./opencode + ./opencode --version + + - name: Настроить шлюз + env: + LITELLM_API_KEY: ${{ secrets.DEEP_AUDIT_LITELLM_KEY }} + run: | + mkdir -p "$HOME/.config/opencode/agents" + cat > "$HOME/.config/opencode/opencode.json" < "$HOME/.config/opencode/agents/audit-auth.md" <<'AUDIT_AUTH_EOF' + --- + description: Level 2 Deep Security Audit — Authentication. Читает весь репозиторий (не диф), ищет проблемы аутентификации. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — ТОЛЬКО аутентификация. Не отвлекайся на авторизацию, инъекции + и прочее — для них есть свои агенты; дублирование только шумит. + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. В отличие от + PR-ревью, у тебя нет дифа — смотри на весь проект. + + Что искать: + - отсутствие проверки аутентификации на чувствительных эндпоинтах; + - слабое хранение/сравнение паролей (без хеширования, сравнение не + constant-time, свой велосипед вместо bcrypt/argon2/scrypt); + - уязвимости в выпуске/валидации токенов и сессий (JWT без проверки + подписи или алгоритма, предсказуемые session id, отсутствие срока + жизни/инвалидации при логауте); + - ошибки в OAuth/SSO/MFA-потоках (небезопасный redirect_uri, отсутствие + проверки state, обход MFA через альтернативный путь); + - небезопасное восстановление пароля (предсказуемый токен, отсутствие + срока действия, утечка через ответ/тайминг). + + Правила: + - Начни с широкого обзора (glob по путям вроде auth/login/session/token), + затем читай конкретные файлы. + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий атаки, прежде чем включать finding в ответ. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "auth", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_AUTH_EOF + + cat > "$HOME/.config/opencode/agents/audit-authz.md" <<'AUDIT_AUTHZ_EOF' + --- + description: Level 2 Deep Security Audit — Authorization/IDOR. Читает весь репозиторий (не диф), ищет проблемы доступа. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — ТОЛЬКО авторизация и IDOR: кто ЧТО может делать, а не кто он + такой (это домен другого агента — auth). Не дублируй чужие домены. + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. Смотри на весь + проект, не только на недавние изменения. + + Что искать: + - IDOR: эндпоинты, принимающие id ресурса (в пути, query, body) без + проверки, что текущий пользователь имеет право на ЭТОТ конкретный + ресурс (владение, членство в организации/команде и т.п.); + - отсутствие проверки роли/права на административных или чувствительных + операциях (проверяется "залогинен ли", а не "имеет ли право именно на + это действие"); + - horizontal privilege escalation (пользователь A получает данные/меняет + данные пользователя B на том же уровне прав); + - vertical privilege escalation (обычный пользователь достигает + admin-функциональности через недостаточно защищённый путь); + - несогласованные проверки прав между разными интерфейсами к одному и + тому же ресурсу (например, API проверяет, а внутренний RPC/фоновая + задача — нет). + + Правила: + - Ищи по паттернам маршрутов/контроллеров, где ресурс достаётся по id, и + проверяй, есть ли рядом сверка владельца/прав. + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий: какой запрос, от чьего имени, к чьим данным. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "authz|idor", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_AUTHZ_EOF + + cat > "$HOME/.config/opencode/agents/audit-injection.md" <<'AUDIT_INJECTION_EOF' + --- + description: Level 2 Deep Security Audit — Injection. Читает весь репозиторий (не диф), ищет инъекции. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — ТОЛЬКО инъекции: SQL, command, template, LDAP, NoSQL, + insecure deserialization, path traversal через построение пути из + пользовательского ввода. Не дублируй домены других агентов (auth, authz). + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. Смотри на весь + проект, не только на недавние изменения. Учти: детерминированные сканеры + (semgrep/gitleaks) уже гоняются на каждый PR — фокусируйся на том, что + паттерн-based сканер вероятно пропустит: инъекции через нетривиальную + цепочку вызовов, кастомные ORM-обёртки, динамическую сборку запросов + через конкатенацию в нескольких местах, десериализацию непроверенных + данных через нестандартные библиотеки. + + Что искать: + - SQL/NoSQL-инъекции: конкатенация пользовательского ввода в запрос + вместо параметризации/prepared statements; + - command injection: пользовательский ввод в вызовах shell + (os.system/subprocess с shell=True, exec/eval, аналоги в других языках); + - template injection: пользовательский ввод, передаваемый как шаблон, а + не как данные для шаблона; + - path traversal: пользовательский ввод в построении файлового пути без + канонизации/проверки выхода за пределы разрешённой директории; + - insecure deserialization: pickle/yaml.load без SafeLoader, + небезопасная десериализация объектов из непроверенного источника. + + Правила: + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий: какой ввод, куда он попадает, что выполнится. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "injection|deserialization|path-traversal", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_INJECTION_EOF + + cat > "$HOME/.config/opencode/agents/audit-ssrf.md" <<'AUDIT_SSRF_EOF' + --- + description: Level 2 Deep Security Audit — Network/SSRF. Читает весь репозиторий (не диф), ищет проблемы исходящих запросов. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — ТОЛЬКО сетевые запросы, инициируемые сервером: SSRF, небезопасные + webhook'и, open redirect (когда он ведёт к SSRF или используется для обхода + allowlist). Не дублируй домены других агентов. + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. Смотри на весь + проект, не только на недавние изменения. + + Что искать: + - HTTP-запросы (requests/httpx/urllib/fetch/axios и т.п.), где хост, URL + или схема берутся из пользовательского ввода без allowlist; + - webhook-регистрация/колбэки, где адрес назначения задаёт пользователь + (классический вектор SSRF во внутреннюю сеть/metadata-эндпоинты + облака, напр. 169.254.169.254); + - отсутствие защиты от редиректов на внутренние адреса (запрос идёт по + адресу из allowlist, но следует за 3xx на произвольный адрес); + - SSRF через второстепенные каналы: генерация превью по ссылке, + импорт по URL, проверка "доступности" стороннего ресурса. + + Правила: + - Проверяй, есть ли валидация хоста/IP (блокировка приватных диапазонов, + localhost, cloud metadata) до того, как уйти в сеть. + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий: какой параметр, какой адрес получится. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "ssrf", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_SSRF_EOF + + - name: Записать доменных агентов (2/2) и верификатора + run: | + cat > "$HOME/.config/opencode/agents/audit-secrets.md" <<'AUDIT_SECRETS_EOF' + --- + description: Level 2 Deep Security Audit — Secrets/Cryptography. Читает весь репозиторий (не диф), ищет проблемы секретов и криптографии. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — секреты и криптография. gitleaks уже ловит секреты по + regex-паттернам на каждый PR — фокусируйся на том, что паттерн-based + сканер пропустит: логические проблемы обращения с секретами и слабую + криптографию, а не "нашёл строку похожую на ключ". + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. Смотри на весь + проект. + + Что искать: + - секреты, попадающие в логи, сообщения об ошибках, ответы API или + клиентский (frontend) код; + - слабая криптография: самописные схемы шифрования, устаревшие алгоритмы + (MD5/SHA1 для паролей, DES/RC4), ECB-режим, статичный/предсказуемый IV + или nonce; + - небезопасная генерация случайных значений для токенов/ключей + (random вместо secrets/crypto-safe генератора); + - один и тот же ключ/секрет захардкожен и используется как для + разработки, так и подразумевается для продакшена; + - секреты, передаваемые между сервисами в открытом виде там, где + ожидается защищённый канал; + - сравнение секретов/токенов не constant-time (тайминг-атака). + + Правила: + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий атаки. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "secrets|crypto", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_SECRETS_EOF + + cat > "$HOME/.config/opencode/agents/audit-business-logic.md" <<'AUDIT_BUSINESS_LOGIC_EOF' + --- + description: Level 2 Deep Security Audit — Business Logic. Читает весь репозиторий (не диф), ищет уязвимости бизнес-логики. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — специализированный security-агент из Deep Security Audit (Level 2). + Твой домен — уязвимости бизнес-логики: то, что не ловится ни сканером + паттернов, ни другими доменными агентами, потому что само по себе не + "неправильный код", а неправильное ДОВЕРИЕ к тому, что клиент/пользователь + не будет действовать недобросовестно. + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + Тебе дают путь к репозиторию, уже склонированному локально. Смотри на весь + проект. + + Что искать: + - цена/количество/скидка приходят от клиента и используются напрямую, а + не пересчитываются на сервере; + - многошаговые процессы (оплата, подтверждение, эскалация прав), где + можно пропустить шаг, обратившись сразу к следующему эндпоинту; + - гонки состояний (race condition) на операциях, где повторный + параллельный запрос даёт результат, которого не должно быть + (двойное списание, повторное использование одноразового купона/токена); + - отсутствие ограничений частоты/количества там, где это критично + (перебор кодов подтверждения, массовая генерация ресурсов); + - необратимые операции без подтверждения, чувствительные к CSRF или + повторной отправке. + + Правила: + - Ищи по местам, где логика зависит от порядка вызовов или от данных, + присланных клиентом, которые логически должны считаться на сервере. + - Не сообщай о находке, если не уверен, что она реально эксплуатируема — + опиши себе точный сценарий: какую последовательность действий сделает + атакующий и что он этим получит. + - Низкая уверенность — confidence ниже 0.5 и severity не выше "medium". + - Если ничего не нашёл — верни пустой массив, не выдумывай находки. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО JSON-массив (без + пояснений, без markdown-обёртки), каждый элемент: + + { + "category": "business-logic|race-condition", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "file": "путь/к/файлу.ext", + "line_start": 1, + "line_end": 1, + "title": "короткое название находки", + "description": "что не так, по-русски", + "impact": "чем это грозит", + "exploit_scenario": "конкретный сценарий эксплуатации", + "evidence": "цитата кода/цепочка вызовов, на которой строится вывод", + "recommendation": "как исправить", + "cwe": "CWE-XXX или null" + } + AUDIT_BUSINESS_LOGIC_EOF + + cat > "$HOME/.config/opencode/agents/audit-verifier.md" <<'AUDIT_VERIFIER_EOF' + --- + description: Level 2 Deep Security Audit — независимый верификатор находок. Читает репозиторий, пытается ОПРОВЕРГНУТЬ находку, а не подтвердить. Не пишет и не правит код. + mode: primary + model: litellm/coder-think + permission: + read: allow + grep: allow + glob: allow + bash: deny + edit: deny + webfetch: deny + websearch: deny + task: deny + todowrite: deny + skill: deny + lsp: allow + --- + Ты — независимый верификатор находок Deep Security Audit (Level 2). Тебе + дают ОДНУ находку (JSON), которую сгенерировал другой агент по своему + домену, и путь к тому же репозиторию. Ты её не писал и не обязан с ней + соглашаться. + + Твоя задача противоположна задаче агента-исследователя: не подтвердить + находку, а СОЗНАТЕЛЬНО ПОПЫТАТЬСЯ ЕЁ ОПРОВЕРГНУТЬ. Это единственный смысл + твоего существования в пайплайне — если ты просто соглашаешься с чужим + выводом, находка проходит без реальной проверки. + + Тебе НЕЛЬЗЯ писать, править или исполнять код — только read/grep/glob/lsp. + + Как проверять: + - Открой указанный файл и строки — действительно ли код выглядит так, как + описано в evidence, или агент ошибся с путём/строкой/контекстом? + - Есть ли защита ВЫШЕ по стеку вызовов, которую агент-исследователь мог + пропустить (мидлварь, декоратор, проверка на уровне маршрутизатора, + валидация схемы), которая на самом деле блокирует описанный сценарий? + - Реалистичен ли exploit_scenario технически, или он требует условий, + которых в реальности не будет (например, endpoint вообще недостижим + извне, или доступен только уже аутентифицированному админу, у которого + и так есть это право)? + - Если возможность эксплуатации ЕСТЬ, но menее серьёзна, чем заявлено + (например, не critical, а medium) — понизь severity/confidence, а не + отклоняй целиком. + + Формат ответа — СТРОГО одно финальное сообщение, ТОЛЬКО один JSON-объект + (без пояснений, без markdown-обёртки): + + { + "verdict": "CONFIRMED|REJECTED", + "severity": "critical|high|medium|low", + "confidence": 0.0, + "reasoning": "почему подтверждено или опровергнуто, конкретно, со ссылкой на код" + } + + Если verdict — REJECTED, severity/confidence всё равно заполни (тем, что + было бы, если бы находка всё же подтвердилась) — они не используются при + REJECTED, но поле обязательно для единообразия формата. + AUDIT_VERIFIER_EOF + + - name: Прогнать доменных агентов последовательно + env: + # env: скопирован на шаг, не на весь job (та же + # ошибка уже была и в Level 1) — ключ нужен здесь + # снова, не только на шаге "Настроить шлюз". + LITELLM_API_KEY: ${{ secrets.DEEP_AUDIT_LITELLM_KEY }} + run: | + DOMAINS="${{ inputs.domains }}" + [ -n "$DOMAINS" ] || DOMAINS="auth,authz,injection,ssrf,secrets,business-logic" + echo "$DOMAINS" > /tmp/audit-domains.txt + mkdir -p /tmp/audit-results + IFS=',' read -ra DOMAIN_ARR <<< "$DOMAINS" + for domain in "${DOMAIN_ARR[@]}"; do + echo "=== домен: $domain ===" + set +e + ./opencode run --agent "audit-$domain" --format json \ + "Проведи аудит своего домена по всему репозиторию. Репозиторий уже склонирован в текущей директории (рабочая копия main)." \ + > "/tmp/audit-results/$domain.jsonl" 2>"/tmp/audit-results/$domain.log" + CODE=$? + set -e + echo " код завершения: $CODE" + echo " --- stderr ---" + tail -30 "/tmp/audit-results/$domain.log" || true + echo " --- stdout (события) ---" + tail -10 "/tmp/audit-results/$domain.jsonl" || true + done + + - name: Верифицировать находки, сформировать отчёт и открыть PR + env: + SECURITY_BOT_TOKEN: ${{ secrets.SECURITY_BOT_TOKEN }} + GITEA_SERVER: ${{ gitea.server_url }} + REPO_FULL: ${{ gitea.repository }} + LITELLM_API_KEY: ${{ secrets.DEEP_AUDIT_LITELLM_KEY }} + run: | + python3 <<'PYEOF3' + import base64, json, os, subprocess, sys, time, urllib.error, urllib.request + + SERVER = os.environ["GITEA_SERVER"].rstrip("/") + REPO = os.environ["REPO_FULL"] + TOKEN = os.environ["SECURITY_BOT_TOKEN"] + + def gitea(method, path, payload=None): + req = urllib.request.Request( + f"{SERVER}/api/v1{path}", method=method, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + body = r.read() + return json.loads(body) if body else {} + except urllib.error.HTTPError as e: + sys.exit(f"FAIL: {method} {path} -> {e.code}: {e.read()[:400]}") + + def last_text(path): + text = "" + try: + with open(path) as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except Exception: + continue + if event.get("type") == "text": + part = event.get("part") or {} + t = part.get("text", "") + if t.strip(): + text = t + except FileNotFoundError: + pass + t = text.strip() + if t.startswith("```"): + t = t.strip("`") + if t.startswith("json"): + t = t[4:] + return t + + domains = [d for d in open("/tmp/audit-domains.txt").read().strip().split(",") if d] + + candidates = [] + for domain in domains: + text = last_text(f"/tmp/audit-results/{domain}.jsonl") + try: + arr = json.loads(text) if text else [] + if not isinstance(arr, list): + arr = [] + except Exception as e: + print(f"{domain}: не удалось разобрать ответ агента ({e})") + arr = [] + for item in arr: + item["_domain"] = domain + candidates.append(item) + + print(f"кандидатов от доменных агентов: {len(candidates)}") + + confirmed = [] + for i, cand in enumerate(candidates): + payload_text = json.dumps(cand, ensure_ascii=False) + with open(f"/tmp/audit-results/verify-{i}.jsonl", "w") as out, \ + open(f"/tmp/audit-results/verify-{i}.log", "w") as errlog: + subprocess.run( + ["./opencode", "run", "--agent", "audit-verifier", "--format", "json", + f"Проверь эту находку (JSON), репозиторий уже склонирован рядом: {payload_text}"], + stdout=out, stderr=errlog, + ) + verdict_text = last_text(f"/tmp/audit-results/verify-{i}.jsonl") + try: + verdict = json.loads(verdict_text) if verdict_text else {} + except Exception: + verdict = {} + print(f" candidate {i} ({cand.get('_domain')}): {verdict.get('verdict', 'НЕТ ОТВЕТА')}") + if verdict.get("verdict") == "CONFIRMED": + cand["severity"] = verdict.get("severity", cand.get("severity")) + cand["confidence"] = verdict.get("confidence", cand.get("confidence")) + cand["verifier_reasoning"] = verdict.get("reasoning", "") + confirmed.append(cand) + + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + findings = [] + for i, c in enumerate(confirmed): + findings.append({ + "id": f"deep-audit-{c['_domain']}-{i}", + "source": "deep-audit", "category": c.get("category", c["_domain"]), + "severity": c.get("severity", "medium"), + "confidence": c.get("confidence", 0.5), + "file": c.get("file", ""), + "line_start": c.get("line_start", 0), + "line_end": c.get("line_end", c.get("line_start", 0)), + "title": (c.get("title", "") or "")[:120], + "description": c.get("description", ""), + "impact": c.get("impact", ""), + "exploit_scenario": c.get("exploit_scenario", ""), + "evidence": c.get("evidence", ""), + "recommendation": c.get("recommendation", ""), + "verifier_reasoning": c.get("verifier_reasoning", ""), + "cwe": c.get("cwe"), + "status": "CONFIRMED", + "created_at": now, + }) + + order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + findings.sort(key=lambda f: order.get(f["severity"], 4)) + + stamp = int(time.time()) + outdir = f"security-analysis/deep-audit/{stamp}" + branch = f"deep-audit-{stamp}" + + report = f"# Deep Security Audit — {now}\n\n" + report += f"Доменов проверено: {len(domains)} ({', '.join(domains)}).\n" + report += f"Кандидатов от агентов: {len(candidates)}, подтверждено независимым верификатором: {len(findings)}.\n\n" + if not findings: + report += "Подтверждённых находок нет.\n" + for f in findings: + report += f"## {f['severity'].upper()} — {f['title']}\n\n" + report += f"- confidence: {f['confidence']}\n- файл: `{f['file']}:{f['line_start']}`\n- CWE: {f.get('cwe') or '—'}\n\n" + report += f"{f['description']}\n\n" + report += f"**Сценарий эксплуатации:** {f['exploit_scenario']}\n\n" + report += f"**Вердикт верификатора:** {f['verifier_reasoning']}\n\n" + report += f"**Рекомендация:** {f['recommendation']}\n\n---\n\n" + + gitea("POST", f"/repos/{REPO}/contents/{outdir}/report.md", { + "content": base64.b64encode(report.encode()).decode(), + "message": f"deep-audit: report {now}", + "branch": "main", + "new_branch": branch, + }) + gitea("POST", f"/repos/{REPO}/contents/{outdir}/findings.json", { + "content": base64.b64encode(json.dumps(findings, ensure_ascii=False, indent=2).encode()).decode(), + "message": f"deep-audit: findings {now}", + "branch": branch, + }) + + pr = gitea("POST", f"/repos/{REPO}/pulls", { + "head": branch, "base": "main", + "title": f"Deep Security Audit — {now}", + "body": f"Level 2. Доменов: {len(domains)}. Подтверждённых находок: {len(findings)}. Полный отчёт: `{outdir}/report.md`.", + }) + print(f"PR открыт: {pr.get('html_url', '?')}") + print(f"findings: {len(findings)}, candidates: {len(candidates)}") + PYEOF3