From f9fdfc2c3b264d811a2303aadb2ca0d63d2e3968 Mon Sep 17 00:00:00 2001 From: testclient-admin Date: Sun, 23 Aug 2026 13:39:56 +0000 Subject: [PATCH 1/6] Level 0: security scanning for pull requests --- .gitea/workflows/security.yml | 1178 +++++++++++++++++++++++++++++++++ 1 file changed, 1178 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..d772096 --- /dev/null +++ b/.gitea/workflows/security.yml @@ -0,0 +1,1178 @@ +# 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 }} + HEAD_REF: ${{ gitea.head_ref }} + run: | + python3 <<'PYEOF' + import base64, 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"] + HEAD_REF = os.environ["HEAD_REF"] + + 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 + + outdir = f"security-analysis/findings/pr-{PR_NUMBER or 'manual'}" + os.makedirs(outdir, exist_ok=True) + outpath = f"{outdir}/{int(time.time())}.json" + json.dump(findings, open(outpath, "w"), ensure_ascii=False, indent=2) + + gitea("POST", f"/repos/{REPO}/contents/{outpath}", { + "content": base64.b64encode(open(outpath, "rb").read()).decode(), + "message": f"security: scan results for PR #{PR_NUMBER}", + "branch": HEAD_REF, + }) + + 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" + body += f"Полный список: `{outpath}`\n\n" + 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 else "success", + "context": "security/scan", + "description": f"{len(findings)} findings, {len(blocking)} blocking", + "target_url": f"{SERVER}/{REPO}/src/branch/{HEAD_REF}/{outpath}", + }) + + print(f"findings: {len(findings)}, blocking: {len(blocking)}") + if blocked: + 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 }} + HEAD_REF: ${{ gitea.head_ref }} + run: | + python3 <<'PYEOF2' + import base64, 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"] + HEAD_REF = os.environ["HEAD_REF"] + + 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 + + outdir = f"security-analysis/findings/pr-{PR_NUMBER or 'manual'}" + outpath = f"{outdir}/{int(time.time())}-review.json" + + if findings: + gitea("POST", f"/repos/{REPO}/contents/{outpath}", { + "content": base64.b64encode(json.dumps(findings, ensure_ascii=False, indent=2).encode()).decode(), + "message": f"security: AI review results for PR #{PR_NUMBER}", + "branch": HEAD_REF, + }) + + 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" + body += f"Полный список: `{outpath}`\n\n" + 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 else "success", + "context": "security/review", + "description": f"{len(findings)} findings, {len(blocking)} blocking", + "target_url": f"{SERVER}/{REPO}", + }) + + print(f"findings: {len(findings)}, blocking: {len(blocking)}") + if blocked: + 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 -- 2.54.0 From e21795ea8b6405bb63628b67702ba25db33c85f2 Mon Sep 17 00:00:00 2001 From: testclient-admin Date: Sun, 23 Aug 2026 13:39:56 +0000 Subject: [PATCH 2/6] Level 0: security scanning for pull requests --- security-analysis/README.md | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 security-analysis/README.md diff --git a/security-analysis/README.md b/security-analysis/README.md new file mode 100644 index 0000000..d4d1479 --- /dev/null +++ b/security-analysis/README.md @@ -0,0 +1,69 @@ +# security-analysis + +Результаты автоматической security-проверки этого репозитория: +Level 0 (детерминированные сканеры) и Level 1 (AI PR review) из +требований AI Security Engine. Заводится и сопровождается +пайплайном `seed-security-scanning` (platform-infra) — правки +этого README переживут только до следующего его запуска. + +## Что здесь лежит + +- `policy.yml` — какие severity блокируют merge и какой минимальный + confidence нужен, чтобы находка вообще считалась. Общий для + обоих уровней. +- `findings/pr-<номер>/[-review].json` — находки + конкретного скана: массив объектов вида + + ```json + { + "id": "...", "source": "semgrep|gitleaks|ai-review", + "category": "sast|secret|auth|idor|injection|...", + "severity": "critical|high|medium|low", "confidence": 0.0, + "file": "...", "line_start": 0, "line_end": 0, + "title": "...", "description": "...", "rule_id": "...", + "cwe": "CWE-...", "status": "OPEN", + "created_at": "...", "pr_number": 0, "commit_sha": "..." + } + ``` + + У находок Level 1 (`source: ai-review`) дополнительно есть + `impact`, `exploit_scenario`, `evidence`, `recommendation`. + +## Уровни + +- **Level 0** (`security/scan`) — semgrep + gitleaks, без LLM. + Быстро и воспроизводимо, ловит известные классы проблем. + Запускается на каждый PR. +- **Level 1** (`security/review`) — headless-агент + (`security-reviewer`, read-only: без bash/edit/webfetch) читает + диф PR через шлюз (модель `coder-fast`) и ищет то, что + детерминированные сканеры не видят: логические дыры + авторизации, IDOR, SSRF и т.п. Не идёт по всему репозиторию — + только диф и то, что нужно дочитать по grep/glob. Тоже на + каждый PR. +- **Level 2** (Deep Security Audit) — по запросу + (`workflow_dispatch` на `security.yml`, вход `domains` — + какие домены проверять, пусто = все). Шесть доменных + read-only агентов (`audit-auth`, `audit-authz`, + `audit-injection`, `audit-ssrf`, `audit-secrets`, + `audit-business-logic`) идут по ВСЕМУ репозиторию + последовательно (не параллельно — проще отлаживать, дороже + по времени), модель `coder-think` (рассуждение, не + экономичная `coder-fast`). Каждая находка проходит через + независимого верификатора (`audit-verifier`), который + пытается её ОПРОВЕРГНУТЬ, а не подтвердить — подтверждённые + находки попадают в отчёт (`security-analysis/deep-audit/<время>/`, + Markdown + JSON), не подтверждённые отбрасываются молча. + Результат — отдельный pull request с отчётом, не блокирует + merge (это аудит, не гейт). + +## Кто это делает + +Комментарии и статусы всех уровней — от имени +`<репозиторий>-security-bot`, отдельной учётки с правом записи +только на этот репозиторий. Не токен разработчика и не +администратора: у бота нет прав ни на что, кроме комментирования +PR и записи находок сюда же. У Level 1 и Level 2 — свои, +отдельные ключи шлюза (`SECURITY_REVIEWER_LITELLM_KEY` только на +`coder-fast`, `DEEP_AUDIT_LITELLM_KEY` только на `coder-think`), +каждый со своим бюджетом. -- 2.54.0 From 52f458a785595cc866bda12d08769e8e53c4b14a Mon Sep 17 00:00:00 2001 From: testclient-admin Date: Sun, 23 Aug 2026 13:39:57 +0000 Subject: [PATCH 3/6] Level 0: security scanning for pull requests --- security-analysis/policy.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 security-analysis/policy.yml diff --git a/security-analysis/policy.yml b/security-analysis/policy.yml new file mode 100644 index 0000000..1e294b0 --- /dev/null +++ b/security-analysis/policy.yml @@ -0,0 +1,7 @@ +# Какие находки блокируют merge. Читает security.yml на каждом +# скане; отсутствие файла — те же значения по умолчанию. +security: + block_on: + - critical + - high + min_confidence: 0.7 -- 2.54.0 From ed79e1ef7b5cae5deb87366d1683b70ea85f336a Mon Sep 17 00:00:00 2001 From: playground-security-bot Date: Sun, 23 Aug 2026 13:41:10 +0000 Subject: [PATCH 4/6] security: scan results for PR #2 --- security-analysis/findings/pr-2/1787492470.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 security-analysis/findings/pr-2/1787492470.json diff --git a/security-analysis/findings/pr-2/1787492470.json b/security-analysis/findings/pr-2/1787492470.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/security-analysis/findings/pr-2/1787492470.json @@ -0,0 +1 @@ +[] \ No newline at end of file -- 2.54.0 From d5ad079bdb765411a28690684f827051155cdfc9 Mon Sep 17 00:00:00 2001 From: playground-security-bot Date: Sun, 23 Aug 2026 13:42:51 +0000 Subject: [PATCH 5/6] security: scan results for PR #2 --- security-analysis/findings/pr-2/1787492571.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 security-analysis/findings/pr-2/1787492571.json diff --git a/security-analysis/findings/pr-2/1787492571.json b/security-analysis/findings/pr-2/1787492571.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/security-analysis/findings/pr-2/1787492571.json @@ -0,0 +1 @@ +[] \ No newline at end of file -- 2.54.0 From c12a8ef044e52f8154d8944267ae74903c5d21f2 Mon Sep 17 00:00:00 2001 From: playground-security-bot Date: Sun, 23 Aug 2026 13:44:26 +0000 Subject: [PATCH 6/6] security: scan results for PR #2 --- security-analysis/findings/pr-2/1787492666.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 security-analysis/findings/pr-2/1787492666.json diff --git a/security-analysis/findings/pr-2/1787492666.json b/security-analysis/findings/pr-2/1787492666.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/security-analysis/findings/pr-2/1787492666.json @@ -0,0 +1 @@ +[] \ No newline at end of file -- 2.54.0