# 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 gitleaks detect --source=. --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