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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
Reference in New Issue
Block a user