Files
playground/.gitea/workflows/security.yml
T

1224 lines
71 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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" <<CFGEOF
{
"\$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"provider": {
"litellm": {
"npm": "@ai-sdk/openai-compatible",
"name": "LiteLLM",
"options": {
"baseURL": "http://72.56.25.127:4000/v1",
"apiKey": "{env:LITELLM_API_KEY}"
},
"models": {
"coder-fast": { "name": "Coder (fast)" }
}
}
}
}
CFGEOF
- name: Собрать диф PR
env:
BASE_SHA: ${{ gitea.event.pull_request.base.sha }}
HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
run: |
git diff "$BASE_SHA" "$HEAD_SHA" > /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" <<CFGEOF
{
"\$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"provider": {
"litellm": {
"npm": "@ai-sdk/openai-compatible",
"name": "LiteLLM",
"options": {
"baseURL": "http://72.56.25.127:4000/v1",
"apiKey": "{env:LITELLM_API_KEY}"
},
"models": {
"coder-think": { "name": "Coder (think)" }
}
}
}
}
CFGEOF
- name: Записать доменных агентов (1/2)
run: |
cat > "$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