feat: фаза 3 (часть 1) — disclaimer, очередь, initiator + IP

3.6 UI: убрать stat-minibar и heatmap с дашборда
3.2 AI disclaimer под каждым LLM-вердиктом
3.4 LLM_MAX_CONCURRENT_ANALYSES + Semaphore в llm.py
3.1 Scan.initiator + source_ip, webhook захватывает, UI показывает
This commit is contained in:
Marker689
2026-05-10 07:32:14 +03:00
parent 0069331119
commit 4ae893a025
13 changed files with 69 additions and 52 deletions

View File

@@ -35,3 +35,4 @@ LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY=
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT_SECONDS=30
LLM_MAX_CONCURRENT_ANALYSES=2

View File

@@ -91,6 +91,7 @@ python -m guarddog_nexus.main
| `LLM_API_BASE` | `https://api.openai.com/v1` | Базовый URL OpenAI-совместимого API |
| `LLM_MODEL` | `gpt-4o-mini` | Название модели |
| `LLM_TIMEOUT_SECONDS` | `30` | Таймаут запроса к LLM |
| `LLM_MAX_CONCURRENT_ANALYSES` | `2` | Максимум одновременных LLM-анализов |
## Настройка Nexus

View File

@@ -57,6 +57,9 @@ class Config:
llm_api_key: str = os.getenv("LLM_API_KEY", "")
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
llm_timeout: int = int(os.getenv("LLM_TIMEOUT_SECONDS", str(LLM_DEFAULT_TIMEOUT)))
llm_max_concurrent: int = int(
os.getenv("LLM_MAX_CONCURRENT_ANALYSES", "2")
)
config = Config()

View File

@@ -35,6 +35,8 @@ async def harvest(
format_: str,
asset_path: str,
session: AsyncSession,
initiator: str | None = None,
source_ip: str | None = None,
) -> Scan | None:
ecosystem = format_ if format_ else DEFAULT_ECOSYSTEM
@@ -78,6 +80,8 @@ async def harvest(
ecosystem=ecosystem,
repository=repository,
nexus_asset_url=download_url,
initiator=initiator,
source_ip=source_ip,
status=ScanStatus.PENDING.value,
)
session.add(scan)

View File

@@ -3,6 +3,7 @@
Supports any OpenAI-compatible API endpoint with configurable model.
"""
import asyncio
import json
import httpx
@@ -11,6 +12,8 @@ from ..config import config
from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT
from ..logging_setup import log
_llm_semaphore = asyncio.Semaphore(config.llm_max_concurrent)
def _build_user_message(finding: dict) -> str:
"""Build a concise prompt from a finding's data."""
@@ -62,6 +65,7 @@ async def analyze_finding(finding_data: dict) -> dict | None:
}
try:
async with _llm_semaphore:
async with httpx.AsyncClient(
timeout=config.llm_timeout, headers=headers
) as client:

View File

@@ -36,6 +36,8 @@ class Scan(Base):
)
finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
initiator: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
findings: Mapped[list["Finding"]] = relationship(
"Finding", back_populates="scan", cascade="all, delete-orphan"

View File

@@ -153,5 +153,7 @@ async def get_scan(scan_id: int, session: AsyncSession = Depends(get_session)):
"started_at": scan.started_at.isoformat() if scan.started_at else None,
"finished_at": scan.finished_at.isoformat() if scan.finished_at else None,
"error_message": scan.error_message,
"initiator": scan.initiator,
"source_ip": scan.source_ip,
"findings": [{"id": f.id, **f.data, "report": f.report} for f in scan.findings],
}

View File

@@ -96,9 +96,15 @@ async def nexus_webhook(
if action not in RELEVANT_WEBHOOK_ACTIONS:
return {"status": WEBHOOK_STATUS_IGNORED, "action": action}
# Log full payload for debugging (to discover available fields)
log.info("Webhook payload: initiator=%s nodeId=%s keys=%s",
data.get("initiator"), data.get("nodeId"), sorted(data.keys()))
repository = data.get("repositoryName", "")
asset = data.get("asset")
component = data.get("component")
initiator = data.get("initiator")
source_ip = request.client.host if request.client else None
if asset:
asset_path = _extract_asset_path(asset)
@@ -113,7 +119,8 @@ async def nexus_webhook(
log.info("Webhook: %s asset %s (%s) in %s", action, asset_path, ecosystem, repository)
background_tasks.add_task(
_scan_in_background, download_url, repository, ecosystem, asset_path
_scan_in_background, download_url, repository, ecosystem, asset_path,
initiator=initiator, source_ip=source_ip,
)
return {"status": WEBHOOK_STATUS_ACCEPTED, "asset": asset_path, "action": action}
@@ -181,10 +188,15 @@ async def _scan_in_background(
repository: str,
format_: str,
asset_path: str,
initiator: str | None = None,
source_ip: str | None = None,
):
try:
async for session in get_session():
await harvest(download_url, repository, format_, asset_path, session)
await harvest(
download_url, repository, format_, asset_path, session,
initiator=initiator, source_ip=source_ip,
)
break
except Exception as e:
log.error("Background scan failed: %s", e)

View File

@@ -237,6 +237,15 @@ table.compact td { padding: 0.35rem 0.5rem; }
.llm-actions { margin-top: 0.5rem; }
.llm-actions button { font-size: 0.8rem; }
.llm-disclaimer {
margin-top: 0.6rem;
font-size: 0.72rem;
opacity: 0.5;
font-style: italic;
border-top: 1px solid var(--pico-color-gray-600);
padding-top: 0.4rem;
}
/* ------------------------------------------------------------------ */
/* Shared controls */
/* ------------------------------------------------------------------ */

View File

@@ -7,4 +7,5 @@
</div>
<p class="llm-summary">{{ report.summary }}</p>
<p class="llm-analysis">{{ report.analysis }}</p>
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies. Always verify findings before taking action.</p>
</div>

View File

@@ -1,28 +1,3 @@
<div class="stat-minibar">
<span><strong>{{ total_scans }}</strong> scans</span>
<span><strong class="flagged">{{ flagged_scans }}</strong> flagged</span>
<span><strong>{{ total_findings }}</strong> findings</span>
<span class="severity-ERROR"><strong>{{ errors_count }}</strong> errors</span>
<span class="severity-WARNING"><strong>{{ warnings_count }}</strong> warnings</span>
</div>
<div class="dashboard-grid">
{% if days %}
<article class="dash-block">
<h3>Scan activity (14 days)</h3>
<div class="heatmap">
{% set max_cnt = days | map(attribute=1) | max %}
{% for day, cnt, fl in days %}
<div class="heatmap-day" title="{{ day }}: {{ cnt }} scans, {{ fl }} flagged">
{% set h = (cnt / max_cnt * 38) | int if max_cnt > 0 else 0 %}
<div class="bar" style="height: {{ h }}px; background: {% if fl > 0 %}var(--pico-color-red-500){% else %}var(--pico-color-zinc-500){% endif %};"></div>
<div class="tooltip">{{ day }}: {{ cnt }} scans, {{ fl }} flagged</div>
</div>
{% endfor %}
</div>
</article>
{% endif %}
{% if latest_flagged %}
<article class="dash-block dash-block-warn">
<h3>Latest Flagged</h3>
@@ -41,7 +16,6 @@
</table>
</article>
{% endif %}
</div>
<article class="dash-block" style="margin-top: 0;">
<h3>Latest Scans</h3>

View File

@@ -64,6 +64,7 @@
</div>
<p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies.</p>
</div>
{% else %}
<div class="llm-actions" id="llm-{{ f.id }}">

View File

@@ -24,6 +24,8 @@
<div><strong>SHA256</strong><br><code class="sha256">{{ scan.sha256 or '-' }}</code></div>
<div><strong>Started</strong><br>{{ scan.started_at.strftime('%Y-%m-%d %H:%M') if scan.started_at }}</div>
<div><strong>Finished</strong><br>{{ scan.finished_at.strftime('%Y-%m-%d %H:%M') if scan.finished_at }}</div>
{% if scan.initiator %}<div><strong>Initiated by</strong><br>{{ scan.initiator }}</div>{% endif %}
{% if scan.source_ip %}<div><strong>Source IP</strong><br>{{ scan.source_ip }}</div>{% endif %}
</div>
{% if scan.error_message %}<div class="scan-error"><strong>Error:</strong> {{ scan.error_message }}</div>{% endif %}
</article>
@@ -58,6 +60,7 @@
</div>
<p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies.</p>
</div>
{% else %}
<div class="llm-actions" id="llm-{{ f.id }}">