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