Refactor Trivy integration: update report generation scripts, enhance email dispatch logic, and remove obsolete test file

This commit is contained in:
2025-09-22 19:23:45 +02:00
parent 1e8526f442
commit 4d00986273
5 changed files with 53 additions and 394 deletions
+1
View File
@@ -4,4 +4,5 @@ locals {
python_version = "3.12-alpine3.21" python_version = "3.12-alpine3.21"
send_report_script = file("${path.module}/resources/send_report.py") send_report_script = file("${path.module}/resources/send_report.py")
generate_report_script = file("${path.module}/resources/generate_report.sh")
} }
+14 -35
View File
@@ -56,13 +56,14 @@ resource "kubernetes_secret" "email_credentials" {
} }
} }
resource "kubernetes_config_map" "send_report" { resource "kubernetes_config_map" "scripts" {
metadata { metadata {
name = "trivy-send-report" name = "trivy-scripts"
namespace = kubernetes_namespace.trivy.metadata[0].name namespace = kubernetes_namespace.trivy.metadata[0].name
} }
data = { data = {
"send_report.py" = local.send_report_script "send_report.py" = local.send_report_script
"generate_report.sh" = local.generate_report_script
} }
} }
@@ -99,17 +100,7 @@ resource "kubernetes_cron_job_v1" "trivy" {
container { container {
image = "aquasec/trivy:${var.tag}" image = "aquasec/trivy:${var.tag}"
name = "trivy" name = "trivy"
args = [ command = ["/bin/sh", "-c", "/scripts/generate_report.sh"]
"k8s",
"--report=${var.report_type}",
"--exit-code=0",
"--severity=${var.levels}",
"--cache-dir=/cache",
"-o", "/reports/trivy.json",
"--format", "json",
"--disable-node-collector",
"--parallel", "1"
]
security_context { security_context {
run_as_non_root = true run_as_non_root = true
run_as_user = 1000 run_as_user = 1000
@@ -117,13 +108,17 @@ resource "kubernetes_cron_job_v1" "trivy" {
allow_privilege_escalation = false allow_privilege_escalation = false
read_only_root_filesystem = true read_only_root_filesystem = true
} }
volume_mount { env {
name = "trivy-reports" name = "REPORT_TYPE"
mount_path = "/reports" value = var.report_type
}
env {
name = "LEVELS"
value = var.levels
} }
volume_mount { volume_mount {
name = "trivy-cache" name = "trivy-scripts"
mount_path = "/cache" mount_path = "/scripts"
} }
volume_mount { volume_mount {
name = "tmp" name = "tmp"
@@ -134,10 +129,6 @@ resource "kubernetes_cron_job_v1" "trivy" {
name = "send-report" name = "send-report"
image = "python:${local.python_version}" image = "python:${local.python_version}"
args = ["/scripts/send_report.py"] args = ["/scripts/send_report.py"]
env {
name = "REPORT_PATH"
value = "/reports/trivy.json"
}
env { env {
name = "SMTP_HOST" name = "SMTP_HOST"
value = var.smtp_host value = var.smtp_host
@@ -179,10 +170,6 @@ resource "kubernetes_cron_job_v1" "trivy" {
allow_privilege_escalation = false allow_privilege_escalation = false
read_only_root_filesystem = true read_only_root_filesystem = true
} }
volume_mount {
name = "trivy-reports"
mount_path = "/reports"
}
volume_mount { volume_mount {
name = "trivy-scripts" name = "trivy-scripts"
mount_path = "/scripts" mount_path = "/scripts"
@@ -192,18 +179,10 @@ resource "kubernetes_cron_job_v1" "trivy" {
mount_path = "/tmp" mount_path = "/tmp"
} }
} }
volume {
name = "trivy-reports"
empty_dir {}
}
volume {
name = "trivy-cache"
empty_dir {}
}
volume { volume {
name = "trivy-scripts" name = "trivy-scripts"
config_map { config_map {
name = kubernetes_config_map.send_report.metadata[0].name name = kubernetes_config_map.scripts.metadata[0].name
default_mode = "0755" default_mode = "0755"
} }
@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -euo pipefail
export HOME="/tmp"
TRIVY_CACHE_DIR="/tmp/cache"
mkdir -p /tmp/.trivy
mkdir -p ${TRIVY_CACHE_DIR}
trivy plugin install scan2html
trivy k8s --report=${REPORT_TYPE:-all} \
--exit-code=0 \
--severity=${LEVELS:-CRITICAL,HIGH,MEDIUM} \
-o tmp/trivy.json \
--format json \
--disable-node-collector \
--parallel ${PARALLEL:-5}
trivy scan2html generate --scan2html-flags --output tmp/report.html --from tmp/trivy.json
@@ -2,29 +2,16 @@
import os import os
import time import time
import smtplib import smtplib
import json
import uuid
import ssl
import urllib.request
import urllib.error
from email.message import EmailMessage from email.message import EmailMessage
from typing import List, Dict, Any, Optional
def dispatch_email(report_path, subject):
def dispatch_email(report_path: str, subject: str, extra_attachments: List[str] = None, body: Optional[str] = None):
"""Send an email with the Trivy report and optional extra attachments (like prompts JSONL).
Attachments are optional and will be ignored if files are missing.
"""
msg = EmailMessage() msg = EmailMessage()
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = os.environ.get("EMAIL_FROM", "noreply@example.com") msg["From"] = os.environ.get("EMAIL_FROM", "noreply@example.com")
msg["To"] = os.environ.get("EMAIL_TO", "admin@example.com") msg["To"] = os.environ.get("EMAIL_TO", "admin@example.com")
if body: if report_path:
msg.set_content(body) msg.set_content("Trivy scan report attached.")
elif report_path and os.path.exists(report_path):
msg.set_content("Trivy scan report attached. Prompts file may also be attached if configured.")
# Attach the report file # Attach the report file
with open(report_path, "rb") as f: with open(report_path, "rb") as f:
@@ -34,32 +21,16 @@ def dispatch_email(report_path: str, subject: str, extra_attachments: List[str]
else: else:
msg.set_content("Trivy scan report not found. Check logs for details.") msg.set_content("Trivy scan report not found. Check logs for details.")
# Attach any extra files (prompts etc)
if extra_attachments:
for path in extra_attachments:
try:
if not path or not os.path.exists(path):
continue
with open(path, "rb") as f:
data = f.read()
name = os.path.basename(path)
# Use generic octet-stream for unknown types
msg.add_attachment(data, maintype="application", subtype="octet-stream", filename=name)
except Exception as e:
print(f"Warning: failed to attach {path}: {e}")
smtp_host = os.environ.get("SMTP_HOST", "smtp.example.com") smtp_host = os.environ.get("SMTP_HOST", "smtp.example.com")
smtp_port = int(os.environ.get("SMTP_PORT", "587")) smtp_port = int(os.environ.get("SMTP_PORT", "587"))
smtp_user = os.environ.get("SMTP_USER", "user") smtp_user = os.environ.get("SMTP_USER", "user")
smtp_pass = os.environ.get("SMTP_PASS", "pass") smtp_pass = os.environ.get("SMTP_PASS", "pass")
# Use context manager but create appropriate SMTP class depending on port with smtplib.SMTP(smtp_host, smtp_port) as server:
try:
if smtp_port == 465: if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_host, smtp_port) server = smtplib.SMTP_SSL(smtp_host, smtp_port)
else: else:
server = smtplib.SMTP(smtp_host, smtp_port) server = smtplib.SMTP(smtp_host, smtp_port)
with server:
if smtp_port != 465: if smtp_port != 465:
server.starttls() server.starttls()
try: try:
@@ -69,273 +40,16 @@ def dispatch_email(report_path: str, subject: str, extra_attachments: List[str]
return return
server.send_message(msg) server.send_message(msg)
print("Report sent.") print("Report sent.")
except Exception as e:
print(f"Failed to send email: {e}")
def load_trivy_report(path: str) -> Dict[str, Any]:
with open(path, "r") as f:
return json.load(f)
def make_prompt_for_findings(resource_id: str, findings: List[Dict[str, Any]]) -> str:
"""Create an LLM-ready prompt for a list of findings for a single resource.
The prompt requests a concise summary, prioritized remediation steps, and suggested changes.
"""
lines = [f"Trivy scan findings for resource: {resource_id}", "\nPlease analyze the following findings:"]
for i, fnd in enumerate(findings, 1):
title = fnd.get("Title") or fnd.get("PkgName") or fnd.get("VulnerabilityID") or fnd.get("Type")
sev = fnd.get("Severity") or fnd.get("SeveritySource") or "UNKNOWN"
desc = fnd.get("Description") or fnd.get("Message") or ""
extra = []
if "VulnerabilityID" in fnd:
extra.append(f"id={fnd.get('VulnerabilityID')}")
if "InstalledVersion" in fnd:
extra.append(f"installed={fnd.get('InstalledVersion')}")
if "FixedVersion" in fnd:
extra.append(f"fixed={fnd.get('FixedVersion')}")
lines.append(f"{i}. {title} (severity={sev}) {' '.join(extra)}")
if desc:
# keep description short
lines.append(" " + (desc.strip().split('\n')[0]) )
lines.append("\nPlease output a JSON object with keys: summary, prioritized_remediation (list), suggested_changes (concrete steps or code/manifest snippets), references (list). Keep answers concise.")
return "\n".join(lines)
def generate_llm_prompts(report_path: str, output_path: str, individual_severities: List[str], batch_size: int = 10) -> List[str]:
"""Parse Trivy JSON and generate a JSONL file with prompts.
Returns list of generated prompt file paths (currently only one file).
"""
report = load_trivy_report(report_path)
resources = report.get("Resources", []) if isinstance(report, dict) else []
prompts = []
# Collect lower severity findings grouped per resource
grouped_by_resource: Dict[str, List[Dict[str, Any]]] = {}
for res in resources:
kind = res.get("Kind", "")
name = res.get("Name", "")
resource_id = f"{kind}/{name}" if name else kind
for result in res.get("Results", []) or []:
# Trivy stores vulnerabilities under 'Vulnerabilities' and misconfigs under 'Misconfigurations' etc.
for k in ("Vulnerabilities", "Misconfigurations", "Secrets", "MisconfigurationResults"):
for item in result.get(k, []) if isinstance(result.get(k, []), list) else []:
sev = (item.get("Severity") or item.get("SeveritySource") or "UNKNOWN").upper()
# If severity in the individual list, make a dedicated prompt
if sev in individual_severities:
prompt_text = make_prompt_for_findings(resource_id, [item])
prompts.append({
"id": str(uuid.uuid4()),
"resource": resource_id,
"severity": sev,
"prompt": prompt_text,
"metadata": item,
})
else:
grouped_by_resource.setdefault(resource_id, []).append(item)
# Now batch grouped findings per resource into chunks of batch_size
for resource_id, items in grouped_by_resource.items():
for i in range(0, len(items), batch_size):
chunk = items[i : i + batch_size]
# pick highest severity in chunk for metadata
highest = "UNKNOWN"
for it in chunk:
s = (it.get("Severity") or it.get("SeveritySource") or "").upper()
if s == "CRITICAL":
highest = "CRITICAL"
break
if s == "HIGH":
highest = "HIGH"
prompt_text = make_prompt_for_findings(resource_id, chunk)
prompts.append({
"id": str(uuid.uuid4()),
"resource": resource_id,
"severity": highest,
"prompt": prompt_text,
"metadata": {"count": len(chunk)},
})
# Write JSONL file
try:
with open(output_path, "w") as out:
for p in prompts:
out.write(json.dumps(p) + "\n")
except Exception as e:
print(f"Failed to write prompts file: {e}")
return []
return [output_path]
def wake_ollama() -> bool:
"""Trigger model loading by making a lightweight generate call to Ollama.
We send a tiny prompt asking the model to respond briefly. If a response (or non-error) is received, we consider the model awake.
"""
wake_timeout = int(os.environ.get("OLLAMA_WAKE_TIMEOUT", "5"))
wake_retries = int(os.environ.get("OLLAMA_WAKE_RETRIES", "6"))
base_url = os.environ.get("OLLAMA_BASE_URL", "http://ollama.reverse-proxy.svc.cluster.local:11434")
last_err = None
for attempt in range(1, wake_retries + 1):
try:
req = urllib.request.Request(base_url, method="GET")
with urllib.request.urlopen(req, timeout=wake_timeout) as resp:
body = resp.read().decode(errors="ignore")
if body != "Ollama is running":
print(f"Wake attempt {attempt} unexpected response body: {body}")
else:
print(f"Ollama is running (attempt {attempt})")
return True
except urllib.error.HTTPError as e:
last_err = f"HTTPError {e.code}: {e.reason}"
print(f"Ollama wake HTTPError: {e}")
sleep = min(5 * attempt, 30)
print(f"Retrying wake in {sleep}s...")
time.sleep(sleep)
print(f"Ollama wake failed after {wake_retries} attempts: last_err={last_err}")
return False
def call_ollama_generate(prompt: str) -> Dict[str, Any]:
"""Call Ollama generate endpoint. Returns parsed JSON response or {'error': str}.
The function is deliberately conservative about response parsing: if typical keys are present, it extracts the text, otherwise returns the full JSON.
"""
base_url = os.environ.get("OLLAMA_BASE_URL", "http://ollama.reverse-proxy.svc.cluster.local:11434")
model = os.environ.get("OLLAMA_MODEL", "mistral:latest")
timeout = int(os.environ.get("OLLAMA_TIMEOUT", "30"))
retries = int(os.environ.get("OLLAMA_RETRIES", "2"))
url = base_url.rstrip("/") + "/api/generate"
payload = {"model": model, "prompt": prompt, "stream": False, "raw": False}
data = json.dumps(payload).encode()
last_err = None
for attempt in range(1, retries + 1):
try:
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode(errors="ignore")
try:
j = json.loads(body)
except Exception:
return {"error": "Failed to parse JSON response:"}
try:
llm_response = j.get("response", "{}")
llm_json = json.loads(llm_response)
return llm_json
except Exception:
return {"error": "Failed to parse JSON response"}
except urllib.error.HTTPError as e:
last_err = f"HTTPError {e.code}: {e.reason}"
print(f"Ollama call HTTPError: {e}")
except Exception as e:
last_err = str(e)
print(f"Ollama call failed: {e}")
time.sleep(1 + attempt)
return {"error": last_err}
def process_prompts_with_ollama(prompts_file: str, max_prompts: Optional[int] = None) -> List[Dict[str, Any]]:
"""Read prompts JSONL and call Ollama for each prompt. Returns list of results.
Each result item contains: id, resource, severity, prompt, response (dict)
"""
# Optionally wake Ollama BEFORE processing the report (we trigger a small generate request to load the model)
max_prompts = os.environ.get("OLLAMA_MAX_PROMPTS", None)
if max_prompts is not None:
max_prompts = int(max_prompts)
results = []
if not os.path.exists(prompts_file):
print(f"Prompts file not found: {prompts_file}")
return results
with open(prompts_file, "r") as f:
for idx, line in enumerate(f):
if max_prompts is not None and idx >= max_prompts:
break
try:
p = json.loads(line)
except Exception as e:
print(f"Failed to parse prompt line {idx}: {e}")
continue
prompt_text = p.get("prompt") or json.dumps(p)
resp = call_ollama_generate(prompt_text)
results.append({
"id": p.get("id"),
"resource": p.get("resource"),
"severity": p.get("severity"),
"prompt": prompt_text,
"response": resp,
})
return results
def get_email_body_lines(responses: Dict[str, Any]) -> List[str]:
lines = ["Trivy scan report attached."]
if responses:
lines.append("\nLLM Analysis Summary:\n")
for r in responses:
res_id = r.get("resource", "unknown resource")
sev = r.get("severity", "UNKNOWN")
resp = r.get("response", {})
if "error" in resp:
lines.append(f"- {res_id} (severity={sev}): LLM Error: {resp['error']}")
continue
summary = resp.get("summary", "No summary provided.")
remediation = resp.get("prioritized_remediation", [])
suggestions = resp.get("suggested_changes", [])
references = resp.get("references", [])
lines.append(f"- {res_id} (severity={sev}):")
lines.append(f" Summary: {summary}")
if remediation:
lines.append(" Prioritized Remediation Steps:")
for step in remediation:
lines.append(f" - {step}")
if suggestions:
lines.append(" Suggested Changes:")
for change in suggestions:
lines.append(f" - {change}")
if references:
lines.append(" References:")
for ref in references:
lines.append(f" - {ref}")
lines.append("") # Blank line between entries
else:
lines.append("\nNo LLM analysis was performed or no responses received.")
return lines
if __name__ == "__main__": if __name__ == "__main__":
report_path = os.environ.get("REPORT_PATH", "/tmp/report.html")
report_file = os.environ.get("REPORT_PATH", None)
wait_timeout = int(os.environ.get("WAIT_TIMEOUT", "200")) wait_timeout = int(os.environ.get("WAIT_TIMEOUT", "200"))
sleep_interval = int(os.environ.get("SLEEP_INTERVAL", "5") ) sleep_interval = int(os.environ.get("SLEEP_INTERVAL", "5") )
if not report_file: print("Trivy report path:", report_path)
print("REPORT_PATH environment variable is not set.")
exit(1)
# Do a quick wake; best-effort - if it fails we'll still send report but LLM processing will also be skipped
woke = False
try:
woke = wake_ollama()
except Exception as e:
print(f"Ollama wake failed: {e}")
woke = False
print("Trivy report path:", report_file)
print("Starting to wait for the report to be generated...") print("Starting to wait for the report to be generated...")
start = time.time() start = time.time()
while not os.path.exists(report_file): while not os.path.exists(report_path):
if time.time() - start > wait_timeout: if time.time() - start > wait_timeout:
print("Timeout waiting for report to be generated.") print("Timeout waiting for report to be generated.")
dispatch_email(None, "Trivy Scan Report - Timeout") dispatch_email(None, "Trivy Scan Report - Timeout")
@@ -343,41 +57,7 @@ if __name__ == "__main__":
print("Still waiting for report to be generated...") print("Still waiting for report to be generated...")
time.sleep(sleep_interval) time.sleep(sleep_interval)
print("Report generated, proceeding to process and send email...") print("Report generated, proceeding to send email...")
time.sleep(5) # Wait for a bit to ensure the file is fully written time.sleep(5) # Wait for a bit to ensure the file is fully written
# Prepare prompts dispatch_email(report_path, "Trivy Scan Report - " + time.strftime("%Y-%m-%d %H:%M:%S"))
prompts_output = os.environ.get("PROMPTS_OUTPUT", os.path.join(os.path.dirname(report_file), "trivy_prompts.jsonl"))
indiv = os.environ.get("PROMPT_INDIVIDUAL_SEVERITIES", "CRITICAL,HIGH")
individual_severities = [s.strip().upper() for s in indiv.split(",") if s.strip()]
batch_size = int(os.environ.get("PROMPT_BATCH_SIZE", "10"))
generated = []
try:
generated = generate_llm_prompts(report_file, prompts_output, individual_severities, batch_size)
print(f"Generated prompts file(s): {generated}")
except Exception as e:
print(f"Failed to generate prompts: {e}")
attach_prompts = os.environ.get("ATTACH_PROMPTS", "true").lower() in ("1", "true", "yes")
extras = generated if attach_prompts else []
# Process prompts through Ollama if token present and wake succeeded
responses = []
if woke and generated:
try:
responses = process_prompts_with_ollama(generated[0])
print(f"Obtained {len(responses)} LLM responses")
except Exception as e:
print(f"Failed to process prompts with Ollama: {e}")
responses = []
else:
if not woke:
print("Ollama not awake, skipping LLM processing")
# Aggregate responses into an email body (concise)
body_text = "\n".join(get_email_body_lines(responses))
dispatch_email(report_file, "Trivy Scan Report - " + time.strftime("%Y-%m-%d %H:%M:%S"), extra_attachments=extras, body=body_text)
@@ -1,16 +0,0 @@
import os
from send_report import generate_llm_prompts, wake_ollama, process_prompts_with_ollama, get_email_body_lines
PATH = os.path.dirname(os.path.abspath(__file__))
os.environ["OLLAMA_MAX_PROMPTS"] = "20"
os.environ["OLLAMA_BASE_URL"] = "http://localhost:11434"
os.environ["OLLAMA_MODEL"] = "mistral:latest"
prompts = generate_llm_prompts(f"{PATH}/fixtures/trivy.json","/tmp/trivy_prompts.jsonl",["CRITICAL","HIGH"],10)
print(prompts)
wake_response = wake_ollama()
print(wake_response)
responses = process_prompts_with_ollama("/tmp/trivy_prompts.jsonl")
email_body_lines = get_email_body_lines(responses)
print("\n".join(email_body_lines))