soc-collector: run-anywhere scanner that pushes reports to SOC Center
Build and push soc-collector / build (push) Successful in 31s

A single Docker image, configured entirely by env vars (SOC_URL + SOC_TOKEN +
targets). Runs once and exits; schedule with cron. Four self-skipping sources:
  - surface     nmap open-port/service scan (SOC_TARGETS)
  - cve         Trivy HIGH/CRITICAL scan of running images (Docker socket)
  - tls         cert-expiry / weak-TLS / missing-headers (SOC_DOMAINS)
  - config      0.0.0.0 binds, --privileged, rw docker-socket mounts (Docker socket)
Stable slugs per source so SOC Center versions/diffs/alerts. Pure stdlib;
image adds nmap + trivy. MIT, intended as a public Ethica repo.
This commit is contained in:
Steffen Skui
2026-06-24 18:17:39 +02:00
commit 7336b17f79
14 changed files with 560 additions and 0 deletions
View File
+35
View File
@@ -0,0 +1,35 @@
"""Tiny read-only Docker Engine API client over the unix socket (stdlib only).
Used by the cve and config-drift sources. HTTP/1.0 so the daemon sends the whole
body then closes the connection (no chunked decoding needed).
"""
import json
import socket
SOCK = "/var/run/docker.sock"
def available():
try:
s = socket.socket(socket.AF_UNIX)
s.connect(SOCK)
s.close()
return True
except OSError:
return False
def get(path):
s = socket.socket(socket.AF_UNIX)
s.settimeout(30)
s.connect(SOCK)
s.sendall(f"GET {path} HTTP/1.0\r\nHost: docker\r\n\r\n".encode())
buf = b""
while True:
chunk = s.recv(65536)
if not chunk:
break
buf += chunk
s.close()
body = buf.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in buf else b""
return json.loads(body or b"null")
+54
View File
@@ -0,0 +1,54 @@
"""Config-drift source: read-only Docker socket -> 0.0.0.0 binds, privileged, rw-socket mounts."""
from soc import finding
from . import _docker
def run():
if not _docker.available():
return None # socket not mounted -> skip
conts = _docker.get("/containers/json") or []
findings = []
for c in conts:
name = (c.get("Names") or ["?"])[0].lstrip("/")
cid = c.get("Id", "")[:12]
try:
d = _docker.get(f"/containers/{cid}/json")
except Exception:
continue
hc = d.get("HostConfig", {}) or {}
# Ports published on all interfaces (0.0.0.0 / ::) — i.e. exposed off-host.
exposed = []
for port, binds in ((d.get("NetworkSettings", {}) or {}).get("Ports") or {}).items():
for b in (binds or []):
if b.get("HostIp") in ("0.0.0.0", "::", ""):
exposed.append(f"{b.get('HostIp') or '0.0.0.0'}:{b.get('HostPort')}->{port}")
if exposed:
findings.append(finding(
f"drift-bind-{name}", "high", f"{name} publishes ports on all interfaces",
host=name, evidence="; ".join(exposed),
impact=["reachable from outside the host, bypassing the reverse proxy"],
fix=["bind to 127.0.0.1, or drop host ports and route via the proxy"]))
if hc.get("Privileged"):
findings.append(finding(
f"drift-priv-{name}", "high", f"{name} runs --privileged",
host=name, evidence="Privileged=true",
impact=["a container escape becomes full host root"],
fix=["drop --privileged; grant only the specific capabilities needed"]))
for m in (d.get("Mounts") or []):
if m.get("Source") == "/var/run/docker.sock" and m.get("RW"):
findings.append(finding(
f"drift-sock-{name}", "high", f"{name} mounts the Docker socket read-write",
host=name, evidence="/var/run/docker.sock (rw)",
impact=["the container can control the host's Docker = host root"],
fix=["mount it :ro, or put a scoped socket-proxy in front"]))
return {
"scope": f"{len(conts)} container(s)",
"findings": findings,
"good": [] if findings else ["No 0.0.0.0 binds, privileged, or rw-socket containers"],
}
+54
View File
@@ -0,0 +1,54 @@
"""Container-CVE source: Trivy-scan the images of running containers (HIGH/CRITICAL)."""
import json
import subprocess
from soc import finding
from . import _docker
SEV = {"CRITICAL": "crit", "HIGH": "high", "MEDIUM": "med", "LOW": "low"}
def _running_images():
conts = _docker.get("/containers/json") or []
seen = []
for c in conts:
img = c.get("Image", "")
if img and img not in seen:
seen.append(img)
return seen
def run():
if not _docker.available():
return None # socket not mounted -> skip
images = _running_images()
if not images:
return None
findings = []
for img in images:
try:
proc = subprocess.run(
["trivy", "image", "--quiet", "--scanners", "vuln",
"--severity", "HIGH,CRITICAL", "--format", "json", img],
capture_output=True, text=True, timeout=900)
rep = json.loads(proc.stdout or "{}")
except Exception:
continue
vulns = [v for res in (rep.get("Results") or []) for v in (res.get("Vulnerabilities") or [])]
if not vulns:
continue
worst = "crit" if any(v.get("Severity") == "CRITICAL" for v in vulns) else "high"
ids = sorted({v.get("VulnerabilityID", "") for v in vulns if v.get("VulnerabilityID")})
findings.append(finding(
f"cve-{img}", worst, f"{len(vulns)} HIGH/CRITICAL CVEs in {img}", host=img,
evidence=", ".join(ids[:10]) + ("" if len(ids) > 10 else ""),
impact=["known-exploitable vulnerabilities in a running image"],
fix=[f"pull/rebuild a patched {img.split(':')[0]} image"]))
return {
"scope": f"{len(images)} running image(s)",
"findings": findings,
"good": [] if findings else ["No HIGH/CRITICAL CVEs in running images"],
}
+60
View File
@@ -0,0 +1,60 @@
"""Attack-surface source: nmap the targets -> SOC surface[] + findings for risky open ports."""
import subprocess
import xml.etree.ElementTree as ET
from soc import finding
# Ports / service-names that usually should NOT be world-reachable -> severity.
RISKY_PORT = {
"23": "high", "2375": "crit", "2376": "high", "3389": "high", "5432": "high",
"3306": "high", "6379": "high", "27017": "high", "9200": "high", "5984": "high",
"11211": "high", "2049": "high", "445": "high", "139": "med", "135": "med",
"5900": "med", "9000": "med", "8006": "high", "61616": "high",
}
RISKY_NAME = {
"telnet": "high", "ms-wbt-server": "high", "postgresql": "high", "mysql": "high",
"redis": "high", "mongodb": "high", "vnc": "med", "docker": "crit", "memcached": "high",
"elasticsearch": "high", "rdp": "high",
}
def run(targets):
if not targets:
return None
proc = subprocess.run(
["nmap", "-Pn", "-sV", "-T4", "--open", "-oX", "-"] + targets,
capture_output=True, text=True, timeout=1800)
if not proc.stdout.strip():
raise RuntimeError(proc.stderr.strip() or "nmap produced no output")
root = ET.fromstring(proc.stdout)
surface, findings = [], []
for host in root.findall("host"):
addr = host.find("address")
ip = addr.get("addr") if addr is not None else "?"
open_ports = []
for p in host.findall("./ports/port"):
st = p.find("state")
if st is None or st.get("state") != "open":
continue
num = p.get("portid")
svc = p.find("service")
name = svc.get("name") if svc is not None else ""
open_ports.append(num)
sev = RISKY_PORT.get(num) or RISKY_NAME.get(name)
if sev:
findings.append(finding(
f"surface-{ip}-{num}", sev,
f"Exposed {name or 'service'} on port {num}", host=ip,
evidence=f"{ip}:{num} ({name or 'unknown'})",
impact=["reachable over the network"],
fix=[f"restrict port {num} to trusted networks, bind to localhost, "
"or front it with the reverse proxy"]))
surface.append({"ip": ip, "role": "", "ports": ",".join(open_ports)})
return {
"scope": "nmap " + " ".join(targets),
"surface": surface,
"findings": findings,
"good": [] if findings else ["No risky ports found open on the scanned targets"],
}
+76
View File
@@ -0,0 +1,76 @@
"""TLS & web-exposure source: cert expiry, weak TLS, missing security headers per domain."""
import datetime
import http.client
import socket
import ssl
from soc import finding
SECURITY_HEADERS = ["strict-transport-security", "content-security-policy",
"x-content-type-options", "x-frame-options"]
def _peer(host, port=443):
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=15) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ss:
return ss.getpeercert(), ss.version()
def run(domains):
if not domains:
return None
findings, good = [], []
for host in domains:
host = host.strip()
if not host:
continue
try:
cert, ver = _peer(host)
except Exception as e:
findings.append(finding(
f"tls-conn-{host}", "med", f"TLS connection to {host} failed", host=host,
evidence=str(e), impact=["the site may be down or its TLS misconfigured"],
fix=["check the service and its certificate"]))
continue
not_after = cert.get("notAfter")
try:
exp = datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
days = (exp - datetime.datetime.utcnow()).days
sev = "crit" if days < 7 else "high" if days < 21 else "med" if days < 40 else None
if sev:
findings.append(finding(
f"tls-exp-{host}", sev, f"Certificate for {host} expires in {days} days",
host=host, evidence=f"notAfter={not_after}",
impact=["an outage the moment it expires"],
fix=["renew it / verify auto-renewal (e.g. Traefik 'le' resolver)"]))
else:
good.append(f"{host}: certificate valid for {days} more days")
except (ValueError, TypeError):
pass
if ver in ("TLSv1", "TLSv1.1"):
findings.append(finding(
f"tls-weak-{host}", "med", f"{host} negotiated deprecated {ver}", host=host,
evidence=ver, impact=["deprecated, downgradeable TLS"],
fix=["disable TLS < 1.2 at the edge"]))
try:
conn = http.client.HTTPSConnection(host, timeout=15)
conn.request("HEAD", "/")
resp = conn.getresponse()
present = {k.lower() for k, _ in resp.getheaders()}
conn.close()
missing = [h for h in SECURITY_HEADERS if h not in present]
if missing:
findings.append(finding(
f"hdr-{host}", "low", f"{host} is missing {len(missing)} security header(s)",
host=host, evidence=", ".join(missing),
impact=["weaker browser-side protections"],
fix=["add at the edge/app: " + ", ".join(missing)]))
except Exception:
pass
return {"scope": ", ".join(domains), "findings": findings, "good": good}