soc-collector: run-anywhere scanner that pushes reports to SOC Center
Build and push soc-collector / build (push) Successful in 31s
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:
@@ -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")
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user