diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index d772096..863a79b 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -154,6 +154,22 @@ jobs: 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 + outdir = f"security-analysis/findings/pr-{PR_NUMBER or 'manual'}" os.makedirs(outdir, exist_ok=True) outpath = f"{outdir}/{int(time.time())}.json" @@ -177,19 +193,22 @@ jobs: 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" + 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 else "success", + "state": "failure" if (blocked or sticky) else "success", "context": "security/scan", - "description": f"{len(findings)} findings, {len(blocking)} blocking", + "description": (f"{len(findings)} findings, {len(blocking)} blocking" + (" (sticky: prior run flagged this commit)" if sticky else "")), "target_url": f"{SERVER}/{REPO}/src/branch/{HEAD_REF}/{outpath}", }) - print(f"findings: {len(findings)}, blocking: {len(blocking)}") - if blocked: + print(f"findings: {len(findings)}, blocking: {len(blocking)}, sticky: {sticky}") + if blocked or sticky: sys.exit(1) PYEOF @@ -440,6 +459,20 @@ jobs: 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 + outdir = f"security-analysis/findings/pr-{PR_NUMBER or 'manual'}" outpath = f"{outdir}/{int(time.time())}-review.json" @@ -467,19 +500,22 @@ jobs: 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" + 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 else "success", + "state": "failure" if (blocked or sticky) else "success", "context": "security/review", - "description": f"{len(findings)} findings, {len(blocking)} blocking", + "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)}") - if blocked: + print(f"findings: {len(findings)}, blocking: {len(blocking)}, sticky: {sticky}") + if blocked or sticky: sys.exit(1) PYEOF2