Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion agent/tests/test_compose_host_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ def test_portainer_update_container(self):
executor._update_container({"container_name": "searxng"}, _CFG)

self.assertEqual(len(self.calls), 2)
tail = ["--project-directory", str(self.host / "compose" / "49"),
# -p keeps compose on the container's own project: without it the
# project is named after the folder ("49"), and the re-up fails with
# 'The container name "/searxng" is already in use'.
tail = ["-p", "searxng", "--project-directory", str(self.host / "compose" / "49"),
"--env-file", str(self.env), "-f", str(self.compose)]
self.assertEqual(self.calls[0], ["docker", "compose", *tail, "pull", "searxng"])
self.assertEqual(self.calls[1], ["docker", "compose", *tail,
Expand Down
4 changes: 2 additions & 2 deletions agent/tests/test_update_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ def test_a_compose_container_updates_through_compose(self):

pull = [c for c in calls if "compose" in c and "pull" in c]
self.assertEqual(pull, [[
"docker", "compose", "-f", "/srv/stack/docker-compose.yml",
"docker", "compose", "-p", "stack", "-f", "/srv/stack/docker-compose.yml",
"pull", "web"]])
up = [c for c in calls if "compose" in c and "up" in c]
self.assertEqual(up, [[
"docker", "compose", "-f", "/srv/stack/docker-compose.yml",
"docker", "compose", "-p", "stack", "-f", "/srv/stack/docker-compose.yml",
"up", "-d", "--no-deps", "web"]])
self.assertFalse(any(c[:2] == ["docker", "run"] for c in calls))
self.assertIn("via compose", out)
Expand Down
2 changes: 1 addition & 1 deletion agent/vigil_agent/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2026.14.1"
__version__ = "2026.14.2"
8 changes: 7 additions & 1 deletion agent/vigil_agent/actions/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,11 @@ def _update_container(params: dict, _config: AgentConfig) -> str:

compose_file = ""
if labels.get(_COMPOSE_PROJECT_LABEL):
# Re-up under the container's own project. Without -p compose names
# the project after the folder (Portainer's /data/compose/49 makes it
# "49"), does not recognise the running container, and fails on its
# container_name.
project = _validate_name(labels[_COMPOSE_PROJECT_LABEL], "compose project")
service = labels.get("com.docker.compose.service") or name
_validate_name(service, "service name")

Expand All @@ -393,7 +398,8 @@ def _update_container(params: dict, _config: AgentConfig) -> str:
dir_args = ["--project-directory", str(ex._validate_path(project_dir, "project directory"))]
_clear_rollback(project_dir) # an update ends a rollback

cmd = [*ex._compose_cmd(), *dir_args, *_label_env_files(labels, known), "-f", compose_file]
cmd = [*ex._compose_cmd(), "-p", project, *dir_args, *_label_env_files(labels, known),
"-f", compose_file]
ex._run(cmd + ["pull", service], timeout=600, extra_env=ex._compose_env())
ex._run(cmd + ["up", "-d", "--no-deps", service], timeout=300,
extra_env=ex._compose_env())
Expand Down
3 changes: 3 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ RUN if [ ! -f /app/agent_dist/VERSION ]; then \
# VIGIL_SKIP_STARTUP_CHECKS=1 lets manage.py boot without VIGIL_SIGNING_KEY_SEED.
# Build-time collectstatic doesn't need a real signing key — the runtime env
# (compose/Portainer) supplies the seed. Required since DEBUG defaults to false.
# VIGIL_HASHED_STATIC: hashed static file names, so an upgrade can never be
# served last release's scripts from a cache (settings.py, STORAGES).
ENV VIGIL_HASHED_STATIC=1
RUN VIGIL_SKIP_STARTUP_CHECKS=1 python manage.py collectstatic --noinput

EXPOSE 8000
Expand Down
159 changes: 159 additions & 0 deletions server/apps/hosts/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Search everything (QA-25) — one box over what the user may already see.

Each kind is filtered exactly the way its own page filters it: hosts and
everything on a host through ``scoping.filter_by_site``, stacks and update
policies for admins only (their APIs are admin-only), task definitions the
user's own plus community ones (what the task library lists).
"""
import re

from django.db.models import Count, Q
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from apps.accounts.permissions import IsAdmin

from vigil import scoping

LIMIT = 8
MAX_QUERY = 100
_IP_PREFIX = re.compile(r"^[0-9a-fA-F:.]+$")
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}


def _plural(n: int, word: str) -> str:
return f"{n} {word}" if n == 1 else f"{n} {word}s"


def _hosts(request, q):
from .models import Host
match = Q(hostname__icontains=q) | Q(os__icontains=q) | Q(tags__icontains=q)
if _IP_PREFIX.match(q):
match |= Q(ip_address__startswith=q)
qs = scoping.filter_by_site(Host.objects.exclude(status="rejected"), request.user).filter(match)
return [{"kind": "host", "id": str(h.id), "title": h.hostname,
"subtitle": " · ".join(x for x in (h.os, h.ip_address or "") if x)}
for h in qs.order_by("hostname")[:LIMIT]]


def _containers(request, q):
from .models import DockerContainer
qs = (scoping.filter_by_site(DockerContainer.objects.exclude(host__status="rejected"),
request.user, path="host__")
.filter(Q(name__icontains=q) | Q(image__icontains=q)).select_related("host"))
return [{"kind": "container", "id": c.name, "host_id": str(c.host_id), "title": c.name,
"subtitle": f"{c.image} on {c.host.hostname}"}
for c in qs.order_by("name")[:LIMIT]]


def _stacks(request, q):
from apps.stacks.models import ManagedStack
qs = (scoping.filter_by_site(ManagedStack.objects.all(), request.user, path="host__")
.filter(name__icontains=q).select_related("host"))
return [{"kind": "stack", "id": str(s.id), "host_id": str(s.host_id), "title": s.name,
"subtitle": f"on {s.host.hostname} · r{s.revision}"}
for s in qs.order_by("name")[:LIMIT]]


def _apps(request, q):
from apps.software.models import SoftwareItem
qs = (scoping.filter_by_site(SoftwareItem.objects.exclude(host__status="rejected"),
request.user, path="host__")
.filter(Q(name__icontains=q) | Q(publisher__icontains=q))
.values("name").annotate(hosts=Count("host", distinct=True)).order_by("-hosts", "name"))
return [{"kind": "app", "id": row["name"], "title": row["name"],
"subtitle": "on " + _plural(row["hosts"], "host")}
for row in qs[:LIMIT]]


def _cves(request, q):
from apps.vulns.models import VulnFinding
qs = (scoping.filter_by_site(VulnFinding.objects.filter(state="open").exclude(cve_id=""),
request.user, path="host__")
.filter(Q(cve_id__icontains=q) | Q(title__icontains=q)))
ids = list(qs.values("cve_id").annotate(hosts=Count("host", distinct=True))
.order_by("-hosts", "cve_id")[:LIMIT])
out = []
for row in ids:
findings = list(qs.filter(cve_id=row["cve_id"]).values("severity", "title")[:50])
worst = min((f["severity"] for f in findings), key=lambda s: _SEVERITY_RANK.get(s, 9))
title = next((f["title"] for f in findings if f["title"]), "")
out.append({"kind": "cve", "id": row["cve_id"], "title": row["cve_id"],
"subtitle": " · ".join(x for x in (worst, _plural(row["hosts"], "host"), title) if x)})
return out


def _tasks(request, q):
from apps.tasks.models import TaskDefinition
qs = (TaskDefinition.objects.filter(archived_at__isnull=True)
.filter(Q(owner=request.user) | Q(visibility=TaskDefinition.Visibility.COMMUNITY))
.filter(Q(name__icontains=q) | Q(description__icontains=q)))
return [{"kind": "task", "id": str(d.id), "title": d.name, "subtitle": (d.description or "")[:80]}
for d in qs.order_by("name")[:LIMIT]]


def _playbooks(request, q):
from apps.playbooks.models import Playbook
qs = (scoping.filter_by_site(Playbook.objects.filter(archived_at__isnull=True), request.user,
cascade_global=True)
.filter(Q(name__icontains=q) | Q(description__icontains=q)))
return [{"kind": "playbook", "id": str(b.id), "title": b.name,
"subtitle": (b.description or "")[:80]}
for b in qs.order_by("name")[:LIMIT]]


def _policies(request, q):
from apps.accounts.permissions import visible_site_ids
from apps.policies.models import UpdatePolicy
qs = UpdatePolicy.objects.filter(name__icontains=q)
# A policy belongs to one site (or none = every site). An admin of one
# site still counts as an admin, so narrow to the sites they can see.
sites = visible_site_ids(request.user)
if sites is not None:
qs = qs.filter(Q(site_id__isnull=True) | Q(site_id__in=sites))
return [{"kind": "policy", "id": str(p.id), "title": p.name,
"subtitle": "enabled" if p.enabled else "disabled"}
for p in qs.order_by("name")[:LIMIT]]


def _alerts(request, q):
from apps.alerts.models import Alert
qs = (scoping.filter_by_site(Alert.objects.filter(state=Alert.State.FIRING), request.user,
path="host__")
.filter(Q(message__icontains=q) | Q(host__hostname__icontains=q)).select_related("host"))
return [{"kind": "alert", "id": str(a.id), "title": (a.message or "")[:100],
"subtitle": f"{a.severity} on {a.host.hostname}"}
for a in qs.order_by("-fired_at")[:LIMIT]]


#: (kind, label, finder, admin only) — in the order the page shows them.
_KINDS = (
("host", "Hosts", _hosts, False),
("container", "Containers", _containers, False),
("stack", "Stacks", _stacks, True),
("app", "Apps", _apps, False),
("cve", "Vulnerabilities", _cves, False),
("task", "Tasks", _tasks, False),
("playbook", "Playbooks", _playbooks, False),
("policy", "Update policies", _policies, True),
("alert", "Firing alerts", _alerts, False),
)


@api_view(["GET"])
@permission_classes([IsAuthenticated])
def search(request):
"""One box over everything the user may already see (QA-25)."""
q = request.query_params.get("q", "").strip()[:MAX_QUERY]
if len(q) < 2:
return Response({"query": q, "groups": []})
admin = IsAdmin().has_permission(request, None)
groups = []
for kind, label, find, admin_only in _KINDS:
if admin_only and not admin:
continue
results = find(request, q)
if results:
groups.append({"kind": kind, "label": label, "results": results})
return Response({"query": q, "groups": groups})
145 changes: 145 additions & 0 deletions server/apps/hosts/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Search everything (QA-25): each kind only as far as its own page shows it."""
from datetime import timedelta
from pathlib import Path

from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.utils.timezone import now
from rest_framework.test import APIClient

from apps.accounts.models import Role, UserProfile
from apps.alerts.models import Alert
from apps.hosts.models import DockerContainer, Host
from apps.policies.models import UpdatePolicy
from apps.software.models import SoftwareItem
from apps.stacks.models import ManagedStack
from apps.tasks.models import TaskDefinition
from apps.vulns.models import VulnFinding

ROOT = Path(__file__).resolve().parents[2]


def _client(user):
c = APIClient()
c.force_authenticate(user)
return c


class SearchApiTests(TestCase):
def setUp(self):
users = get_user_model().objects
self.admin = users.create_user("admin", password="x")
UserProfile.objects.create(user=self.admin, role=Role.ADMIN)
self.viewer = users.create_user("viewer", password="x")
UserProfile.objects.create(user=self.viewer, role=Role.VIEWER)
self.web = Host.objects.create(hostname="web-01", agent_token="t1", status=Host.Status.ONLINE,
os="Ubuntu 24.04", ip_address="10.0.0.21", tags=["canary"])
self.nas = Host.objects.create(hostname="nas", agent_token="t2", status=Host.Status.ONLINE)
Host.objects.create(hostname="web-rejected", agent_token="t3", status="rejected")

def _search(self, q, user=None):
resp = _client(user or self.admin).get("/api/v1/search/", {"q": q})
self.assertEqual(resp.status_code, 200, resp.content)
return {g["kind"]: g["results"] for g in resp.json()["groups"]}

def test_short_queries_return_nothing(self):
resp = _client(self.admin).get("/api/v1/search/", {"q": "w"})
self.assertEqual(resp.json()["groups"], [])

def test_hosts_by_name_tag_and_ip_never_rejected(self):
self.assertEqual([r["title"] for r in self._search("web")["host"]], ["web-01"])
self.assertEqual([r["title"] for r in self._search("canary")["host"]], ["web-01"])
self.assertEqual([r["title"] for r in self._search("10.0.0")["host"]], ["web-01"])

def test_a_container_carries_its_host(self):
DockerContainer.objects.create(host=self.nas, container_id="c1", name="vault", image="vaultwarden/server")
[hit] = self._search("vaultwarden")["container"]
self.assertEqual((hit["id"], hit["host_id"]), ("vault", str(self.nas.id)))

def test_stacks_and_policies_are_for_admins_only(self):
ManagedStack.objects.create(host=self.nas, name="mediabox", compose_yaml="services: {}")
UpdatePolicy.objects.create(name="mediabox nightly")
found = self._search("mediabox")
self.assertIn("stack", found)
self.assertIn("policy", found)
found = self._search("mediabox", self.viewer)
self.assertNotIn("stack", found)
self.assertNotIn("policy", found)

def test_apps_are_one_result_per_name(self):
for host in (self.web, self.nas):
SoftwareItem.objects.create(host=host, source="dpkg", package_id="firefox", name="Firefox",
name_key="firefox", first_seen=now())
[hit] = self._search("firefox")["app"]
self.assertEqual(hit["subtitle"], "on 2 hosts")

def test_cves_are_grouped_and_open_only(self):
for host in (self.web, self.nas):
VulnFinding.objects.create(host=host, scanner="trivy", plugin_id_or_oid="p", cve_id="CVE-2026-1111",
severity="high", state="open", title="openssl overflow")
VulnFinding.objects.create(host=self.web, scanner="trivy", plugin_id_or_oid="q", cve_id="CVE-2026-2222",
severity="critical", state="fixed")
[hit] = self._search("CVE-2026")["cve"]
self.assertEqual(hit["id"], "CVE-2026-1111")
self.assertTrue(hit["subtitle"].startswith("high · 2 hosts"), hit["subtitle"])

def test_tasks_mine_and_community_not_others_or_archived(self):
other = get_user_model().objects.create_user("other", password="x")
TaskDefinition.objects.create(owner=self.admin, name="restart nginx mine", yaml_source="x")
TaskDefinition.objects.create(owner=other, name="restart nginx shared", yaml_source="x",
visibility="community")
TaskDefinition.objects.create(owner=other, name="restart nginx private", yaml_source="x")
TaskDefinition.objects.create(owner=self.admin, name="restart nginx old", yaml_source="x",
archived_at=now() - timedelta(days=1))
titles = sorted(r["title"] for r in self._search("restart nginx")["task"])
self.assertEqual(titles, ["restart nginx mine", "restart nginx shared"])

def test_only_firing_alerts(self):
Alert.objects.create(host=self.web, severity="critical", message="disk nearly full", state="firing")
Alert.objects.create(host=self.web, severity="critical", message="disk was full", state="resolved")
self.assertEqual([r["title"] for r in self._search("disk")["alert"]], ["disk nearly full"])

def test_at_most_eight_per_kind(self):
for i in range(10):
Host.objects.create(hostname=f"fleet-{i}", agent_token=f"f{i}", status=Host.Status.ONLINE)
self.assertEqual(len(self._search("fleet")["host"]), 8)


class SearchCrossSiteTests(TestCase):
"""An admin of one site finds nothing that lives in another."""

def setUp(self):
from apps_business.sites.models import HostSiteAssignment, Site, UserSiteRole
west = Site.objects.create(name="West", slug="west")
lab = Site.objects.create(name="Lab", slug="lab")
self.dana = get_user_model().objects.create_user("dana", password="x")
UserSiteRole.objects.create(user=self.dana, site=west, role=Role.ADMIN)
for site in (west, lab):
host = Host.objects.create(hostname=f"probe-{site.slug}", agent_token=f"t-{site.slug}",
status=Host.Status.ONLINE)
HostSiteAssignment.objects.create(host=host, site=site)
ManagedStack.objects.create(host=host, name=f"probe-{site.slug}", compose_yaml="services: {}")
DockerContainer.objects.create(host=host, container_id=site.slug, name=f"probe-{site.slug}")
Alert.objects.create(host=host, severity="critical", message=f"probe {site.slug}", state="firing")
UpdatePolicy.objects.create(name=f"probe {site.slug}", site_id=site.id)

def test_nothing_from_the_other_site(self):
resp = _client(self.dana).get("/api/v1/search/", {"q": "probe"})
found = {g["kind"]: [r["title"] for r in g["results"]] for g in resp.json()["groups"]}
for kind in ("host", "container", "stack", "alert", "policy"):
self.assertTrue(found.get(kind), f"{kind}: West's own row is found")
self.assertFalse([t for t in found[kind] if "lab" in t], f"{kind} leaked Lab: {found[kind]}")


class SearchWiringTests(SimpleTestCase):
def test_page_sidebar_and_script(self):
base = (ROOT / "templates/base.html").read_text(encoding="utf-8")
dash = (ROOT / "templates/dashboard.html").read_text(encoding="utf-8")
js = (ROOT / "static/js/vigil-search.js").read_text(encoding="utf-8")
self.assertIn('data-page="search"', base)
self.assertIn("js/vigil-search.js", base)
self.assertIn('{% include "pages/_search.html" %}', dash)
for needle in ("delegateClick('[data-search-kind]'", "/api/v1/search/", "escHtml(r.title)",
"seq !== searchState.seq"):
self.assertIn(needle, js)
self.assertNotIn("onclick=", js)
Loading
Loading