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_API_KEY=
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
LLM_TIMEOUT_SECONDS=30
|
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_API_BASE` | `https://api.openai.com/v1` | Базовый URL OpenAI-совместимого API |
|
||||||
| `LLM_MODEL` | `gpt-4o-mini` | Название модели |
|
| `LLM_MODEL` | `gpt-4o-mini` | Название модели |
|
||||||
| `LLM_TIMEOUT_SECONDS` | `30` | Таймаут запроса к LLM |
|
| `LLM_TIMEOUT_SECONDS` | `30` | Таймаут запроса к LLM |
|
||||||
|
| `LLM_MAX_CONCURRENT_ANALYSES` | `2` | Максимум одновременных LLM-анализов |
|
||||||
|
|
||||||
## Настройка Nexus
|
## Настройка Nexus
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class Config:
|
|||||||
llm_api_key: str = os.getenv("LLM_API_KEY", "")
|
llm_api_key: str = os.getenv("LLM_API_KEY", "")
|
||||||
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
|
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
|
||||||
llm_timeout: int = int(os.getenv("LLM_TIMEOUT_SECONDS", str(LLM_DEFAULT_TIMEOUT)))
|
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()
|
config = Config()
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ async def harvest(
|
|||||||
format_: str,
|
format_: str,
|
||||||
asset_path: str,
|
asset_path: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
initiator: str | None = None,
|
||||||
|
source_ip: str | None = None,
|
||||||
) -> Scan | None:
|
) -> Scan | None:
|
||||||
ecosystem = format_ if format_ else DEFAULT_ECOSYSTEM
|
ecosystem = format_ if format_ else DEFAULT_ECOSYSTEM
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ async def harvest(
|
|||||||
ecosystem=ecosystem,
|
ecosystem=ecosystem,
|
||||||
repository=repository,
|
repository=repository,
|
||||||
nexus_asset_url=download_url,
|
nexus_asset_url=download_url,
|
||||||
|
initiator=initiator,
|
||||||
|
source_ip=source_ip,
|
||||||
status=ScanStatus.PENDING.value,
|
status=ScanStatus.PENDING.value,
|
||||||
)
|
)
|
||||||
session.add(scan)
|
session.add(scan)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Supports any OpenAI-compatible API endpoint with configurable model.
|
Supports any OpenAI-compatible API endpoint with configurable model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -11,6 +12,8 @@ from ..config import config
|
|||||||
from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT
|
from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT
|
||||||
from ..logging_setup import log
|
from ..logging_setup import log
|
||||||
|
|
||||||
|
_llm_semaphore = asyncio.Semaphore(config.llm_max_concurrent)
|
||||||
|
|
||||||
|
|
||||||
def _build_user_message(finding: dict) -> str:
|
def _build_user_message(finding: dict) -> str:
|
||||||
"""Build a concise prompt from a finding's data."""
|
"""Build a concise prompt from a finding's data."""
|
||||||
@@ -62,6 +65,7 @@ async def analyze_finding(finding_data: dict) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
async with _llm_semaphore:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=config.llm_timeout, headers=headers
|
timeout=config.llm_timeout, headers=headers
|
||||||
) as client:
|
) as client:
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class Scan(Base):
|
|||||||
)
|
)
|
||||||
finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, 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(
|
findings: Mapped[list["Finding"]] = relationship(
|
||||||
"Finding", back_populates="scan", cascade="all, delete-orphan"
|
"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,
|
"started_at": scan.started_at.isoformat() if scan.started_at else None,
|
||||||
"finished_at": scan.finished_at.isoformat() if scan.finished_at else None,
|
"finished_at": scan.finished_at.isoformat() if scan.finished_at else None,
|
||||||
"error_message": scan.error_message,
|
"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],
|
"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:
|
if action not in RELEVANT_WEBHOOK_ACTIONS:
|
||||||
return {"status": WEBHOOK_STATUS_IGNORED, "action": action}
|
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", "")
|
repository = data.get("repositoryName", "")
|
||||||
asset = data.get("asset")
|
asset = data.get("asset")
|
||||||
component = data.get("component")
|
component = data.get("component")
|
||||||
|
initiator = data.get("initiator")
|
||||||
|
source_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
if asset:
|
if asset:
|
||||||
asset_path = _extract_asset_path(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)
|
log.info("Webhook: %s asset %s (%s) in %s", action, asset_path, ecosystem, repository)
|
||||||
|
|
||||||
background_tasks.add_task(
|
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}
|
return {"status": WEBHOOK_STATUS_ACCEPTED, "asset": asset_path, "action": action}
|
||||||
|
|
||||||
@@ -181,10 +188,15 @@ async def _scan_in_background(
|
|||||||
repository: str,
|
repository: str,
|
||||||
format_: str,
|
format_: str,
|
||||||
asset_path: str,
|
asset_path: str,
|
||||||
|
initiator: str | None = None,
|
||||||
|
source_ip: str | None = None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
async for session in get_session():
|
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
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Background scan failed: %s", 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 { margin-top: 0.5rem; }
|
||||||
.llm-actions button { font-size: 0.8rem; }
|
.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 */
|
/* Shared controls */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -7,4 +7,5 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="llm-summary">{{ report.summary }}</p>
|
<p class="llm-summary">{{ report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ report.analysis }}</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>
|
</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 %}
|
{% if latest_flagged %}
|
||||||
<article class="dash-block dash-block-warn">
|
<article class="dash-block dash-block-warn">
|
||||||
<h3>Latest Flagged</h3>
|
<h3>Latest Flagged</h3>
|
||||||
@@ -41,7 +16,6 @@
|
|||||||
</table>
|
</table>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="dash-block" style="margin-top: 0;">
|
<article class="dash-block" style="margin-top: 0;">
|
||||||
<h3>Latest Scans</h3>
|
<h3>Latest Scans</h3>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="llm-summary">{{ f.report.summary }}</p>
|
<p class="llm-summary">{{ f.report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
||||||
|
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="llm-actions" id="llm-{{ f.id }}">
|
<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>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>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>
|
<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>
|
</div>
|
||||||
{% if scan.error_message %}<div class="scan-error"><strong>Error:</strong> {{ scan.error_message }}</div>{% endif %}
|
{% if scan.error_message %}<div class="scan-error"><strong>Error:</strong> {{ scan.error_message }}</div>{% endif %}
|
||||||
</article>
|
</article>
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="llm-summary">{{ f.report.summary }}</p>
|
<p class="llm-summary">{{ f.report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
||||||
|
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="llm-actions" id="llm-{{ f.id }}">
|
<div class="llm-actions" id="llm-{{ f.id }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user