a5714116ac
+feat: configuration, progress bar, OSV
260 lines
10 KiB
Python
260 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import Counter, defaultdict
|
|
from typing import List, Dict, Any, Tuple, Optional
|
|
|
|
from .deps_pipeline import dedupe_effective
|
|
|
|
|
|
def _sec(sections: Dict[str, Any] | None, key: str, default: bool) -> bool:
|
|
"""Read a boolean 'sections' flag with a default."""
|
|
if not isinstance(sections, dict):
|
|
return default
|
|
v = sections.get(key, default)
|
|
return bool(v)
|
|
|
|
|
|
def _sec_str(sections: Dict[str, Any] | None, key: str, default: str) -> str:
|
|
if not isinstance(sections, dict):
|
|
return default
|
|
v = sections.get(key, default)
|
|
return str(v)
|
|
|
|
|
|
def _is_dev_scope(eco: str, scope: str | None) -> bool:
|
|
scope_l = (scope or "").lower()
|
|
if eco == "npm":
|
|
return scope_l == "devdependencies"
|
|
if eco == "pypi":
|
|
return ("dev" in scope_l) or ("test" in scope_l) or scope_l.startswith("optional:")
|
|
if eco in {"maven", "gradle"}:
|
|
return ("test" in scope_l) or scope_l in {"provided"}
|
|
return False
|
|
|
|
|
|
def split_important_and_dev(deps_eff: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
important, dev = [], []
|
|
for d in deps_eff:
|
|
eco = d.get("ecosystem") or "unknown"
|
|
if _is_dev_scope(str(eco), d.get("scope")):
|
|
dev.append(d)
|
|
else:
|
|
important.append(d)
|
|
return important, dev
|
|
|
|
|
|
def print_deps_grouped(title: str, deps_list: List[Dict[str, Any]], max_per_eco: int) -> None:
|
|
print(f" {title}:")
|
|
if not deps_list:
|
|
print(" - (нет)")
|
|
return
|
|
|
|
by_eco: dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
for d in deps_list:
|
|
by_eco[str(d.get("ecosystem") or "unknown")].append(d)
|
|
|
|
for eco in sorted(by_eco.keys()):
|
|
items = sorted(by_eco[eco], key=lambda x: (str(x.get("name") or "")))
|
|
total = len(items)
|
|
print(f" [{eco}] {total}")
|
|
for i, d in enumerate(items):
|
|
if i >= max_per_eco:
|
|
print(f" ... ещё {total - i}")
|
|
break
|
|
name = d.get("name")
|
|
spec = d.get("spec")
|
|
scope = d.get("scope") or d.get("note")
|
|
line = f" - {name}"
|
|
if spec:
|
|
line += f" [{spec}]"
|
|
if scope:
|
|
line += f" ({scope})"
|
|
print(line)
|
|
|
|
|
|
def print_container_report(
|
|
container: Dict[str, Any],
|
|
*,
|
|
sections: Optional[Dict[str, Any]] = None,
|
|
max_deps_per_ecosystem: int = 20,
|
|
max_dev_deps_per_ecosystem: int = 10,
|
|
top_affected: int = 8,
|
|
) -> None:
|
|
if _sec(sections, "show_separator", False):
|
|
print("=" * 88)
|
|
else:
|
|
print()
|
|
|
|
print(f"Контейнер: {container.get('name')} Образ: {container.get('image')}")
|
|
|
|
if _sec(sections, "show_id_status", True):
|
|
line = f" ID: {container.get('id')} Статус: {container.get('status')}"
|
|
if _sec(sections, "show_created", False):
|
|
line += f" Создан: {container.get('create_time')}"
|
|
print(line)
|
|
|
|
if _sec(sections, "show_ports", False):
|
|
print(f" Порты: {container.get('ports')}")
|
|
|
|
if _sec(sections, "show_mounts", False):
|
|
mounts = container.get("mounted_data") or []
|
|
if isinstance(mounts, list) and mounts:
|
|
print(f" Маунты: {len(mounts)}")
|
|
for m in mounts[:5]:
|
|
if isinstance(m, dict):
|
|
src = m.get("Source") or m.get("Name") or "?"
|
|
dst = m.get("Destination") or "?"
|
|
mode = m.get("Mode") or ""
|
|
rw = "rw" if m.get("RW") else "ro"
|
|
extra = f" ({mode},{rw})" if mode else f" ({rw})"
|
|
print(f" - {src} -> {dst}{extra}")
|
|
if len(mounts) > 5:
|
|
print(f" ... ещё {len(mounts) - 5}")
|
|
else:
|
|
print(" Маунты: 0")
|
|
|
|
if _sec(sections, "show_versions", True):
|
|
print(f" Версия: tag={container.get('version')} label={container.get('image_version_label')}")
|
|
|
|
if _sec(sections, "show_language", True):
|
|
print(f" Язык: {container.get('language')}")
|
|
if _sec(sections, "show_source", True):
|
|
print(f" Source repo: {container.get('source_url')}")
|
|
if _sec(sections, "show_revision", True):
|
|
print(f" Revision: {container.get('source_revision')}")
|
|
if _sec(sections, "show_deps_source", True):
|
|
print(f" Источник зависимостей: {container.get('deps_source')}")
|
|
|
|
if _sec(sections, "show_manifests", True):
|
|
manifests = container.get("dep_manifests_used") or []
|
|
mode = _sec_str(sections, "manifests_mode", "count").lower()
|
|
if not isinstance(manifests, list):
|
|
manifests = []
|
|
if mode == "list":
|
|
print(f" Манифесты ({len(manifests)}): " + (", ".join(str(x) for x in manifests) if manifests else "(нет)"))
|
|
else:
|
|
print(f" Манифесты: {len(manifests)}")
|
|
|
|
raw_deps = container.get("dependencies") or []
|
|
if _sec(sections, "show_raw_deps_count", False):
|
|
print(f" Зависимостей (сырых): {len(raw_deps)}")
|
|
|
|
deps_eff = dedupe_effective(raw_deps)
|
|
if _sec(sections, "show_effective_deps_count", True):
|
|
print(f" Зависимости: {len(deps_eff)} (после дедупликации)")
|
|
|
|
if _sec(sections, "show_ecosystem_counts", True):
|
|
eco_counts = Counter(d.get("ecosystem") for d in deps_eff if d.get("ecosystem"))
|
|
if eco_counts:
|
|
print(" По экосистемам: " + ", ".join(f"{k}={v}" for k, v in sorted(eco_counts.items())))
|
|
|
|
if _sec(sections, "show_deps_list", False):
|
|
important, dev = split_important_and_dev(deps_eff)
|
|
print_deps_grouped("ВАЖНЫЕ (для CVE/анализа)", important, max_per_eco=max_deps_per_ecosystem)
|
|
if dev and _sec(sections, "show_dev_deps_list", False):
|
|
print_deps_grouped("DEV/TEST (при наличии)", dev, max_per_eco=max_dev_deps_per_ecosystem)
|
|
|
|
# OSV summary (after severity filtering)
|
|
if _sec(sections, "show_osv_summary", True):
|
|
if container.get("osv_errors"):
|
|
print(f" OSV: ОШИБКА: {container.get('osv_errors')}")
|
|
else:
|
|
pinned = container.get("osv_pinned_deps") or []
|
|
vuln_count = int(container.get("osv_vuln_count") or 0)
|
|
affected = container.get("osv_affected_deps") or []
|
|
counts = container.get("osv_vuln_counts_by_severity") or {}
|
|
|
|
sev_summary = ""
|
|
if isinstance(counts, dict) and counts:
|
|
ordered = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
|
|
parts = [f"{k}={int(counts.get(k, 0))}" for k in ordered if k in counts or k == "UNKNOWN"]
|
|
sev_summary = " (" + ", ".join(parts) + ")" if parts else ""
|
|
|
|
print(f" OSV: уязвимости={vuln_count}; затронутые пакеты={len(affected)}; проверено пакетов={len(pinned)}{sev_summary}")
|
|
|
|
if affected and _sec(sections, "show_osv_top_affected", True):
|
|
print(" OSV: топ уязвимых зависимостей:")
|
|
for row in affected[:top_affected]:
|
|
if len(row) >= 5:
|
|
eco, name, ver, cnt, max_sev = row[:5]
|
|
print(f" - {eco}:{name}@{ver} -> {cnt} ids (max={max_sev})")
|
|
else:
|
|
eco, name, ver, cnt = row[:4]
|
|
print(f" - {eco}:{name}@{ver} -> {cnt} ids")
|
|
|
|
if _sec(sections, "show_osv_sample_ids", False):
|
|
top_key = f"{affected[0][0]}:{affected[0][1]}@{affected[0][2]}"
|
|
ids = (container.get("osv_vulns_by_dep") or {}).get(top_key, [])
|
|
if ids:
|
|
print(" OSV: примеры vuln_ids (первые 10): " + ", ".join(ids[:10]))
|
|
|
|
if _sec(sections, "show_errors", True):
|
|
errs = container.get("dep_errors") or []
|
|
if errs:
|
|
print(" Примечания/ошибки (первые 8):")
|
|
for e in errs[:8]:
|
|
print(f" ! {e}")
|
|
if len(errs) > 8:
|
|
print(f" ... ещё {len(errs) - 8}")
|
|
|
|
if _sec(sections, "show_code_files", False):
|
|
print(f" code_files: {container.get('code_files')}")
|
|
|
|
|
|
def _service_key(container: Dict[str, Any]) -> str:
|
|
labels = container.get("all_labels") or container.get("labels") or {}
|
|
if isinstance(labels, dict):
|
|
svc = labels.get("com.docker.compose.service")
|
|
proj = labels.get("com.docker.compose.project")
|
|
if svc:
|
|
return f"{proj}:{svc}" if proj else str(svc)
|
|
|
|
name = str(container.get("name") or "")
|
|
m = name.rsplit("_", 1)
|
|
if len(m) == 2 and m[1].isdigit():
|
|
return m[0]
|
|
|
|
image = str(container.get("image") or "")
|
|
base = image.split("/")[-1]
|
|
base = base.split(":", 1)[0]
|
|
return base or name or "unknown"
|
|
|
|
|
|
def print_report(
|
|
containers: List[Dict[str, Any]],
|
|
*,
|
|
group_by_service: bool = True,
|
|
sections: Optional[Dict[str, Any]] = None,
|
|
max_deps_per_ecosystem: int = 20,
|
|
max_dev_deps_per_ecosystem: int = 10,
|
|
top_affected: int = 8,
|
|
) -> None:
|
|
if not group_by_service:
|
|
for c in containers:
|
|
print_container_report(
|
|
c,
|
|
sections=sections,
|
|
max_deps_per_ecosystem=max_deps_per_ecosystem,
|
|
max_dev_deps_per_ecosystem=max_dev_deps_per_ecosystem,
|
|
top_affected=top_affected,
|
|
)
|
|
return
|
|
|
|
groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
for c in containers:
|
|
groups[_service_key(c)].append(c)
|
|
|
|
for key in sorted(groups.keys()):
|
|
if _sec(sections, "show_service_separator", True):
|
|
print("─" * 88)
|
|
if _sec(sections, "show_service_header", True):
|
|
print(f"Сервис: {key} Контейнеров: {len(groups[key])}")
|
|
for c in groups[key]:
|
|
print_container_report(
|
|
c,
|
|
sections=sections,
|
|
max_deps_per_ecosystem=max_deps_per_ecosystem,
|
|
max_dev_deps_per_ecosystem=max_dev_deps_per_ecosystem,
|
|
top_affected=top_affected,
|
|
)
|