From 609a3debe00642ac92aefc25e7fdab5ff6f0b2c0 Mon Sep 17 00:00:00 2001 From: Farrel Augusta Dinata Date: Thu, 22 Oct 2026 17:03:45 +0700 Subject: [PATCH] ci: add runtime security audit and github dependabot --- .github/dependabot.yml | 20 + .github/workflows/security-audit.yml | 18 + scripts/actions/security/check_runtimes.py | 365 ++++++++++++++++++ scripts/actions/security/manage_issues.py | 53 +++ .../security/tests/test_check_runtimes.py | 153 ++++++++ .../security/tests/test_manage_issues.py | 29 ++ 6 files changed, 638 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 scripts/actions/security/check_runtimes.py create mode 100644 scripts/actions/security/tests/test_check_runtimes.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..84ee8be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: 'docker' + directory: '/server' + schedule: + interval: 'weekly' + commit-message: + prefix: 'chore(docker)' + labels: + - 'security' + - 'dependencies' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + commit-message: + prefix: 'ci(actions)' + labels: + - 'ci' diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index eb895f7..fedb66b 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -229,6 +229,22 @@ jobs: pnpm audit --json --ignore-unfixable > pnpm-audit-after.json 2>/dev/null || true poetry --directory ./ml run pip-audit --format json --skip-editable > ml-audit-after.json 2>/dev/null || true + - name: Audit Language Runtimes (Node.js & Python Lifecycle) + id: runtime_audit + continue-on-error: true + run: | + python3 scripts/actions/security/check_runtimes.py --output runtime-audit.json + + - name: Scan Server Dockerfile with Trivy + uses: aquasecurity/trivy-action@0.36.0 + continue-on-error: true + with: + scan-type: 'config' + scan-ref: 'server/Dockerfile' + format: 'table' + exit-code: '0' + severity: 'CRITICAL,HIGH' + - name: Synchronize GitHub Security Issues id: sync_issues if: (inputs.track_issues != false && inputs.track_issues != 'false') @@ -241,6 +257,7 @@ jobs: --node-audit pnpm-audit-after.json \ --python-audit ml-audit-after.json \ --slither-report blockchain/slither-report.txt \ + --runtime-audit runtime-audit.json \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" @@ -255,6 +272,7 @@ jobs: ml/ml-audit-report.txt ml-audit-after.json blockchain/slither-report.txt + runtime-audit.json retention-days: 7 - name: Forward Security Notifications (Discord & Telegram) diff --git a/scripts/actions/security/check_runtimes.py b/scripts/actions/security/check_runtimes.py new file mode 100644 index 0000000..24f4fd1 --- /dev/null +++ b/scripts/actions/security/check_runtimes.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request +from datetime import date +from pathlib import Path +from typing import Any + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + +ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent + + +def parse_semver(version_str: str) -> tuple[int, ...]: + """Parses a version string like '24.15.0' or '3.13' into a tuple of ints.""" + clean = re.sub(r"^[v^~>=<]+", "", version_str.strip()) + parts: list[int] = [] + for part in clean.split("."): + digits = re.match(r"^\d+", part) + if digits: + parts.append(int(digits.group(0))) + else: + break + return tuple(parts) + + +def get_current_node_version(root_dir: Path = ROOT_DIR) -> str: + """Reads current pinned Node.js version from .nvmrc or package.json.""" + nvmrc = root_dir / ".nvmrc" + if nvmrc.exists(): + val = nvmrc.read_text(encoding="utf-8").strip() + if val: + return val + + pkg_json = root_dir / "package.json" + if pkg_json.exists(): + try: + data = json.loads(pkg_json.read_text(encoding="utf-8")) + engines = data.get("engines", {}) + if "node" in engines: + return str(engines["node"]).strip() + except Exception: + pass + + return "24.15.0" + + +def get_current_python_version(root_dir: Path = ROOT_DIR) -> str: + """Reads current target Python version from ml/pyproject.toml or default.""" + pyproject = root_dir / "ml" / "pyproject.toml" + if pyproject.exists(): + try: + content = pyproject.read_text(encoding="utf-8") + match = re.search(r'python_version\s*=\s*"([^"]+)"', content) + if match: + return match.group(1).strip() + except Exception: + pass + + return "3.13" + + +def fetch_eol_data(product: str) -> list[dict[str, Any]]: + """Fetches lifecycle data for a runtime from endoflife.date API.""" + url = f"https://endoflife.date/api/{product}.json" + req = urllib.request.Request( + url, + headers={"User-Agent": "RekaKarbon-Runtime-Sentinel/1.0"}, + ) + + ctx = None + try: + import ssl + + import certifi + + ctx = ssl.create_default_context(cafile=certifi.where()) + except Exception: + try: + import ssl + + ctx = ssl._create_unverified_context() + except Exception: + ctx = None + + try: + kwargs: dict[str, Any] = {"timeout": 10} + if ctx is not None: + kwargs["context"] = ctx + with urllib.request.urlopen(req, **kwargs) as resp: + data = json.loads(resp.read().decode("utf-8")) + if isinstance(data, list): + return data + except Exception as e: + sys.stderr.write(f"Warning: Failed to fetch EOL data for {product}: {e}\n") + return [] + + +def audit_node_runtime( + current_version: str, eol_data: list[dict[str, Any]] | None = None +) -> dict[str, Any] | None: + """Audits current Node.js version against lifecycle and patch availability.""" + if eol_data is None: + eol_data = fetch_eol_data("nodejs") + if not eol_data: + return None + + parsed_cur = parse_semver(current_version) + if not parsed_cur: + return None + cycle = str(parsed_cur[0]) + + cycle_info = next((item for item in eol_data if str(item.get("cycle")) == cycle), None) + if not cycle_info: + return None + + latest_str = str(cycle_info.get("latest", "")) + eol_str = str(cycle_info.get("eol", "")) + today = date.today().isoformat() + + is_eol = bool(eol_str and eol_str <= today) + parsed_latest = parse_semver(latest_str) if latest_str else () + + needs_update = bool(parsed_latest and parsed_cur < parsed_latest) + + if not is_eol and not needs_update: + return None + + severity = "HIGH" if is_eol else "MODERATE" + description = ( + f"Node.js {current_version} is End-of-Life as of {eol_str}." + if is_eol + else f"Node.js {current_version} is behind the latest security patch {latest_str} in cycle {cycle}." + ) + + return { + "ecosystem": "Runtime (Node.js)", + "component": "Node.js", + "installed_version": current_version, + "latest_version": latest_str, + "cycle": cycle, + "eol": eol_str, + "is_eol": is_eol, + "severity": severity, + "title": description, + "advisory_id": f"RUNTIME-NODE-{cycle}-EOL" if is_eol else f"RUNTIME-NODE-{cycle}-PATCH", + "recommendation": f"Upgrade Node.js to {latest_str} using 'python scripts/actions/security/check_runtimes.py --sync-node {latest_str}'", + } + + +def audit_python_runtime( + current_version: str, eol_data: list[dict[str, Any]] | None = None +) -> dict[str, Any] | None: + """Audits current Python version against lifecycle and patch availability.""" + if eol_data is None: + eol_data = fetch_eol_data("python") + if not eol_data: + return None + + parsed_cur = parse_semver(current_version) + if not parsed_cur: + return None + + cycle = f"{parsed_cur[0]}.{parsed_cur[1]}" if len(parsed_cur) >= 2 else str(parsed_cur[0]) + + cycle_info = next((item for item in eol_data if str(item.get("cycle")) == cycle), None) + if not cycle_info: + return None + + latest_str = str(cycle_info.get("latest", "")) + eol_str = str(cycle_info.get("eol", "")) + today = date.today().isoformat() + + is_eol = bool(eol_str and eol_str <= today) + parsed_latest = parse_semver(latest_str) if latest_str else () + + needs_update = bool(len(parsed_cur) >= 3 and parsed_latest and parsed_cur < parsed_latest) + + if not is_eol and not needs_update: + return None + + severity = "HIGH" if is_eol else "LOW" + description = ( + f"Python {current_version} is End-of-Life as of {eol_str}." + if is_eol + else f"Python {current_version} has patch update {latest_str} available in cycle {cycle}." + ) + + return { + "ecosystem": "Runtime (Python)", + "component": "Python", + "installed_version": current_version, + "latest_version": latest_str, + "cycle": cycle, + "eol": eol_str, + "is_eol": is_eol, + "severity": severity, + "title": description, + "advisory_id": f"RUNTIME-PY-{cycle.replace('.', '')}-EOL" + if is_eol + else f"RUNTIME-PY-{cycle.replace('.', '')}-PATCH", + "recommendation": f"Upgrade Python to {latest_str} or cycle {cycle}.", + } + + +def sync_node_version(new_version: str, root_dir: Path = ROOT_DIR) -> list[str]: + """Atomically synchronizes a new Node.js version across all monorepo files.""" + modified_files: list[str] = [] + + # 1. .nvmrc + nvmrc = root_dir / ".nvmrc" + if nvmrc.exists(): + nvmrc.write_text(f"{new_version}\n", encoding="utf-8") + modified_files.append(".nvmrc") + + # 2. package.json + pkg_json = root_dir / "package.json" + if pkg_json.exists(): + content = pkg_json.read_text(encoding="utf-8") + updated = re.sub(r'("node":\s*")[^"]+(")', rf"\g<1>{new_version}\g<2>", content) + if updated != content: + pkg_json.write_text(updated, encoding="utf-8") + modified_files.append("package.json") + + # 3. server/Dockerfile + dockerfile = root_dir / "server" / "Dockerfile" + if dockerfile.exists(): + content = dockerfile.read_text(encoding="utf-8") + updated = re.sub( + r"FROM node:[^\s-]+-alpine AS base", + f"FROM node:{new_version}-alpine AS base", + content, + ) + if updated != content: + dockerfile.write_text(updated, encoding="utf-8") + modified_files.append("server/Dockerfile") + + # 4. Workflows (.github/workflows/*.yml) + workflows_dir = root_dir / ".github" / "workflows" + if workflows_dir.exists(): + for wf in workflows_dir.glob("*.yml"): + content = wf.read_text(encoding="utf-8") + updated = re.sub(r"node-version:\s*[\d.]+", f"node-version: {new_version}", content) + if updated != content: + wf.write_text(updated, encoding="utf-8") + modified_files.append(f".github/workflows/{wf.name}") + + return modified_files + + +def sync_python_version(new_version: str, root_dir: Path = ROOT_DIR) -> list[str]: + """Atomically synchronizes a new Python version across all monorepo files.""" + modified_files: list[str] = [] + cycle = ".".join(new_version.split(".")[:2]) if "." in new_version else new_version # e.g. 3.13 + ruff_target = f"py{cycle.replace('.', '')}" # e.g. py313 + + # 1. ml/pyproject.toml + pyproject = root_dir / "ml" / "pyproject.toml" + if pyproject.exists(): + content = pyproject.read_text(encoding="utf-8") + updated = re.sub( + r'target-version\s*=\s*"[^"]+"', + f'target-version = "{ruff_target}"', + content, + ) + updated = re.sub( + r'python_version\s*=\s*"[^"]+"', + f'python_version = "{cycle}"', + updated, + ) + if updated != content: + pyproject.write_text(updated, encoding="utf-8") + modified_files.append("ml/pyproject.toml") + + # 2. Workflows (.github/workflows/*.yml) + workflows_dir = root_dir / ".github" / "workflows" + if workflows_dir.exists(): + for wf in workflows_dir.glob("*.yml"): + content = wf.read_text(encoding="utf-8") + updated = re.sub( + r"python-version:\s*['\"][^'\"]+['\"]", + f"python-version: '{cycle}'", + content, + ) + if updated != content: + wf.write_text(updated, encoding="utf-8") + modified_files.append(f".github/workflows/{wf.name}") + + return modified_files + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Audit and synchronize monorepo runtime and language versions." + ) + parser.add_argument( + "--output", + "-o", + type=Path, + help="Optional path to output structured JSON findings for CI automation.", + ) + parser.add_argument( + "--sync-node", + type=str, + help="Synchronize Node.js version across .nvmrc, package.json, Dockerfile, and workflows.", + ) + parser.add_argument( + "--sync-python", + type=str, + help="Synchronize Python version across pyproject.toml and workflows.", + ) + args = parser.parse_args() + + if args.sync_node: + updated = sync_node_version(args.sync_node) + print(f"Synchronized Node.js to {args.sync_node} in {len(updated)} files:") + for path in updated: + print(f" - {path}") + return 0 + + if args.sync_python: + updated = sync_python_version(args.sync_python) + print(f"Synchronized Python to {args.sync_python} in {len(updated)} files:") + for path in updated: + print(f" - {path}") + return 0 + + node_cur = get_current_node_version() + python_cur = get_current_python_version() + + print(f"Checking runtimes (Node.js: {node_cur}, Python: {python_cur})...") + + findings: list[dict[str, Any]] = [] + node_finding = audit_node_runtime(node_cur) + if node_finding: + findings.append(node_finding) + print(f"⚠️ [Node.js] {node_finding['title']}") + else: + print(f"✅ [Node.js] Version {node_cur} is up to date.") + + python_finding = audit_python_runtime(python_cur) + if python_finding: + findings.append(python_finding) + print(f"⚠️ [Python] {python_finding['title']}") + else: + print(f"✅ [Python] Version {python_cur} is up to date.") + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump({"findings": findings}, f, indent=2) + print(f"Audit report saved to {args.output}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/actions/security/manage_issues.py b/scripts/actions/security/manage_issues.py index a94d7c5..42fef4c 100644 --- a/scripts/actions/security/manage_issues.py +++ b/scripts/actions/security/manage_issues.py @@ -209,6 +209,47 @@ def parse_pip_audit_json(data: dict[str, Any]) -> list[VulnerabilityAdvisory]: return advisories +def parse_runtime_audit_json(data: dict[str, Any]) -> list[VulnerabilityAdvisory]: + """Extracts advisories from runtime sentinel audit JSON format.""" + advisories: list[VulnerabilityAdvisory] = [] + findings = data.get("findings", []) + for f in findings: + eco = f.get("ecosystem", "Runtime") + comp = f.get("component", "Runtime") + adv_id = f.get("advisory_id", "RUNTIME-ALERT") + installed = f.get("installed_version", "Unknown") + latest = f.get("latest_version", "Unknown") + cycle = f.get("cycle", "") + eol = f.get("eol", "") + is_eol = f.get("is_eol", False) + sev = f.get("severity", "MODERATE") + title = f.get("title", f"{comp} update available") + rec = f.get("recommendation", "") + + desc = ( + f"**Runtime**: `{comp}`\n\n" + f"- **Installed Version**: `{installed}`\n" + f"- **Latest Patch**: `{latest}` (Cycle `{cycle}`)\n" + f"- **End-of-Life Date**: `{eol}` (Is EOL: `{is_eol}`)\n\n" + f"### Recommended Action\n{rec}\n" + ) + + advisories.append( + VulnerabilityAdvisory( + ecosystem=eco, + package_name=comp.lower(), + advisory_id=adv_id, + severity=sev, + title=title, + vulnerable_versions=f"Installed: {installed} (Latest: {latest})", + url=f"https://endoflife.date/{comp.lower().replace('.', '')}", + description=desc, + dependency_chain=[f"Repository runtime configuration ({comp})"], + ) + ) + return advisories + + def parse_slither_text(text: str) -> list[VulnerabilityAdvisory]: """Extracts severe findings from Slither analysis report.""" advisories: list[VulnerabilityAdvisory] = [] @@ -509,6 +550,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--node-audit", help="Path to pnpm audit JSON file") parser.add_argument("--python-audit", help="Path to pip-audit JSON file") parser.add_argument("--slither-report", help="Path to Slither report txt file") + parser.add_argument("--runtime-audit", help="Path to runtime sentinel audit JSON file") parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) parser.add_argument("--run-id", default=os.environ.get("GITHUB_RUN_ID", "")) parser.add_argument( @@ -552,6 +594,17 @@ def main(argv: list[str] | None = None) -> int: except Exception as e: print(f"Warning: Failed to parse {args.slither_report}: {e}") + # Parse Runtime findings + if args.runtime_audit and Path(args.runtime_audit).exists(): + try: + with open(args.runtime_audit, "r", encoding="utf-8") as f: + runtime_data = json.load(f) + runtime_vulns = parse_runtime_audit_json(runtime_data) + findings.extend(runtime_vulns) + print(f"Loaded {len(runtime_vulns)} advisory finding(s) from {args.runtime_audit}") + except Exception as e: + print(f"Warning: Failed to parse {args.runtime_audit}: {e}") + existing_issues = ( fetch_open_security_issues(args.repo) if args.repo and not args.dry_run else [] ) diff --git a/scripts/actions/security/tests/test_check_runtimes.py b/scripts/actions/security/tests/test_check_runtimes.py new file mode 100644 index 0000000..3d42144 --- /dev/null +++ b/scripts/actions/security/tests/test_check_runtimes.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.actions.security.check_runtimes import ( + audit_node_runtime, + audit_python_runtime, + parse_semver, + sync_node_version, + sync_python_version, +) + + +class TestCheckRuntimes(unittest.TestCase): + def test_parse_semver(self) -> None: + self.assertEqual(parse_semver("24.15.0"), (24, 15, 0)) + self.assertEqual(parse_semver("v3.13.2"), (3, 13, 2)) + self.assertEqual(parse_semver(">=3.12"), (3, 12)) + self.assertEqual(parse_semver("3.13"), (3, 13)) + + def test_audit_node_runtime_outdated(self) -> None: + mock_eol = [ + { + "cycle": "24", + "latest": "24.22.1", + "eol": "2028-04-30", + } + ] + result = audit_node_runtime("24.15.0", mock_eol) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result["installed_version"], "24.15.0") + self.assertEqual(result["latest_version"], "24.22.1") + self.assertEqual(result["severity"], "MODERATE") + self.assertIn("behind the latest security patch", result["title"]) + + def test_audit_node_runtime_up_to_date(self) -> None: + mock_eol = [ + { + "cycle": "24", + "latest": "24.22.1", + "eol": "2028-04-30", + } + ] + result = audit_node_runtime("24.22.1", mock_eol) + self.assertIsNone(result) + + def test_audit_node_runtime_eol(self) -> None: + mock_eol = [ + { + "cycle": "18", + "latest": "18.20.7", + "eol": "2024-04-30", + } + ] + result = audit_node_runtime("18.20.7", mock_eol) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result["severity"], "HIGH") + self.assertTrue(result["is_eol"]) + + def test_audit_python_runtime_up_to_date(self) -> None: + mock_eol = [ + { + "cycle": "3.13", + "latest": "3.13.17", + "eol": "2029-10-31", + } + ] + result = audit_python_runtime("3.13", mock_eol) + self.assertIsNone(result) + + def test_audit_python_runtime_outdated(self) -> None: + mock_eol = [ + { + "cycle": "3.13", + "latest": "3.13.17", + "eol": "2029-10-31", + } + ] + result = audit_python_runtime("3.13.0", mock_eol) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result["severity"], "LOW") + + def test_sync_node_version(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + (tmp_path / ".nvmrc").write_text("24.15.0\n", encoding="utf-8") + (tmp_path / "package.json").write_text( + '{\n "engines": {\n "node": "24.15.0"\n }\n}\n', encoding="utf-8" + ) + + server_dir = tmp_path / "server" + server_dir.mkdir(parents=True) + (server_dir / "Dockerfile").write_text( + "FROM node:24.15.0-alpine AS base\nWORKDIR /app\n", encoding="utf-8" + ) + + wf_dir = tmp_path / ".github" / "workflows" + wf_dir.mkdir(parents=True) + (wf_dir / "test.yml").write_text( + "steps:\n - uses: actions/setup-node@v7\n with:\n node-version: 24.15.0\n", + encoding="utf-8", + ) + + modified = sync_node_version("24.22.1", root_dir=tmp_path) + self.assertEqual(len(modified), 4) + + self.assertEqual((tmp_path / ".nvmrc").read_text(encoding="utf-8").strip(), "24.22.1") + self.assertIn( + '"node": "24.22.1"', (tmp_path / "package.json").read_text(encoding="utf-8") + ) + self.assertIn( + "FROM node:24.22.1-alpine AS base", + (server_dir / "Dockerfile").read_text(encoding="utf-8"), + ) + self.assertIn( + "node-version: 24.22.1", (wf_dir / "test.yml").read_text(encoding="utf-8") + ) + + def test_sync_python_version(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + ml_dir = tmp_path / "ml" + ml_dir.mkdir(parents=True) + (ml_dir / "pyproject.toml").write_text( + '[tool.ruff]\ntarget-version = "py313"\n\n[tool.mypy]\npython_version = "3.13"\n', + encoding="utf-8", + ) + + wf_dir = tmp_path / ".github" / "workflows" + wf_dir.mkdir(parents=True) + (wf_dir / "ci.yml").write_text( + "steps:\n - uses: actions/setup-python@v7\n with:\n python-version: '3.13'\n", + encoding="utf-8", + ) + + modified = sync_python_version("3.14", root_dir=tmp_path) + self.assertEqual(len(modified), 2) + + pyproject_content = (ml_dir / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn('target-version = "py314"', pyproject_content) + self.assertIn('python_version = "3.14"', pyproject_content) + self.assertIn("python-version: '3.14'", (wf_dir / "ci.yml").read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/actions/security/tests/test_manage_issues.py b/scripts/actions/security/tests/test_manage_issues.py index cb00867..7ba65fa 100644 --- a/scripts/actions/security/tests/test_manage_issues.py +++ b/scripts/actions/security/tests/test_manage_issues.py @@ -18,6 +18,7 @@ normalize_advisory_id, parse_pip_audit_json, parse_pnpm_audit_json, + parse_runtime_audit_json, parse_slither_text, sync_security_issues, ) @@ -207,6 +208,34 @@ def test_clean_table_cell(self): self.assertEqual(clean_table_cell("details: XSS in | component"), "XSS in \\| component") self.assertEqual(clean_table_cell(""), "N/A") + def test_parse_runtime_audit_json(self): + sample_runtime = { + "findings": [ + { + "ecosystem": "Runtime (Node.js)", + "component": "Node.js", + "installed_version": "24.15.0", + "latest_version": "24.22.1", + "cycle": "24", + "eol": "2028-04-30", + "is_eol": False, + "severity": "MODERATE", + "title": "Node.js 24.15.0 is behind the latest security patch 24.22.1 in cycle 24.", + "advisory_id": "RUNTIME-NODE-24-PATCH", + "recommendation": "Upgrade Node.js to 24.22.1", + } + ] + } + advisories = parse_runtime_audit_json(sample_runtime) + self.assertEqual(len(advisories), 1) + adv = advisories[0] + self.assertEqual(adv.ecosystem, "Runtime (Node.js)") + self.assertEqual(adv.package_name, "node.js") + self.assertEqual(adv.advisory_id, "RUNTIME-NODE-24-PATCH") + self.assertEqual(adv.severity, "MODERATE") + self.assertIn("24.15.0", adv.vulnerable_versions) + self.assertIn("Upgrade Node.js to 24.22.1", adv.description) + def test_templates_engine_render_issue_advisory(self): adv = VulnerabilityAdvisory( ecosystem="Python",