diff --git a/insights/__init__.py b/insights/__init__.py new file mode 100644 index 000000000..20854f2ad --- /dev/null +++ b/insights/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# diff --git a/insights/apps.py b/insights/apps.py new file mode 100644 index 000000000..7200f28cd --- /dev/null +++ b/insights/apps.py @@ -0,0 +1,14 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from django.apps import AppConfig + + +class InsightsConfig(AppConfig): + name = "insights" diff --git a/insights/charts/__init__.py b/insights/charts/__init__.py new file mode 100644 index 000000000..c989f493a --- /dev/null +++ b/insights/charts/__init__.py @@ -0,0 +1,155 @@ +from functools import partial + +from insights.models import ChartDefinition + +from .data_quality_panel import collect_data_quality_todos_resolutions +from .data_quality_panel import collect_open_issues_to_datasource +from .data_quality_panel import format_issue_contribution_donut +from .data_quality_panel import format_issue_type_bar +from .data_quality_panel import format_todos_resolution_timeline +from .importer_panel import _get_snapshot_data +from .importer_panel import build_importer_exploit_columns +from .importer_panel import build_importer_package_columns +from .importer_panel import collect_importers +from .overview_panel import collect_kpi_card +from .overview_panel import collect_yearly_distribution +from .overview_panel import format_coverage_comparison +from .overview_panel import format_growth_trend +from .overview_panel import format_kpi_card +from .overview_panel import format_yearly_distribution +from .package_panel import collect_cwes +from .package_panel import collect_ecosystem_distribution +from .package_panel import collect_packages +from .package_panel import format_ecosystem_distribution +from .package_panel import format_top_cwes +from .package_panel import format_top_packages +from .severity_panel import collect_severities +from .severity_panel import get_severity_snapshot_data + +CHARTS = [ + ChartDefinition( + id="overview-stats", + title="Overview Stats", + panel="overview_panel", + chart_type="custom", + formatter_fn=format_kpi_card, + collect_fn=collect_kpi_card, + ), + ChartDefinition( + id="overview-cross-validation", + title="Coverage Comparison", + panel="overview_panel", + chart_type="stacked_bar", + formatter_fn=format_coverage_comparison, + collect_fn=collect_importers, + ), + ChartDefinition( + id="overview-growth-trend", + title="Advisories Imported in the last 30 days", + panel="overview_panel", + chart_type="colored_bar", + formatter_fn=format_growth_trend, + collect_fn=collect_kpi_card, + ), + ChartDefinition( + id="overview-historical", + title="Advisories Published in the Last 10 years", + panel="overview_panel", + chart_type="colored_bar", + formatter_fn=format_yearly_distribution, + collect_fn=collect_yearly_distribution, + ), + ChartDefinition( + id="pkg-dist-donut", + title="Ecosystem Distribution", + panel="package_panel", + chart_type="donut", + formatter_fn=format_ecosystem_distribution, + collect_fn=collect_ecosystem_distribution, + ), + ChartDefinition( + id="pkg-name-bar", + title="Top 10 Packages", + panel="package_panel", + chart_type="colored_bar", + formatter_fn=format_top_packages, + collect_fn=collect_packages, + is_per_package=True, + ), + ChartDefinition( + id="pkg-cwe-bar", + title="Top 10 CWE Distribution", + panel="package_panel", + chart_type="colored_bar", + formatter_fn=format_top_cwes, + collect_fn=collect_cwes, + is_per_package=False, + has_search=True, + ), + ChartDefinition( + id="severity-scatter-plot", + title="Severity Distribution across Packages", + panel="severity_panel", + chart_type="scatter", + formatter_fn=get_severity_snapshot_data, + collect_fn=collect_severities, + has_search=True, + ), + ChartDefinition( + id="importer-empty-pkg-bar", + title="PURL Coverage across Importers", + panel="importer_panel", + chart_type="importer_bar", + formatter_fn=partial(_get_snapshot_data, build_columns_fn=build_importer_package_columns), + collect_fn=collect_importers, + ), + ChartDefinition( + id="importer-exploit-bar", + title="Exploit Coverage across Importers", + panel="importer_panel", + chart_type="importer_bar", + formatter_fn=partial(_get_snapshot_data, build_columns_fn=build_importer_exploit_columns), + collect_fn=collect_importers, + ), + ChartDefinition( + id="dq-issue-type-bar", + title="Open Issues by Type per Datasource", + panel="data_quality_panel", + chart_type="colored_bar", + formatter_fn=format_issue_type_bar, + collect_fn=collect_open_issues_to_datasource, + ), + ChartDefinition( + id="dq-importer-contribution-donut", + title="Datasource Contribution per Open Issue", + panel="data_quality_panel", + chart_type="donut", + formatter_fn=format_issue_contribution_donut, + ), + ChartDefinition( + id="dq-resolution-timeline", + title="Issue Opened and Resolved Monthly", + panel="data_quality_panel", + chart_type="line", + formatter_fn=format_todos_resolution_timeline, + collect_fn=collect_data_quality_todos_resolutions, + ), +] + +PANELS = [ + "overview_panel", + "package_panel", + "severity_panel", + "importer_panel", + "data_quality_panel", +] +PANEL_LABELS = { + "overview_panel": "Overview", + "package_panel": "Package Analytics", + "severity_panel": "Severity Analytics", + "importer_panel": "Importer Analytics", + "data_quality_panel": "Data Quality Analytics", +} +PANEL_LAYOUTS = { + "package_panel": "split_top", +} diff --git a/insights/charts/data_quality_panel.py b/insights/charts/data_quality_panel.py new file mode 100644 index 000000000..43ad03134 --- /dev/null +++ b/insights/charts/data_quality_panel.py @@ -0,0 +1,238 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from collections import defaultdict +from datetime import timedelta +from itertools import accumulate +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models.functions import TruncMonth +from django.utils import timezone + +from insights.models import DataQualityIssueByDatasourceInsight +from insights.models import DataQualityToDosResolutionInsight +from insights.utils import format_issue_type_label +from vulnerabilities.models import AdvisoryToDoV2 + +# Ignore Phantom Importers that don't collect Affected Packages +IGNORED_IMPORTERS = { + "epss_importer_v2", + "epss", + "vulnrichment_importer_v2", + "vulnrichment", + "suse_importer_v2", + "suse_score", +} + + +# Open Issues by Type and Datasource Contribution per Open Issue +def open_issues_to_datasource_queryset(): + """Return a query set of open issue counts by type and datasource.""" + return ( + AdvisoryToDoV2.objects.filter(is_resolved=False, advisories__datasource_id__isnull=False) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .values("issue_type", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + + +def iter_open_issues_to_datasource_insights(): + """Yield DataQualityIssueByDatasourceInsight objects.""" + for row in open_issues_to_datasource_queryset(): + yield DataQualityIssueByDatasourceInsight( + issue_type=row["issue_type"], + datasource_id=row["advisories__datasource_id"], + count=row["count"], + ) + + +def collect_open_issues_to_datasource(pipeline: Any) -> None: + """Collect open issue counts by type and datasource.""" + pipeline.data_quality_issue_types = list(iter_open_issues_to_datasource_insights()) + + +def build_issue_type_columns(issue_counts: dict) -> Dict[str, Any]: + """Helper to build column data for the issue type bar chart as expected by Billboard""" + issue_types = list(issue_counts.keys()) + open_issue_counts = list(issue_counts.values()) + + return { + "columns": [ + ["x"] + issue_types, + ["To-Dos"] + open_issue_counts, + ], + "x_label": "Type of Issue", + "y_label": "To-Dos Open Issue Count", + "color": "var(--bulma-danger)", + } + + +def format_issue_type_bar(snapshot: Any) -> Dict[str, Any]: + """Format open issues by type per datasource for the colored bar chart.""" + open_issue_counts_by_datasource = defaultdict(dict) + total_issues_by_type = defaultdict(int) + + for insight in snapshot.data_quality_issue_types.all(): + label = format_issue_type_label(insight.issue_type) + open_issue_counts_by_datasource[insight.datasource_id][label] = insight.count + total_issues_by_type[label] += insight.count + + data = {} + for datasource_id, counts in open_issue_counts_by_datasource.items(): + data[datasource_id] = build_issue_type_columns(counts) + + if total_issues_by_type: + data["global"] = build_issue_type_columns(total_issues_by_type) + + return data + + +def format_issue_contribution_donut(snapshot: Any) -> Dict[str, Any]: + """Format datasource contribution per open issue for the donut chart as expected by Billboard""" + datasource_counts_by_issue = defaultdict(dict) + total_issues_by_datasource = defaultdict(int) + + for insight in snapshot.data_quality_issue_types.all(): + label = format_issue_type_label(insight.issue_type) + datasource_counts_by_issue[label][insight.datasource_id] = insight.count + total_issues_by_datasource[insight.datasource_id] += insight.count + + data = {} + for issue_type, counts in datasource_counts_by_issue.items(): + columns = [[datasource_id, count] for datasource_id, count in counts.items()] + data[issue_type] = {"columns": columns} + + if total_issues_by_datasource: + global_columns = [ + [datasource_id, count] for datasource_id, count in total_issues_by_datasource.items() + ] + data["global"] = {"columns": global_columns} + + return data + + +# To-Dos Issue Resolution Timeline +def data_quality_todos_resolutions_queryset(): + """Return resolution rates by month for open and resolved to-dos.""" + start_date = timezone.now() - timedelta(days=365) # Collect last 12 months only + + open_todos = ( + AdvisoryToDoV2.objects.filter( + created_at__isnull=False, + created_at__gte=start_date, + advisories__datasource_id__isnull=False, + ) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .annotate(month=TruncMonth("created_at")) + .values("month", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + + resolved_todos = ( + AdvisoryToDoV2.objects.filter( + is_resolved=True, + resolved_at__isnull=False, + resolved_at__gte=start_date, + advisories__datasource_id__isnull=False, + ) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .annotate(month=TruncMonth("resolved_at")) + .values("month", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + return open_todos, resolved_todos + + +def iter_data_quality_todos_resolutions_insights(): + """Yield DataQualityToDosResolutionInsight objects.""" + open_todos, resolved_todos = data_quality_todos_resolutions_queryset() + + open_counts = defaultdict(int) + for open_record in open_todos: + datasource = open_record["advisories__datasource_id"] + month = open_record["month"].date() + open_counts[datasource, month] += open_record["count"] + + resolved_counts = defaultdict(int) + for resolved_record in resolved_todos: + datasource = resolved_record["advisories__datasource_id"] + month = resolved_record["month"].date() + resolved_counts[datasource, month] += resolved_record["count"] + + all_keys = set(open_counts.keys()) | set( + resolved_counts.keys() + ) # All unique (datasource, month) pairs across opened and resolved to-dos + + for datasource_id, month_date in sorted(all_keys): + yield DataQualityToDosResolutionInsight( + datasource_id=datasource_id, + month=month_date, + open_count=open_counts[(datasource_id, month_date)], + resolved_count=resolved_counts[(datasource_id, month_date)], + ) + + +def collect_data_quality_todos_resolutions(pipeline: Any) -> None: + """Collect historical resolution rates by month.""" + pipeline.data_quality_todos_resolutions = list(iter_data_quality_todos_resolutions_insights()) + + +def build_todos_resolution_columns(month_counts: Dict[str, Dict[str, int]]) -> Dict[str, Any]: + """Helper to build issue resolution line chart as expected by Billboard""" + sorted_months = sorted(month_counts.keys()) + new_open_counts = [month_counts[month]["open"] for month in sorted_months] + new_resolved_counts = [month_counts[month]["resolved"] for month in sorted_months] + + return { + "columns": [ + ["x"] + sorted_months, + ["Open"] + list(accumulate(new_open_counts)), + ["Resolved"] + list(accumulate(new_resolved_counts)), + ], + "new_open_counts": new_open_counts, + "new_resolved_counts": new_resolved_counts, + "y_label": "Cumulative Count", + } + + +def format_todos_resolution_timeline(snapshot: Any) -> Dict[str, Any]: + """Format cumulative to-dos resolution line chart""" + resolution_insights = snapshot.data_quality_todos_resolutions.all().order_by("month") + + datasource_month_counts = defaultdict(dict) + global_month_counts = {} + + for insight in resolution_insights: + formatted_month = insight.month.strftime("%Y-%m-%d") + datasource_id = insight.datasource_id + + # Initialize global month count if not exists + if formatted_month not in global_month_counts: + global_month_counts[formatted_month] = {"open": 0, "resolved": 0} + + global_month_counts[formatted_month]["open"] += insight.open_count + global_month_counts[formatted_month]["resolved"] += insight.resolved_count + + datasource_month_counts[datasource_id][formatted_month] = { + "open": insight.open_count, + "resolved": insight.resolved_count, + } + + data = {} + if global_month_counts: + data["global"] = build_todos_resolution_columns(global_month_counts) + for datasource_id, month_counts in datasource_month_counts.items(): + data[datasource_id] = build_todos_resolution_columns(month_counts) + + return data diff --git a/insights/charts/importer_panel.py b/insights/charts/importer_panel.py new file mode 100644 index 000000000..84c948963 --- /dev/null +++ b/insights/charts/importer_panel.py @@ -0,0 +1,214 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models import Exists +from django.db.models import OuterRef +from django.db.models import Q + +from insights.models import ImporterInsight +from insights.utils import format_importer_name +from vulnerabilities.models import AdvisoryExploit +from vulnerabilities.models import AdvisoryV2 + +# Ignore Phantom Importers that don't collect Affected Packages +IGNORED_IMPORTERS = { + "epss_importer_v2", + "epss", + "vulnrichment_importer_v2", + "suse_importer_v2", + "suse_score", +} + + +def packages_queryset(): + """Return a query set of package statistics by importer.""" + return ( + AdvisoryV2.objects.filter(is_latest=True) + .exclude(datasource_id__in=IGNORED_IMPORTERS) + .values("datasource_id") + .annotate( + total_advisories=Count("avid", distinct=True), + advisories_with_packages=Count( + "avid", filter=Q(impacted_packages__affecting_packages__isnull=False), distinct=True + ), + advisories_with_ghost_packages=Count( + "avid", + filter=Q(impacted_packages__affecting_packages__is_ghost=True), + distinct=True, + ), + ) + .order_by("-total_advisories") + .iterator() + ) + + +def exploits_queryset(): + """Return a query set of exploit statistics by importer.""" + kev_exploits = AdvisoryExploit.objects.filter(advisory=OuterRef("pk"), data_source="KEV") + metasploit_exploits = AdvisoryExploit.objects.filter( + advisory=OuterRef("pk"), data_source="Metasploit" + ) + exploitdb_exploits = AdvisoryExploit.objects.filter( + advisory=OuterRef("pk"), data_source="Exploit-DB" + ) + + return ( + AdvisoryV2.objects.filter(is_latest=True) + .exclude(datasource_id__in=IGNORED_IMPORTERS) + .annotate( + has_kev=Exists(kev_exploits), + has_metasploit=Exists(metasploit_exploits), + has_exploitdb=Exists(exploitdb_exploits), + ) + .values("datasource_id") + .annotate( + advisories_with_kev=Count("avid", filter=Q(has_kev=True), distinct=True), + advisories_with_metasploit=Count( + "avid", filter=Q(has_kev=False, has_metasploit=True), distinct=True + ), + advisories_with_exploitdb=Count( + "avid", + filter=Q(has_kev=False, has_metasploit=False, has_exploitdb=True), + distinct=True, + ), + ) + .iterator() + ) + + +def iter_importer_insights(): + """Yield ImporterInsight objects by merging package and exploit statistics.""" + exploit_stats_by_importer = {} + for record in exploits_queryset(): + exploit_stats_by_importer[record["datasource_id"]] = record + + for pkg_record in packages_queryset(): + datasource_id = pkg_record["datasource_id"] + exploit_record = exploit_stats_by_importer.get(datasource_id, {}) + + yield ImporterInsight( + importer=datasource_id, + total_advisories=pkg_record["total_advisories"], + advisories_with_packages=pkg_record["advisories_with_packages"], + advisories_with_ghost_packages=pkg_record["advisories_with_ghost_packages"], + advisories_with_kev=exploit_record.get("advisories_with_kev", 0), + advisories_with_metasploit=exploit_record.get("advisories_with_metasploit", 0), + advisories_with_exploitdb=exploit_record.get("advisories_with_exploitdb", 0), + ) + + +def collect_importers(pipeline): + """Calculates and stores importer insights.""" + + # Two charts use this function, so we avoid rerunning the query twice + if pipeline.importer_insights: + return + + for insight in iter_importer_insights(): + pipeline.importer_insights.append(insight) + + +def _get_snapshot_data(snapshot: Any, build_columns_fn) -> Dict[str, Any]: + """Helper to format importer statistics using a provided column builder.""" + all_importers = list(snapshot.importer_insights.order_by("-total_advisories")) + data = {} + + if all_importers: + # Top 5 by default for global view + data["global"] = build_columns_fn(all_importers[:5]) + + # Individual data for each importer + for importer_insight in all_importers: + formatted_name = format_importer_name(importer_insight.importer) + data[formatted_name] = build_columns_fn([importer_insight]) + + return data + + +def build_importer_package_columns(importers) -> Dict[str, Any]: + """Helper to build mappings for Package Coverage chart as expected by Billboard.JS""" + importer_names = [] + total_advisories = [] + advisories_with_packages = [] + advisories_without_packages = [] + advisories_with_ghost_packages = [] + advisories_without_ghost_packages = [] + + for importer_insight in importers: + importer_names.append(format_importer_name(importer_insight.importer)) + total_advisories.append(importer_insight.total_advisories) + advisories_with_packages.append(importer_insight.advisories_with_packages) + advisories_without_packages.append( + importer_insight.total_advisories - importer_insight.advisories_with_packages + ) + advisories_with_ghost_packages.append(importer_insight.advisories_with_ghost_packages) + advisories_without_ghost_packages.append( + importer_insight.advisories_with_packages + - importer_insight.advisories_with_ghost_packages + ) + + return { + "x_categories": importer_names, + "columns": [ + ["total_advisories"] + total_advisories, + ["advisories_with_packages"] + advisories_with_packages, + ["advisories_without_packages"] + advisories_without_packages, + ["advisories_without_ghost_packages"] + advisories_without_ghost_packages, + ["advisories_with_ghost_packages"] + advisories_with_ghost_packages, + ], + "groups": [ + ["advisories_with_packages", "advisories_without_packages"], + ["advisories_without_ghost_packages", "advisories_with_ghost_packages"], + ], + } + + +def build_importer_exploit_columns(importers) -> Dict[str, Any]: + """Helper to build mappings for Exploit Coverage chart as expected by Billboard.JS""" + importer_names = [] + total_advisories = [] + advisories_with_exploits = [] + advisories_without_exploits = [] + advisories_with_kev = [] + advisories_with_metasploit = [] + advisories_with_exploitdb = [] + + for importer_insight in importers: + importer_names.append(format_importer_name(importer_insight.importer)) + total_advisories.append(importer_insight.total_advisories) + advisories_with_kev.append(importer_insight.advisories_with_kev) + advisories_with_metasploit.append(importer_insight.advisories_with_metasploit) + advisories_with_exploitdb.append(importer_insight.advisories_with_exploitdb) + + total_with_exploits = ( + importer_insight.advisories_with_kev + + importer_insight.advisories_with_metasploit + + importer_insight.advisories_with_exploitdb + ) + advisories_with_exploits.append(total_with_exploits) + advisories_without_exploits.append(importer_insight.total_advisories - total_with_exploits) + + return { + "x_categories": importer_names, + "columns": [ + ["total_advisories"] + total_advisories, + ["advisories_with_exploits"] + advisories_with_exploits, + ["advisories_without_exploits"] + advisories_without_exploits, + ["advisories_with_exploitdb"] + advisories_with_exploitdb, + ["advisories_with_metasploit"] + advisories_with_metasploit, + ["advisories_with_kev"] + advisories_with_kev, + ], + "groups": [ + ["advisories_with_exploits", "advisories_without_exploits"], + ["advisories_with_exploitdb", "advisories_with_metasploit", "advisories_with_kev"], + ], + } diff --git a/insights/charts/overview_panel.py b/insights/charts/overview_panel.py new file mode 100644 index 000000000..fab450e41 --- /dev/null +++ b/insights/charts/overview_panel.py @@ -0,0 +1,186 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from datetime import timedelta +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models.functions import ExtractYear +from django.db.models.functions import TruncDate +from django.utils import timezone + +from insights.models import DailySnapshot +from insights.models import OverviewInsight +from insights.models import OverviewYearlyInsight +from insights.utils import format_importer_name +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import PackageV2 + + +# Overview KPIs +def kpi_card_queryset() -> Dict[str, int]: + """Return querysets for KPI cards.""" + return { + "total_advisories": AdvisoryV2.objects.filter(is_latest=True).aggregate( + count=Count("avid", distinct=True) + )["count"], + "total_packages": PackageV2.objects.values("plain_package_url").distinct().count(), + "total_data_sources": AdvisoryV2.objects.values("datasource_id").distinct().count(), + } + + +def daily_growth_counts(reference_time) -> list[int]: + """Return daily advisory ingestion counts for the last 30 days as a list.""" + start_date = reference_time.date() - timedelta(days=30) + daily_counts = { + entry["day"]: entry["count"] + for entry in ( + AdvisoryV2.objects.filter(is_latest=True, date_collected__gte=start_date) + .annotate(day=TruncDate("date_collected")) + .values("day") + .annotate(count=Count("avid", distinct=True)) + ) + if entry["day"] + } + return [daily_counts.get(start_date + timedelta(days=i), 0) for i in range(31)] + + +def collect_kpi_card(pipeline: Any) -> None: + """Collect overall totals and 30-day ingestion counts for OverviewInsight.""" + reference_time = timezone.now() + pipeline.overview_insight = OverviewInsight( + **kpi_card_queryset(), + last_30days=daily_growth_counts(reference_time), + ) + + +def build_kpi_card( + overview: OverviewInsight, previous_snapshot: DailySnapshot = None +) -> Dict[str, Any]: + """Build the KPI card data with deltas compared to the previous snapshot.""" + delta_advisories = 0 + delta_packages = 0 + delta_sources = 0 + + if previous_snapshot: + previous_overview = previous_snapshot.overview + delta_advisories = overview.total_advisories - previous_overview.total_advisories + delta_packages = overview.total_packages - previous_overview.total_packages + delta_sources = overview.total_data_sources - previous_overview.total_data_sources + + return { + "total_advisories": overview.total_advisories, + "delta_advisories": delta_advisories, + "total_packages": overview.total_packages, + "delta_packages": delta_packages, + "total_data_sources": overview.total_data_sources, + "delta_data_sources": delta_sources, + } + + +def format_kpi_card(snapshot: Any) -> Dict[str, Any]: + """Format the Overview KPI cards and calculate deltas compared to the previous snapshot.""" + previous_snapshot = ( + DailySnapshot.objects.filter(created_at__lt=snapshot.created_at) + .order_by("-created_at") + .first() + ) + return {"kpis": build_kpi_card(snapshot.overview, previous_snapshot)} + + +# Year wise distribution of advisories +def yearly_distribution_queryset(): + """Return a queryset of advisories grouped by year for the last 10 years.""" + current_year = timezone.now().year + start_year = current_year - 9 + + return ( + AdvisoryV2.objects.filter( + date_published__isnull=False, + date_published__year__gte=start_year, + date_published__year__lte=current_year, + ) + .annotate(year=ExtractYear("date_published")) + .values("year") + .annotate(count=Count("avid", distinct=True)) + .order_by("-year") + .iterator() + ) + + +def iter_yearly_distribution_insights(): + """Yield OverviewYearlyInsight objects.""" + for yearly_record in yearly_distribution_queryset(): + yield OverviewYearlyInsight(**yearly_record) + + +def collect_yearly_distribution(pipeline: Any) -> None: + """Collect advisories by published year for the last 10 years.""" + pipeline.overview_yearly_insights = list(iter_yearly_distribution_insights()) + + +def format_yearly_distribution(snapshot: Any) -> Dict[str, Any]: + """Format historical advisories published over the last 10 years as expected by Billboard""" + yearly_insights = snapshot.overview.yearly_insights.order_by("year") + + return { + "columns": [ + ["x"] + [str(yi.year) for yi in yearly_insights], + ["Advisories Published"] + [yi.count for yi in yearly_insights], + ], + "x_label": "Year", + "y_label": "Advisories Published", + "color": "var(--bulma-primary)", + "rotated": False, + "x_tick_culling": 15, + } + + +# Advisory Ingestion Trend +def format_growth_trend(snapshot: Any) -> Dict[str, Any]: + """Format daily advisory ingestion trend as expected by Billboard.""" + reference_time = getattr(snapshot, "created_at", None) or timezone.now() + start_date = reference_time.date() - timedelta(days=30) + + dates = [(start_date + timedelta(days=i)).strftime("%b %d") for i in range(31)] + counts = getattr(snapshot.overview, "last_30days", []) or [0] * len(dates) + + return { + "columns": [ + ["x"] + dates, + ["Advisories Imported"] + counts, + ], + "x_label": "Date", + "y_label": "Advisories Imported", + "color": "var(--bulma-primary-dark)", + "rotated": False, + "x_tick_culling": 7, + } + + +# Coverage Comparison +def format_coverage_comparison(snapshot: Any) -> Dict[str, Any]: + """Format the coverage comparison graph as expected by Billboard.""" + importers = list(snapshot.importer_insights.all().order_by("-total_advisories")) + importer_names = [format_importer_name(importer.importer) for importer in importers] + total_vulnerablecode_advisories = sum(importer.total_advisories for importer in importers) + + columns = [ + ["x", "VulnerableCode", "Sources"], + ["VulnerableCode", total_vulnerablecode_advisories, 0], + ] + for importer in importers: + columns.append([format_importer_name(importer.importer), 0, importer.total_advisories]) + + return { + "columns": columns, + "groups": [importer_names], + "y_label": "Advisories", + } diff --git a/insights/charts/package_panel.py b/insights/charts/package_panel.py new file mode 100644 index 000000000..78482b4c0 --- /dev/null +++ b/insights/charts/package_panel.py @@ -0,0 +1,194 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from collections import Counter +from collections import defaultdict +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models import F +from django.db.models import Q + +from insights.models import PackageCWEInsight +from insights.models import PackageInsight +from insights.models import PackageNameInsight +from insights.utils import get_cwe_label +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import PackageV2 + +# Generic OWASP category CWEs used by NVD, not actual weakness IDs. +IGNORED_CWE_IDS = [937, 1035] + + +# Package Distribution +def ecosystem_distribution_queryset(package_types): + """Return a query set of total package counts per ecosystem.""" + return ( + PackageV2.objects.filter(type__in=package_types) + .values("type") + .annotate(total_package_count=Count("id")) + .iterator() + ) + + +def iter_ecosystem_distribution_insights(package_types): + """Yield total package type counts per ecosystem.""" + for package_stat in ecosystem_distribution_queryset(package_types): + yield package_stat["type"], package_stat["total_package_count"] + + +def collect_ecosystem_distribution(pipeline: Any) -> None: + """Collect total package type counts per ecosystem.""" + for package_type, total_packages in iter_ecosystem_distribution_insights(pipeline.packages): + if package_type in pipeline.package_insights: + pipeline.package_insights[package_type].total_packages = total_packages + + +def format_ecosystem_distribution(snapshot: Any) -> Dict[str, Any]: + """Format the Ecosystem Distribution Chart as expected by Billboard.JS""" + stats = list(snapshot.package_insights.exclude(package="global").order_by("-total_packages")) + + columns = [[stat.package, stat.total_packages] for stat in stats[:10]] + + others_count = sum(stat.total_packages for stat in stats[10:]) + others_list = [[stat.package, stat.total_packages] for stat in stats[10:20]] + if others_count > 0: + columns.append(["Others", others_count]) + + return {"global": {"columns": columns, "others_list": others_list}} + + +# Top 10 Packages +def top_packages_queryset(package_type: str): + """Return a query set of the top 10 packages by number of distinct advisories.""" + return ( + PackageV2.objects.filter(type=package_type) + .values("name") + .annotate( + count=Count( + "affected_in_impacts__advisory", + filter=Q(affected_in_impacts__advisory__is_latest=True), + ) + ) + .filter(count__gt=0) + .order_by("-count")[:10] + .iterator() + ) + + +def iter_packages_insights(package_type: str, insight_obj: Any): + """Yield PackageNameInsight objects for the top 10 packages of a given type.""" + for stat in top_packages_queryset(package_type): + yield PackageNameInsight( + package_insight=insight_obj, name=stat["name"], count=stat["count"] + ) + + +def collect_packages(pipeline: Any, package_type: str) -> None: + """Collect the top 10 packages for a package type""" + insight_obj = pipeline.package_insights[package_type] + for insight in iter_packages_insights(package_type, insight_obj): + pipeline.package_names.append(insight) + + +def build_name_chart_columns(name_counts: dict) -> Dict[str, Any]: + """Helper to build package name bar charts.""" + names = list(name_counts.keys()) + counts = list(name_counts.values()) + + return { + "columns": [ + ["x"] + names, + ["Advisories"] + counts, + ], + "x_label": "Package Name", + "y_label": "Advisories", + "color": "var(--bulma-orange)", + } + + +def format_top_packages(snapshot: Any) -> Dict[str, Any]: + """ + Format name data for the frontend donut chart as expected by Billboard.JS + Returns Top 10 package names for each package type and global. + """ + data = {} + global_counts = defaultdict(int) + + for package_insight in ( + snapshot.package_insights.exclude(package="global").prefetch_related("names").all() + ): + name_counts = {ns.name: ns.count for ns in package_insight.names.all()} + data[package_insight.package] = build_name_chart_columns(name_counts) + for name, count in name_counts.items(): + global_counts[name] += count + + top_global = dict(Counter(global_counts).most_common(10)) + data["global"] = build_name_chart_columns(top_global) + return data + + +# Top CWE Distribution +def compute_top_cwes(packages=None) -> dict: + """Computes the top 10 CWEs for the given queryset of packages. If None, computes globally.""" + qs = AdvisoryV2.objects.filter(weaknesses__isnull=False) + if packages is not None: + qs = qs.filter(impacted_packages__affecting_packages__in=packages) + + top_10 = ( + qs.values(cwe=F("weaknesses__cwe_id")) + .exclude(cwe__in=IGNORED_CWE_IDS) + .annotate(count=Count("avid", distinct=True)) + .order_by("-count")[:10] + ) + return {stat["cwe"]: stat["count"] for stat in top_10 if stat["cwe"]} + + +def iter_cwes_insights(insight_obj: Any): + """Yield PackageCWEInsight objects for the global top 10 CWE distribution.""" + for cwe_id, count in compute_top_cwes().items(): + yield PackageCWEInsight(package_insight=insight_obj, cwe_id=cwe_id, count=count) + + +def collect_cwes(pipeline: Any) -> None: + """Collect the global top 10 CWE distribution.""" + if "global" not in pipeline.package_insights: + pipeline.package_insights["global"] = PackageInsight(package="global") + + # Collect top 10 CWEs globally + insight_obj = pipeline.package_insights["global"] + for insight in iter_cwes_insights(insight_obj): + pipeline.package_cwes.append(insight) + + +def build_cwe_chart_columns(cwe_counts: dict) -> Dict[str, Any]: + """Helper to CWE chart as expected by Billboard.JS""" + # Sort CWEs by count in descending order + sorted_cwes = sorted(cwe_counts.items(), key=lambda item: item[1], reverse=True) + return { + "columns": [ + ["x"] + [f"CWE-{cwe_id}" for cwe_id, count in sorted_cwes], + ["Advisories"] + [count for cwe_id, count in sorted_cwes], + ], + "full_labels": [get_cwe_label(cwe_id) for cwe_id, count in sorted_cwes], + } + + +def format_top_cwes(snapshot: Any) -> Dict[str, Any]: + """Format CWE distribution data.""" + data = {} + try: + global_insight = snapshot.package_insights.get(package="global") + cwe_counts = {cwe.cwe_id: cwe.count for cwe in global_insight.cwes.all()} + if cwe_counts: + data["global"] = build_cwe_chart_columns(cwe_counts) + except PackageInsight.DoesNotExist: + pass + + return data diff --git a/insights/charts/severity_panel.py b/insights/charts/severity_panel.py new file mode 100644 index 000000000..51430bb30 --- /dev/null +++ b/insights/charts/severity_panel.py @@ -0,0 +1,74 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from typing import Any +from typing import Dict + +from django.db.models import Count + +from insights.models import SeverityInsight +from vulnerabilities.models import AdvisoryV2 + +CVSS_SCORE_LABELS = ["0-1", "1-2", "2-3", "3-4", "4-5", "5-6", "6-7", "7-8", "8-9", "9-10"] + + +def aggregate_severity_buckets(queryset: Any, count_field: str) -> list[int]: + """Helper to aggregate a queryset of severity values into exactly 10 CVSS buckets.""" + buckets = [0] * 10 + + for severity in queryset: + try: + score = float(severity.get("severities__value")) + if 0 <= score <= 10: + buckets[min(9, int(score))] += severity[count_field] + except (ValueError, TypeError): + continue + + return buckets + + +def iter_severity_insights(): + """Yield SeverityInsight objects.""" + advisory_severities = ( + AdvisoryV2.objects.filter( + severities__isnull=False, severities__scoring_system__icontains="cvss" + ) + .values("severities__value") + .annotate(advisory_count=Count("avid", distinct=True)) + .iterator() + ) + + severity_buckets = aggregate_severity_buckets(advisory_severities, "advisory_count") + yield SeverityInsight(buckets=severity_buckets) + + +def collect_severities(pipeline: Any) -> None: + """Pre-compute the global severity distribution.""" + for insight in iter_severity_insights(): + pipeline.severity_insight = insight + + +def format_severity_chart_data(buckets: list[int]) -> Dict[str, Any]: + """Helper to build mappings for Severity Distribution chart as expected by Billboard.JS""" + return { + "columns": [ + ["x"] + CVSS_SCORE_LABELS, + ["Advisories"] + buckets, + ] + } + + +def get_severity_snapshot_data(snapshot: Any) -> Dict[str, Any]: + """Return the global severity distribution for the frontend scatter plot.""" + if hasattr(snapshot, "severity_insight"): + insight = snapshot.severity_insight + global_buckets = insight.buckets + else: + global_buckets = [0] * 10 + + return {"global": format_severity_chart_data(global_buckets)} diff --git a/insights/insights_snapshot_pipeline.py b/insights/insights_snapshot_pipeline.py new file mode 100644 index 000000000..5e4644a63 --- /dev/null +++ b/insights/insights_snapshot_pipeline.py @@ -0,0 +1,118 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from aboutcode.pipeline import LoopProgress +from django.db import transaction + +from insights.charts import CHARTS +from insights.models import DailySnapshot +from insights.models import DataQualityIssueByDatasourceInsight +from insights.models import DataQualityToDosResolutionInsight +from insights.models import ImporterInsight +from insights.models import OverviewCoverageInsight +from insights.models import OverviewYearlyInsight +from insights.models import PackageCWEInsight +from insights.models import PackageInsight +from insights.models import PackageNameInsight +from vulnerabilities.models import PackageV2 +from vulnerabilities.pipelines import VulnerableCodePipeline + + +class InsightsSnapshotPipeline(VulnerableCodePipeline): + """Pipeline to compute aggregated statistics for the Insights Dashboard.""" + + pipeline_id = "insights_snapshot" + + @classmethod + def steps(cls): + return ( + cls.compute_chart_analytics, + cls.save_snapshot, + ) + + def compute_chart_analytics(self): + """Run chart collect_fns to compute analytics.""" + + # List all package types + self.packages = list(PackageV2.objects.order_by().values_list("type", flat=True).distinct()) + + self.package_insights = {pkg: PackageInsight(package=pkg) for pkg in self.packages} + self.package_names = [] + self.package_cwes = [] + self.importer_insights = [] + self.severity_insight = None + self.overview_insight = None + self.overview_yearly_insights = [] + self.overview_coverage_insights = [] + self.data_quality_issue_types = [] + self.data_quality_todos_resolutions = [] + active_charts = [chart_def for chart_def in CHARTS if chart_def.collect_fn] + + # Count steps for progress bar + total_steps = sum( + len(self.packages) if chart_def.is_per_package else 1 for chart_def in active_charts + ) + + progress = LoopProgress(total_iterations=total_steps, logger=self.log, progress_step=1) + progress_iter = iter(progress.iter(range(total_steps))) + + for chart_def in active_charts: + self.log(f"Running collect_fn for {chart_def.id}") + + if chart_def.is_per_package: + for pkg in self.packages: + next(progress_iter, None) + chart_def.collect_fn(self, pkg) + else: + next(progress_iter, None) + chart_def.collect_fn(self) + + @transaction.atomic + def save_snapshot(self): + self.log("Saving snapshot") + snapshot = DailySnapshot.objects.create() + + for insight in self.package_insights.values(): + insight.snapshot_id = snapshot.id + + for insight in self.importer_insights: + insight.snapshot_id = snapshot.id + + if self.severity_insight: + self.severity_insight.snapshot_id = snapshot.id + self.severity_insight.save() + + if self.overview_insight: + self.overview_insight.snapshot_id = snapshot.id + self.overview_insight.save() + + for yearly in self.overview_yearly_insights: + yearly.overview_id = self.overview_insight.id + for coverage in self.overview_coverage_insights: + coverage.overview_id = self.overview_insight.id + + for insight in self.data_quality_issue_types: + insight.snapshot_id = snapshot.id + for insight in self.data_quality_todos_resolutions: + insight.snapshot_id = snapshot.id + + PackageInsight.objects.bulk_create(self.package_insights.values(), batch_size=5000) + PackageNameInsight.objects.bulk_create(self.package_names, batch_size=5000) + PackageCWEInsight.objects.bulk_create(self.package_cwes, batch_size=5000) + ImporterInsight.objects.bulk_create(self.importer_insights, batch_size=5000) + OverviewYearlyInsight.objects.bulk_create(self.overview_yearly_insights, batch_size=5000) + OverviewCoverageInsight.objects.bulk_create( + self.overview_coverage_insights, batch_size=5000 + ) + DataQualityIssueByDatasourceInsight.objects.bulk_create( + self.data_quality_issue_types, batch_size=5000 + ) + DataQualityToDosResolutionInsight.objects.bulk_create( + self.data_quality_todos_resolutions, batch_size=5000 + ) + self.log("Snapshot saved successfully.") diff --git a/insights/migrations/0001_initial.py b/insights/migrations/0001_initial.py new file mode 100644 index 000000000..26b349636 --- /dev/null +++ b/insights/migrations/0001_initial.py @@ -0,0 +1,174 @@ +# Generated by Django 5.2.11 on 2026-07-23 21:56 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="DailySnapshot", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["-created_at"], + "get_latest_by": "created_at", + }, + ), + migrations.CreateModel( + name="PackageInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("package", models.CharField(max_length=50)), + ("total_packages", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="package_insights", + to="insights.dailysnapshot", + ), + ), + ], + ), + migrations.CreateModel( + name="PackageCWEInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("cwe_id", models.CharField(max_length=50)), + ("count", models.IntegerField()), + ( + "package_insight", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cwes", + to="insights.packageinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="PackageNameInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("count", models.IntegerField()), + ( + "package_insight", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="names", + to="insights.packageinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="SeverityInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "buckets", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), + default=list, + help_text="Scores mapped to buckets 0-10 (e.g. 0.0-0.9 -> index 0)", + size=10, + ), + ), + ( + "snapshot", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="severity_insight", + to="insights.dailysnapshot", + ), + ), + ], + ), + migrations.CreateModel( + name="ImporterInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("importer", models.CharField(max_length=100)), + ("total_advisories", models.IntegerField(default=0)), + ("advisories_with_packages", models.IntegerField(default=0)), + ("advisories_with_ghost_packages", models.IntegerField(default=0)), + ("advisories_with_kev", models.IntegerField(default=0)), + ("advisories_with_metasploit", models.IntegerField(default=0)), + ("advisories_with_exploitdb", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="importer_insights", + to="insights.dailysnapshot", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("snapshot", "importer"), name="unique_snapshot_importer" + ) + ], + }, + ), + migrations.AddConstraint( + model_name="packageinsight", + constraint=models.UniqueConstraint( + fields=("snapshot", "package"), name="unique_snapshot_package" + ), + ), + migrations.AddConstraint( + model_name="packagecweinsight", + constraint=models.UniqueConstraint( + fields=("package_insight", "cwe_id"), name="unique_package_cwe" + ), + ), + migrations.AddConstraint( + model_name="packagenameinsight", + constraint=models.UniqueConstraint( + fields=("package_insight", "name"), name="unique_package_name" + ), + ), + ] diff --git a/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py b/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py new file mode 100644 index 000000000..be15737cb --- /dev/null +++ b/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py @@ -0,0 +1,150 @@ +# Generated by Django 5.2.11 on 2026-08-08 22:59 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("insights", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OverviewInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("total_advisories", models.IntegerField(default=0)), + ("total_packages", models.IntegerField(default=0)), + ("total_data_sources", models.IntegerField(default=0)), + ( + "last_30days", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), default=list, size=None + ), + ), + ( + "snapshot", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="overview", + to="insights.dailysnapshot", + ), + ), + ], + ), + migrations.CreateModel( + name="OverviewCoverageInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("source_name", models.CharField(max_length=100)), + ("count", models.IntegerField()), + ( + "overview", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="coverage_insights", + to="insights.overviewinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="OverviewYearlyInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("year", models.IntegerField()), + ("count", models.IntegerField()), + ( + "overview", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="yearly_insights", + to="insights.overviewinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="DataQualityIssueByDatasourceInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("issue_type", models.CharField(max_length=50)), + ("datasource_id", models.CharField(max_length=100)), + ("count", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="data_quality_issue_types", + to="insights.dailysnapshot", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("snapshot", "issue_type", "datasource_id"), + name="unique_snapshot_issue_type_datasource", + ) + ], + }, + ), + migrations.CreateModel( + name="DataQualityToDosResolutionInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("datasource_id", models.CharField(max_length=100)), + ( + "month", + models.DateField(help_text="The first day of the month for this data point."), + ), + ("open_count", models.IntegerField(default=0)), + ("resolved_count", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="data_quality_todos_resolutions", + to="insights.dailysnapshot", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("snapshot", "datasource_id", "month"), + name="unique_snapshot_todos_resolution_datasource_month", + ) + ], + }, + ), + ] diff --git a/insights/migrations/__init__.py b/insights/migrations/__init__.py new file mode 100644 index 000000000..20854f2ad --- /dev/null +++ b/insights/migrations/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# diff --git a/insights/models.py b/insights/models.py new file mode 100644 index 000000000..89f9843c9 --- /dev/null +++ b/insights/models.py @@ -0,0 +1,173 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# +from dataclasses import dataclass +from typing import Any +from typing import Callable +from typing import Dict +from typing import Optional + +from django.contrib.postgres.fields import ArrayField +from django.db import models + + +class DailySnapshot(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + get_latest_by = "created_at" + ordering = ["-created_at"] + + +class OverviewInsight(models.Model): + snapshot = models.OneToOneField( + "DailySnapshot", related_name="overview", on_delete=models.CASCADE + ) + + total_advisories = models.IntegerField(default=0) + total_packages = models.IntegerField(default=0) + total_data_sources = models.IntegerField(default=0) + last_30days = ArrayField( + models.IntegerField(), + default=list, + ) + + +class OverviewYearlyInsight(models.Model): + overview = models.ForeignKey( + OverviewInsight, related_name="yearly_insights", on_delete=models.CASCADE + ) + year = models.IntegerField() + count = models.IntegerField() + + +class OverviewCoverageInsight(models.Model): + overview = models.ForeignKey( + OverviewInsight, related_name="coverage_insights", on_delete=models.CASCADE + ) + source_name = models.CharField(max_length=100) + count = models.IntegerField() + + +class PackageInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="package_insights", on_delete=models.CASCADE + ) + package = models.CharField(max_length=50) + total_packages = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["snapshot", "package"], name="unique_snapshot_package") + ] + + +class PackageNameInsight(models.Model): + package_insight = models.ForeignKey( + PackageInsight, related_name="names", on_delete=models.CASCADE + ) + name = models.CharField(max_length=255) + count = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["package_insight", "name"], name="unique_package_name") + ] + + +class PackageCWEInsight(models.Model): + package_insight = models.ForeignKey( + PackageInsight, related_name="cwes", on_delete=models.CASCADE + ) + cwe_id = models.CharField(max_length=50) + count = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["package_insight", "cwe_id"], name="unique_package_cwe") + ] + + +class SeverityInsight(models.Model): + snapshot = models.OneToOneField( + DailySnapshot, related_name="severity_insight", on_delete=models.CASCADE + ) + buckets = ArrayField( + models.IntegerField(), + size=10, + default=list, + help_text="Scores mapped to buckets 0-10 (e.g. 0.0-0.9 -> index 0)", + ) + + +class ImporterInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="importer_insights", on_delete=models.CASCADE + ) + importer = models.CharField(max_length=100) + total_advisories = models.IntegerField(default=0) + advisories_with_packages = models.IntegerField(default=0) + advisories_with_ghost_packages = models.IntegerField(default=0) + advisories_with_kev = models.IntegerField(default=0) + advisories_with_metasploit = models.IntegerField(default=0) + advisories_with_exploitdb = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["snapshot", "importer"], name="unique_snapshot_importer" + ) + ] + + +class DataQualityIssueByDatasourceInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="data_quality_issue_types", on_delete=models.CASCADE + ) + issue_type = models.CharField(max_length=50) + datasource_id = models.CharField(max_length=100) + count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["snapshot", "issue_type", "datasource_id"], + name="unique_snapshot_issue_type_datasource", + ) + ] + + +class DataQualityToDosResolutionInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="data_quality_todos_resolutions", on_delete=models.CASCADE + ) + datasource_id = models.CharField(max_length=100) + month = models.DateField(help_text="The first day of the month for this data point.") + open_count = models.IntegerField(default=0) + resolved_count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["snapshot", "datasource_id", "month"], + name="unique_snapshot_todos_resolution_datasource_month", + ) + ] + + +@dataclass(frozen=True) +class ChartDefinition: + + id: str + title: str + panel: str + chart_type: str + formatter_fn: Callable[[Dict[str, Any]], Dict[str, Any]] + collect_fn: Optional[Callable] = None + is_per_package: bool = False + has_search: bool = False diff --git a/insights/static/insights/css/insights.css b/insights/static/insights/css/insights.css new file mode 100644 index 000000000..2f3676e17 --- /dev/null +++ b/insights/static/insights/css/insights.css @@ -0,0 +1,330 @@ +/* Palette and CSS variables */ +:root { + --bulma-primary: #00D1B2; + --bulma-link: #3273DC; + --bulma-info: #209CEE; + --bulma-success: #48C774; + --bulma-warning: #FFDD57; + --bulma-danger: #FF3860; + --bulma-orange: #FF470F; + --bulma-purple: #B86BFF; + --bulma-grey: #7A7A7A; + --bulma-primary-dark: #00947e; + --bulma-black: #0A0A0A; + --bulma-white: #FFFFFF; +} + +/* Dashboard layout */ +.insights-layout { + display: flex; + align-items: flex-start; + gap: 1.5rem; + padding: 1.5rem 1rem; +} + +/* Override opacity of scatter plot bubbles */ +.bb-circles circle { + opacity: 0.7 !important; +} + +.insights-sidenav { + width: 220px; + flex-shrink: 0; + position: sticky; + top: 5rem; + height: calc(100vh - 7rem); + display: flex; + flex-direction: column; +} + +.insights-sidenav nav.menu { + flex: 1; + overflow-y: auto; + min-height: 0; + margin-bottom: 1rem; +} + +.insights-sidenav .menu-label { + color: var(--bulma-black); + font-weight: 600; +} + +.insights-sidenav .menu-list { + list-style: none !important; + padding-left: 0; + margin-left: 0; +} + +.insights-sidenav .menu-list li { + margin-bottom: 0.25rem; +} + +.insights-sidenav .menu-list a { + color: var(--bulma-black); + padding-left: 1rem; + border-radius: 0 4px 4px 0; +} + +.insights-sidenav .menu-list a:hover { + background-color: #f6f8fa; + color: var(--bulma-black); +} + +.insights-sidenav .menu-list a.is-active { + background-color: #f0f5fa; + color: var(--bulma-link); + font-weight: 600; + border-left: 3px solid var(--bulma-link); + padding-left: calc(1rem - 3px); +} + +.insights-main { + flex: 1; + min-width: 0; +} + +.chart-inner { + max-width: 860px; + margin-left: auto; + margin-right: auto; +} + +.chart-row { + margin-bottom: 1.25rem; + padding: 1.25rem 1.5rem; + background: var(--bulma-white); +} + +.chart-row h3 { + margin-bottom: 0.75rem; + font-size: 1rem; + font-weight: 600; + color: #2c3e50; +} + + + +/* Chart containers and loading state */ +.bb svg { + width: 100% !important; +} + +.chart-container { + width: 100%; + min-height: 320px; +} + +.chart-container.is-tall { + min-height: 420px; +} + +.chart-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--bulma-grey); + font-size: 0.9rem; +} + + + +/* Billboard.js tooltip overrides */ +.bb-tooltip-container table.bb-tooltip { + min-width: 260px; + white-space: nowrap; +} + +.bb-tooltip-container table.bb-tooltip th { + padding: 0.4rem 0.8rem !important; +} + +.bb-tooltip-container table.bb-tooltip td { + padding: 0.3rem 0.8rem !important; +} + +.bb-tooltip-container table.bb-tooltip td.value, +.bb-tooltip-container table.bb-tooltip td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 600; + padding-right: 1.2rem !important; +} + +.sidenav-footer { + margin-top: auto; + padding: 1rem 0; + border-top: 1px solid #e8e8e8; +} + +.snapshot-text { + font-size: 0.75rem; + color: var(--bulma-black); + line-height: 1.4; +} + +.snapshot-text strong { + color: var(--bulma-black); +} + +/* Classes replacing inline styles */ +.panel-section { + background-color: var(--bulma-white); + overflow: hidden; +} + +.flat-panel { + margin-bottom: 0; + box-shadow: none; +} + +.flat-panel-heading { + border-radius: 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.w-full { + max-width: 100%; +} + +.card-chart-row { + border: 1px solid #e8e8e8; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.card-chart-title { + border-bottom: 1px solid #e8e8e8; + padding-bottom: 0.5rem; + margin-bottom: 0.75rem; +} + +/* Package Panel Specific Styles */ +.chart-dropdown-wrapper { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.severity-chart-layout { + display: flex; + align-items: flex-start; + gap: 0; + width: 100%; + font-family: inherit; +} + +.severity-table-wrapper { + flex: 0 0 380px; + min-width: 260px; +} + +.severity-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.sev-th-left { + text-align: left; + padding: 4px 8px; + color: #555; + font-weight: 600; + border-bottom: 1px solid #ddd; +} + +.sev-th-right { + text-align: right; + padding: 4px 8px; + color: #555; + font-weight: 600; + border-bottom: 1px solid #ddd; +} + +.sev-tfoot-tr { + border-top: 1px solid #ddd; +} + +.sev-td-total-label { + padding: 5px 8px; + font-weight: 600; + color: #333; +} + +.sev-td-total-value { + padding: 5px 8px; + text-align: right; + font-weight: 600; + color: #2563eb; +} + +.severity-bb-container { + flex: 1; + min-height: 280px; + margin-top: 52px; +} + +.sev-td-bucket { + padding: 4px 8px; + vertical-align: middle; +} + +.sev-bucket-label { + display: inline-block; + width: 2.5em; + color: #444; +} + +.sev-bucket-bar { + display: inline-block; + height: 7px; + border-radius: 2px; + vertical-align: middle; + margin-left: 4px; +} + +.sev-td-count { + padding: 4px 8px; + text-align: right; + color: #2563eb; +} + +.sev-empty { + color: #aaa; +} + +.card-chart-title-with-search { + display: flex; + justify-content: space-between; + align-items: center; +} + +.severity-search-wrapper { + display: flex; + align-items: center; + margin-left: auto; +} + +/* Overview Panel Specific Styles */ +.kpi-card { + border-top: 4px solid var(--bulma-link); + height: 100%; + margin-bottom: 0 !important; +} + +.kpi-card:hover { + transform: translateY(-2px); + transition: transform 0.2s ease-in-out; +} + +.delta-positive { + color: var(--bulma-success); + font-weight: bold; +} + +.delta-negative { + color: var(--bulma-danger); + font-weight: bold; +} \ No newline at end of file diff --git a/insights/static/insights/js/core.js b/insights/static/insights/js/core.js new file mode 100644 index 000000000..3b046d6e9 --- /dev/null +++ b/insights/static/insights/js/core.js @@ -0,0 +1,57 @@ +// +// Copyright (c) nexB Inc. and others. All rights reserved. +// VulnerableCode is a trademark of nexB Inc. +// SPDX-License-Identifier: Apache-2.0 +// See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +// See https://github.com/aboutcode-org/vulnerablecode for support or download. +// See https://aboutcode.org for more information about nexB OSS projects. +// + +import { renderers } from './renderers.js'; + +export function renderChartWithData(chartId, key, chartDataMap = {}) { + // Handle dropdown charts with key + const chartData = key && chartDataMap[key] ? chartDataMap[key] : chartDataMap; + const chartContainer = document.getElementById(`chart-${chartId}`); + if (!chartContainer) return; + + if (!chartData?.columns?.length) { + chartContainer.innerHTML = "
No data available.
"; + return; + } + + const type = chartContainer.dataset.chartType; + if (renderers[type]) renderers[type](chartId, chartData); +} + +export function initDropdownChart(chartId, chartData, defaultLabel) { + const chartContainer = document.getElementById(`chart-${chartId}`); + if (!chartData || !chartContainer || chartContainer.previousElementSibling?.classList.contains("chart-dropdown-wrapper")) return; + + const options = Object.keys(chartData).filter(k => k !== "global"); + if (options.length) { + chartContainer.insertAdjacentHTML("beforebegin", ` +| Others | |
|---|---|
| ${name} | ${val.toLocaleString()} (${((val / total) * 100).toFixed(1)}%) |
{{ search_error }}
+ {% endif %} +Total Active Advisories
+--
+--
+Total Packages
+--
+--
+Data Sources
+--
+--
+| CVSS Score Range | +Advisories | +
|---|---|
| Total | ++ |