ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
492 lines
20 KiB
Python
492 lines
20 KiB
Python
#!/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(__name__)
|
|
|
|
_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("~/.implant"))
|
|
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="sensor-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},
|
|
}
|