Add Phase 2 intel modules — 9 intelligence modules for on-device analysis
- credential_db: Central credential store with dedup, hashcat/CSV/JSON export, crack tracking - topology_mapper: Network graph from HOST_DISCOVERED/VLAN_DETECTED events, Graphviz DOT/SVG output - net_intel: Beacon detection (stddev/mean analysis), comm graph, DNS tunneling, service deps - user_timeline: Per-user activity timelines, work hours, admin/service account identification - supply_chain_detect: Passive detection of PyPI/npm mirrors, WSUS, CI/CD, SCCM, container registries - change_detector: Continuous baseline diff for burn detection, scan/security tool alerts - security_posture: EDR/SIEM/NAC/honeypot/scanner detection by DNS/port/UA, risk gating - operator_audit: Append-only HMAC chain audit log for engagement deconfliction - tool_output_parser: Unified parser for bettercap events, Responder logs, mitmproxy flows
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supply chain infrastructure detection — passively identifies internal
|
||||
package repos, update servers, config management, CI/CD, and container
|
||||
registries by analyzing DNS and HTTP traffic patterns.
|
||||
|
||||
Detection only — no exploitation or active probing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger("bb.intel.supply_chain_detect")
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS supply_chain (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
target_ip TEXT NOT NULL,
|
||||
target_hostname TEXT DEFAULT '',
|
||||
target_port INTEGER,
|
||||
evidence TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
UNIQUE(type, target_ip, target_port)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_type ON supply_chain(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_ip ON supply_chain(target_ip);
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detection signatures
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# HTTP path patterns -> (infra_type, description, confidence)
|
||||
_HTTP_PATH_PATTERNS = [
|
||||
# Package repositories
|
||||
(re.compile(r"/simple/[\w-]+/"), "pypi_mirror", "PyPI mirror (PEP 503 simple API)", 0.9),
|
||||
(re.compile(r"/pypi/[\w-]+/json"), "pypi_mirror", "PyPI mirror (JSON API)", 0.9),
|
||||
(re.compile(r"/-/npm/v1/"), "npm_registry", "npm registry (v1 API)", 0.9),
|
||||
(re.compile(r"/@[\w-]+/[\w-]+/-/"), "npm_registry", "npm scoped package download", 0.85),
|
||||
(re.compile(r"/repository/maven-"), "maven_repo", "Maven/Nexus repository", 0.85),
|
||||
(re.compile(r"/artifactory/"), "artifactory", "JFrog Artifactory", 0.9),
|
||||
(re.compile(r"/content/repositories/"), "nexus_repo", "Sonatype Nexus repository", 0.85),
|
||||
(re.compile(r"/api/v4/projects/\d+/packages/"), "gitlab_packages", "GitLab Package Registry", 0.9),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/manifests/"), "container_registry", "Docker/OCI registry (manifest pull)", 0.95),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/blobs/"), "container_registry", "Docker/OCI registry (blob pull)", 0.95),
|
||||
(re.compile(r"/v2/_catalog"), "container_registry", "Docker registry catalog", 0.95),
|
||||
|
||||
# WSUS
|
||||
(re.compile(r"/ClientWebService/"), "wsus", "WSUS client web service", 0.9),
|
||||
(re.compile(r"/SimpleAuthWebService/"), "wsus", "WSUS auth service", 0.9),
|
||||
(re.compile(r"/ApiRemoting30/"), "wsus", "WSUS API remoting", 0.9),
|
||||
(re.compile(r"/Content/[\w-]+\.cab"), "wsus", "WSUS content download", 0.8),
|
||||
|
||||
# CI/CD
|
||||
(re.compile(r"/job/[\w-]+/build"), "jenkins", "Jenkins build trigger", 0.9),
|
||||
(re.compile(r"/api/json\?tree="), "jenkins", "Jenkins API query", 0.85),
|
||||
(re.compile(r"/queue/api/json"), "jenkins", "Jenkins queue API", 0.85),
|
||||
(re.compile(r"/api/v4/runners/"), "gitlab_ci", "GitLab CI runner API", 0.9),
|
||||
(re.compile(r"/api/v3/repos/.+/actions/"), "github_actions", "GitHub Actions API", 0.85),
|
||||
(re.compile(r"/_apis/build/"), "azure_devops", "Azure DevOps build API", 0.9),
|
||||
|
||||
# Config management
|
||||
(re.compile(r"/puppet/v3/"), "puppet", "Puppet v3 API", 0.9),
|
||||
(re.compile(r"/puppet/v4/"), "puppet", "Puppet v4 API", 0.9),
|
||||
(re.compile(r"/organizations/[\w-]+/cookbooks/"), "chef", "Chef cookbook API", 0.9),
|
||||
(re.compile(r"/nodes/[\w.-]+"), "chef", "Chef node API", 0.8),
|
||||
|
||||
# Internal CA
|
||||
(re.compile(r"/certsrv/"), "internal_ca", "Microsoft AD CS certificate services", 0.9),
|
||||
(re.compile(r"/acme/directory"), "internal_ca", "ACME CA directory (internal Let's Encrypt/step-ca)", 0.8),
|
||||
(re.compile(r"/v1/pki/"), "vault_pki", "HashiCorp Vault PKI engine", 0.9),
|
||||
|
||||
# SCCM / MECM
|
||||
(re.compile(r"/SMS_MP/"), "sccm", "SCCM Management Point", 0.9),
|
||||
(re.compile(r"/CCM_Client/"), "sccm", "SCCM client endpoint", 0.9),
|
||||
(re.compile(r"/CMGateway/"), "sccm", "SCCM Cloud Management Gateway", 0.85),
|
||||
]
|
||||
|
||||
# DNS patterns -> (infra_type, description, confidence)
|
||||
_DNS_PATTERNS = [
|
||||
(re.compile(r"(pypi|pip)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"pypi_mirror", "Internal PyPI mirror hostname", 0.85),
|
||||
(re.compile(r"(npm|registry)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"npm_registry", "Internal npm registry hostname", 0.85),
|
||||
(re.compile(r"(nexus|artifactory|repo)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"artifact_repo", "Internal artifact repository", 0.8),
|
||||
(re.compile(r"(docker|registry|harbor|quay)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"container_registry", "Internal container registry", 0.85),
|
||||
(re.compile(r"(wsus|update)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"wsus", "WSUS/update server", 0.8),
|
||||
(re.compile(r"(jenkins|ci|build|drone|bamboo|teamcity|concourse)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ci_cd", "CI/CD server", 0.8),
|
||||
(re.compile(r"(gitlab)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"gitlab", "GitLab instance", 0.9),
|
||||
(re.compile(r"(puppet|puppetdb|foreman)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"puppet", "Puppet infrastructure", 0.85),
|
||||
(re.compile(r"(chef|supermarket)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"chef", "Chef infrastructure", 0.85),
|
||||
(re.compile(r"(ansible|tower|awx)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ansible", "Ansible Tower/AWX", 0.85),
|
||||
(re.compile(r"(sccm|mecm|configmgr)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"sccm", "SCCM/MECM infrastructure", 0.9),
|
||||
(re.compile(r"(ca|pki|cert|issuing)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"internal_ca", "Internal CA/PKI", 0.7),
|
||||
(re.compile(r"(vault)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"hashicorp_vault", "HashiCorp Vault", 0.8),
|
||||
]
|
||||
|
||||
# Port patterns -> (infra_type, description, confidence)
|
||||
_PORT_PATTERNS = {
|
||||
8140: ("puppet", "Puppet master default port", 0.7),
|
||||
8443: ("ci_cd", "Common CI/CD HTTPS port", 0.3), # low confidence — many things use 8443
|
||||
8080: ("jenkins", "Jenkins default HTTP port", 0.3),
|
||||
50000: ("jenkins", "Jenkins agent JNLP port", 0.7),
|
||||
5000: ("container_registry", "Docker registry default port", 0.5),
|
||||
443: ("generic_https", "HTTPS", 0.1), # too common for high confidence alone
|
||||
8140: ("puppet", "Puppet master", 0.7),
|
||||
4433: ("chef", "Chef Automate", 0.6),
|
||||
}
|
||||
|
||||
|
||||
class SupplyChainDetect(BaseModule):
|
||||
"""Passively detect internal supply chain infrastructure."""
|
||||
|
||||
name = "supply_chain_detect"
|
||||
module_type = "intel"
|
||||
priority = 200
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._detection_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.bigbrother"))
|
||||
self._db_path = os.path.join(base_dir, "supply_chain.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# Subscribe to host discovery for port-based detection
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic scan of accumulated DNS and HTTP data
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._periodic_scan, daemon=True,
|
||||
name="bb-supply-chain-scan",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("SupplyChainDetect started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("SupplyChainDetect stopped — %d detections", self._detection_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
det_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM supply_chain"
|
||||
).fetchone()
|
||||
det_count = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"detections": det_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_http_request(self, target_ip: str, target_port: int,
|
||||
method: str, path: str, host_header: str = "",
|
||||
user_agent: str = "") -> Optional[dict]:
|
||||
"""Analyze an HTTP request for supply chain infrastructure indicators.
|
||||
|
||||
Called by tool_output_parser or directly by HTTP monitoring modules.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _HTTP_PATH_PATTERNS:
|
||||
if pattern.search(path):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=host_header,
|
||||
target_port=target_port,
|
||||
evidence=f"{method} {path}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"host": host_header,
|
||||
"user_agent": user_agent,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_dns_query(self, query_name: str, resolved_ip: str = "",
|
||||
client_ip: str = "") -> Optional[dict]:
|
||||
"""Analyze a DNS query for supply chain infrastructure indicators.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _DNS_PATTERNS:
|
||||
if pattern.search(query_name):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=resolved_ip or "unresolved",
|
||||
target_hostname=query_name,
|
||||
target_port=None,
|
||||
evidence=f"DNS: {query_name}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"query": query_name,
|
||||
"resolved_ip": resolved_ip,
|
||||
"client_ip": client_ip,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_port(self, target_ip: str, port: int,
|
||||
hostname: str = "") -> Optional[dict]:
|
||||
"""Check if a discovered open port suggests supply chain infrastructure."""
|
||||
if port not in _PORT_PATTERNS:
|
||||
return None
|
||||
|
||||
infra_type, description, confidence = _PORT_PATTERNS[port]
|
||||
if confidence < 0.5:
|
||||
# Low-confidence port-only detections need corroboration
|
||||
# Check if we already have DNS or HTTP evidence for this IP
|
||||
existing = self._get_detections_for_ip(target_ip)
|
||||
if existing:
|
||||
# Boost confidence if corroborated
|
||||
confidence = min(confidence + 0.3, 0.95)
|
||||
else:
|
||||
return None
|
||||
|
||||
return self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
target_port=port,
|
||||
evidence=f"Open port {port}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({"description": description, "port": port}),
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check discovered host ports for supply chain indicators."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
hostname = p.get("hostname", "")
|
||||
ports = p.get("open_ports", [])
|
||||
|
||||
for port in ports:
|
||||
self.analyze_port(ip, port, hostname)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_detection(self, infra_type: str, target_ip: str,
|
||||
target_hostname: str, target_port: Optional[int],
|
||||
evidence: str, confidence: float,
|
||||
details: str = "") -> dict:
|
||||
"""Record a supply chain detection, deduplicating by type+ip+port."""
|
||||
now = time.time()
|
||||
detection = {
|
||||
"type": infra_type,
|
||||
"target_ip": target_ip,
|
||||
"target_hostname": target_hostname,
|
||||
"target_port": target_port,
|
||||
"evidence": evidence,
|
||||
"confidence": confidence,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO supply_chain
|
||||
(type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, first_seen, last_seen, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(type, target_ip, target_port) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
confidence = MAX(supply_chain.confidence, excluded.confidence),
|
||||
evidence = supply_chain.evidence || '; ' || excluded.evidence,
|
||||
target_hostname = CASE WHEN excluded.target_hostname != ''
|
||||
THEN excluded.target_hostname
|
||||
ELSE supply_chain.target_hostname END
|
||||
""",
|
||||
(infra_type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, now, now, details),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._detection_count += 1
|
||||
|
||||
logger.info(
|
||||
"Supply chain detection: %s at %s:%s (%s, confidence=%.2f)",
|
||||
infra_type, target_ip, target_port, evidence, confidence,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to record supply chain detection")
|
||||
|
||||
return detection
|
||||
|
||||
def _get_detections_for_ip(self, ip: str) -> list:
|
||||
"""Get all detections for a given IP."""
|
||||
with self._lock:
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM supply_chain WHERE target_ip = ?", (ip,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_scan(self) -> None:
|
||||
"""Periodically pull DNS and HTTP data from other modules' state."""
|
||||
interval = self.config.get("supply_chain_scan_interval", 180)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._scan_dns_data()
|
||||
self._scan_http_data()
|
||||
except Exception:
|
||||
logger.exception("Periodic supply chain scan failed")
|
||||
|
||||
def _scan_dns_data(self) -> None:
|
||||
"""Pull recent DNS queries from dns_logger state and analyze."""
|
||||
last_ts = self.state.get(self.name, "dns_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
max_ts = last_ts
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_dns_query(
|
||||
query_name=q.get("query_name", ""),
|
||||
resolved_ip=q.get("resolved_ip", ""),
|
||||
client_ip=q.get("client_ip", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _scan_http_data(self) -> None:
|
||||
"""Pull recent HTTP log data from state and analyze."""
|
||||
last_ts = self.state.get(self.name, "http_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
http_json = self.state.get("http_monitor", "recent_requests")
|
||||
if not http_json:
|
||||
return
|
||||
|
||||
try:
|
||||
requests = json.loads(http_json)
|
||||
max_ts = last_ts
|
||||
for req in requests:
|
||||
ts = req.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_http_request(
|
||||
target_ip=req.get("target_ip", ""),
|
||||
target_port=req.get("target_port", 80),
|
||||
method=req.get("method", "GET"),
|
||||
path=req.get("path", ""),
|
||||
host_header=req.get("host", ""),
|
||||
user_agent=req.get("user_agent", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "http_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_detections(self, infra_type: str = None,
|
||||
min_confidence: float = 0.0) -> list:
|
||||
"""Get supply chain detections, optionally filtered."""
|
||||
with self._lock:
|
||||
if infra_type:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE type = ? AND confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(infra_type, min_confidence),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(min_confidence,),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return a summary of detected supply chain infrastructure."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT type, COUNT(*) as count,
|
||||
AVG(confidence) as avg_confidence
|
||||
FROM supply_chain
|
||||
GROUP BY type
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_detections": sum(r[1] for r in rows),
|
||||
"by_type": {r[0]: {"count": r[1], "avg_confidence": round(r[2], 2)}
|
||||
for r in rows},
|
||||
}
|
||||
Reference in New Issue
Block a user