From eb85c3057676434a8cac4476d3562841ff2a7a01 Mon Sep 17 00:00:00 2001 From: Brandon Bradshaw Date: Sun, 16 Aug 2026 17:47:25 -0500 Subject: [PATCH] Add fleet and endpoint security management --- README.md | 15 +- docs/FLEET_ADMIN.md | 66 +++++++ pyproject.toml | 1 + src/intentgate/admin_cli.py | 70 +++++++ src/intentgate/deployments.py | 276 ++++++++++++++++++++++++++++ src/intentgate/security_policies.py | 161 ++++++++++++++++ src/intentgate/service.py | 66 ++++++- src/intentgate/web/console.css | 41 ++++- src/intentgate/web/console.js | 213 ++++++++++++++++++++- src/intentgate/web/index.html | 90 ++++++++- tests/test_service.py | 56 ++++++ 11 files changed, 1036 insertions(+), 19 deletions(-) create mode 100644 docs/FLEET_ADMIN.md create mode 100644 src/intentgate/admin_cli.py create mode 100644 src/intentgate/deployments.py create mode 100644 src/intentgate/security_policies.py diff --git a/README.md b/README.md index 186f709..e211211 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ This project is intended to help teams investigate a missing security layer betw | Pluggable model advisor | OpenAI Responses API, OpenAI-compatible endpoints, or a generic model gateway webhook | Adds an independent structured intent recommendation without making a model the enforcement authority | | Protected integration API | OWASP CRS WAF, backend network isolation, bearer-token ingestion, and bounded requests | Reduces attack surface for the security signals that influence policy | | Repeatable deployment | Docker Compose, Terraform, and Ansible | Makes the POC reproducible for labs, demos, and controlled evaluations | +| Fleet administration | Endpoint inventory, security-group discovery, deployment plans, and agent-pull jobs | Demonstrates controlled network distribution without exposing arbitrary remote shell execution | ## Where it can be used @@ -214,6 +215,8 @@ intentgate: BLOCK risk=100 | `uig-service` | Start signal-ingestion and metrics endpoints | | `uig-collector config/integrations.json` | Poll configured security sources | | `uig-notifier` | Deliver queued manager risk reports | +| `uig-admin discover` | Snapshot enrolled endpoints and security groups over the authenticated API | +| `uig-admin deploy --group engineering --version 0.4.0 --execute` | Queue the fixed install manifest to a discovered security group | Exit codes are `0` for success, `2` for a non-allow dry run, `125` for review not approved, `126` for a blocked command, and `127` when the executable is missing. @@ -300,8 +303,18 @@ Use **Set API token** in the console to enter the configured bearer token. The t - Versioned, locally persisted review and block thresholds - Versioned zero-trust step-up controls and a micro-segmentation flow designer - An inspectable destructive-action rule catalog +- Fleet discovery with endpoint, platform, security-group, online, and installed-version coverage +- Dry-run and queued deployment waves for a fixed Intent Gate installation manifest +- Endpoint security policy coverage for antivirus, malware prevention, DLP, behavior monitoring, and vulnerability/CVE scanning +- A group-oriented inventory explorer showing policy assignments and every endpoint in each security group -The console is intentionally **assessment-only**. Approving a review records human authorization but never launches the command from the browser. A trusted command broker remains the required production execution boundary. +The Command Lab and review workflow are intentionally **assessment-only**. Approving a command review records human authorization but never launches that command from the browser. Fleet Admin can separately queue only the fixed software-install manifest; a trusted endpoint agent remains the required deployment execution boundary. + +### Fleet Admin + +The **Fleet Admin** section discovers enrolled endpoints and security groups, shows agent coverage, previews the equivalent `uig-admin` network command, and plans or queues deployment waves. Discovery is based on registered inventory and heartbeats rather than blind network scanning. Deployment jobs use a fixed `install-or-upgrade-intentgate` manifest and an agent-pull transport; arbitrary remote commands are not accepted. + +For command examples, endpoint-agent API routes, safety properties, and production requirements, see [Fleet Administration](docs/FLEET_ADMIN.md). HTTP assessments use a standard `console-operator` execution context by default rather than inheriting the container service account's root identity. A trusted broker can submit an explicit `execution_context` containing the originating user and privilege level; production enforcement must authenticate that context rather than accepting it directly from an untrusted client. diff --git a/docs/FLEET_ADMIN.md b/docs/FLEET_ADMIN.md new file mode 100644 index 0000000..2427c78 --- /dev/null +++ b/docs/FLEET_ADMIN.md @@ -0,0 +1,66 @@ +# Fleet administration + +The Fleet Admin POC adds an authenticated deployment control plane to the Intent Gate service. It demonstrates scoped software distribution without turning the console into a general-purpose remote shell. + +## How it works + +1. Endpoint agents register hostname, address, operating system, security groups, and installed version with `POST /v1/endpoints/register`. +2. A discovery session snapshots enrolled inventory and summarizes group, online, and managed coverage. +3. An administrator targets a security group or explicit endpoint IDs and creates a dry-run plan. +4. An executed plan creates one fixed `install-or-upgrade-intentgate` manifest per endpoint. +5. Endpoint agents poll `GET /v1/deployment-jobs/next?endpoint_id=...` and report lifecycle state to `POST /v1/deployment-jobs/{job_id}`. + +The repository implements the control plane, API contract, demo inventory, network CLI, and UI. A production endpoint agent and artifact repository are intentionally separate trust components. + +## Network administration command + +Set the bearer token without putting it in shell history: + +```powershell +$env:UIG_ADMIN_SERVER = "https://intentgate.example" +$env:UIG_INGEST_TOKEN = "replace-with-a-secret-from-your-vault" +``` + +Run discovery and inspect inventory: + +```powershell +uig-admin discover +uig-admin inventory +``` + +Plan a deployment, then explicitly queue it: + +```powershell +uig-admin deploy --group engineering --version 0.4.0 +uig-admin deploy --group engineering --version 0.4.0 --execute +uig-admin deployments +``` + +Repeat `--endpoint` to target explicit enrolled endpoint IDs instead of a group. The token is accepted through `--token`, but environment or secret-store injection is preferred. + +## API routes + +| Route | Purpose | +|---|---| +| `GET /v1/endpoints` | Inventory, security groups, and coverage summary | +| `POST /v1/endpoints/register` | Agent registration and heartbeat | +| `POST /v1/discovery-sessions` | Create an inventory snapshot | +| `GET /v1/deployments` | Recent deployment waves | +| `POST /v1/deployments` | Plan or queue a scoped deployment | +| `GET /v1/deployment-jobs/next` | Fetch the next queued endpoint manifest | +| `POST /v1/deployment-jobs/{id}` | Record endpoint deployment state | + +Every route requires the existing bearer token. WAF, rate limiting, enterprise identity, and separate endpoint credentials should be applied before this control plane is exposed beyond a lab. + +## Safety properties + +- No endpoint accepts arbitrary command text from this API. +- Deployment jobs contain a fixed package/action manifest. +- Planning is the default; queueing requires the explicit `execute` field or `--execute` flag. +- Offline endpoints are deferred rather than treated as successful. +- Every deployment records the selector, requestor, target version, endpoint jobs, timestamps, and state transitions. +- Discovery uses enrolled inventory and heartbeats; it does not perform unauthenticated subnet scanning. + +## Production additions + +Before real enterprise rollout, add mutual TLS and per-agent identity, signed artifacts and manifests, RBAC with approval separation, maintenance windows, phased rings/canaries, health-based pause and rollback, durable encrypted storage, rate limits, immutable audit export, inventory connectors for Entra ID/Intune/AD/CMDB, and an endpoint service that verifies signatures before installation. diff --git a/pyproject.toml b/pyproject.toml index 4b35852..982fd36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ uig-scan = "intentgate.provenance:main" uig-service = "intentgate.service:main" uig-collector = "intentgate.collector:main" uig-notifier = "intentgate.notifier:main" +uig-admin = "intentgate.admin_cli:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/intentgate/admin_cli.py b/src/intentgate/admin_cli.py new file mode 100644 index 0000000..38a99bb --- /dev/null +++ b/src/intentgate/admin_cli.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +import json +import os +import urllib.error +import urllib.parse +import urllib.request + + +def _request(server: str, token: str, path: str, method: str = "GET", body: object | None = None) -> dict: + payload = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request( + server.rstrip("/") + path, + data=payload, + method=method, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + try: + detail = json.loads(exc.read()).get("error", exc.reason) + except (json.JSONDecodeError, AttributeError): + detail = exc.reason + raise SystemExit(f"Intent Gate admin request failed: {detail}") from exc + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="uig-admin", description="Intent Gate network administration client") + parser.add_argument("--server", default=os.environ.get("UIG_ADMIN_SERVER", "http://127.0.0.1:8787")) + parser.add_argument("--token", default=os.environ.get("UIG_INGEST_TOKEN", "")) + sub = parser.add_subparsers(dest="action", required=True) + sub.add_parser("inventory", help="List discovered endpoints and security groups") + sub.add_parser("discover", help="Create an enrolled-endpoint discovery session") + deployments = sub.add_parser("deployments", help="List recent deployment waves") + deployments.add_argument("--limit", type=int, default=20) + deploy = sub.add_parser("deploy", help="Plan or queue the fixed Intent Gate deployment manifest") + deploy.add_argument("--group", help="Target a security group") + deploy.add_argument("--endpoint", action="append", default=[], help="Target an endpoint id; repeat as needed") + deploy.add_argument("--version", default="0.4.0") + deploy.add_argument("--execute", action="store_true", help="Queue jobs; omission creates a dry-run plan") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if not args.token: + raise SystemExit("Set UIG_INGEST_TOKEN or pass --token") + if args.action == "inventory": + result = _request(args.server, args.token, "/v1/endpoints") + elif args.action == "discover": + result = _request(args.server, args.token, "/v1/discovery-sessions", "POST", {"requested_by": "network-cli"}) + elif args.action == "deployments": + result = _request(args.server, args.token, f"/v1/deployments?limit={max(1, min(args.limit, 250))}") + else: + result = _request(args.server, args.token, "/v1/deployments", "POST", { + "security_group": args.group, + "endpoint_ids": args.endpoint, + "version": args.version, + "execute": args.execute, + "requested_by": "network-cli", + }) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/intentgate/deployments.py b/src/intentgate/deployments.py new file mode 100644 index 0000000..2c09e5d --- /dev/null +++ b/src/intentgate/deployments.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import json +import os +import re +import time +import uuid +from pathlib import Path +from threading import Lock +from typing import Any + + +_LOCK = Lock() +_ENDPOINT_ID = re.compile(r"^[a-zA-Z0-9._-]{1,80}$") +_VERSION = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+-]{0,39}$") + +_DEMO_ENDPOINTS = ( + ("dev-ws-042", "DEV-WS-042", "10.24.10.42", "Windows 11", ("engineering", "windows", "standard-trust"), "0.4.0", "online"), + ("dev-ws-117", "DEV-WS-117", "10.24.10.117", "Windows 11", ("engineering", "windows", "privileged-dev"), "0.4.0", "online"), + ("fin-lt-019", "FIN-LT-019", "10.24.20.19", "Windows 11", ("finance", "windows", "high-value"), None, "online"), + ("ops-pa-003", "OPS-PA-003", "10.24.30.3", "Windows Server 2025", ("operations", "windows", "privileged-access"), "0.3.2", "online"), + ("build-lnx-02", "BUILD-LNX-02", "10.24.40.22", "Ubuntu 24.04", ("engineering", "linux", "ci-runners"), "0.4.0", "online"), + ("kube-admin-01", "KUBE-ADMIN-01", "10.24.30.11", "Ubuntu 24.04", ("operations", "linux", "cluster-admins"), "0.4.0", "online"), + ("sales-lt-088", "SALES-LT-088", "10.24.50.88", "Windows 11", ("sales", "windows", "standard-trust"), None, "offline"), + ("sec-lab-07", "SEC-LAB-07", "10.24.60.7", "macOS 15", ("security", "macos", "purple-team"), "0.4.0", "online"), +) + + +def _state_dir() -> Path: + return Path(os.environ.get("UIG_STATE_DIR", Path.home() / ".intentgate")) + + +def _path(name: str) -> Path: + return _state_dir() / name + + +def _read(path: Path, default: Any) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return default + + +def _write(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _demo_endpoints() -> list[dict[str, Any]]: + now = time.time() + return [ + { + "id": endpoint_id, + "hostname": hostname, + "ip_address": ip_address, + "operating_system": operating_system, + "security_groups": list(groups), + "agent_version": agent_version, + "install_state": "managed" if agent_version else "not-installed", + "status": status, + "last_seen": now - (22 if status == "online" else 7_440), + "source": "demo-inventory", + } + for endpoint_id, hostname, ip_address, operating_system, groups, agent_version, status in _DEMO_ENDPOINTS + ] + + +def list_endpoints() -> list[dict[str, Any]]: + endpoints = _read(_path("endpoints.json"), None) + if not isinstance(endpoints, list) or not endpoints: + endpoints = _demo_endpoints() + _write(_path("endpoints.json"), endpoints) + return sorted((item for item in endpoints if isinstance(item, dict)), key=lambda item: str(item.get("hostname", ""))) + + +def register_endpoint(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError("endpoint registration must be an object") + hostname = str(value.get("hostname", "")).strip()[:120] + endpoint_id = str(value.get("id") or hostname.lower()).strip() + if not hostname or not _ENDPOINT_ID.fullmatch(endpoint_id): + raise ValueError("hostname and a valid endpoint id are required") + groups = value.get("security_groups", []) + if not isinstance(groups, list) or len(groups) > 20: + raise ValueError("security_groups must be an array with at most 20 entries") + groups = sorted({str(item).strip()[:80] for item in groups if str(item).strip()}) + endpoint = { + "id": endpoint_id, + "hostname": hostname, + "ip_address": str(value.get("ip_address", "unknown"))[:80], + "operating_system": str(value.get("operating_system", "unknown"))[:120], + "security_groups": groups, + "agent_version": str(value.get("agent_version", ""))[:40] or None, + "install_state": str(value.get("install_state", "managed"))[:40], + "status": "online", + "last_seen": time.time(), + "source": str(value.get("source", "agent-registration"))[:80], + } + with _LOCK: + endpoints = list_endpoints() + endpoints = [item for item in endpoints if item.get("id") != endpoint_id] + endpoints.append(endpoint) + _write(_path("endpoints.json"), endpoints[-2_000:]) + return endpoint + + +def security_groups(endpoints: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + selected = endpoints or list_endpoints() + names = sorted({str(group) for item in selected for group in item.get("security_groups", [])}) + return [ + { + "name": name, + "endpoint_count": sum(name in item.get("security_groups", []) for item in selected), + "online_count": sum(name in item.get("security_groups", []) and item.get("status") == "online" for item in selected), + "managed_count": sum(name in item.get("security_groups", []) and bool(item.get("agent_version")) for item in selected), + } + for name in names + ] + + +def inventory() -> dict[str, Any]: + endpoints = list_endpoints() + sessions = _read(_path("discovery-sessions.json"), []) + deployments = list_deployments() + return { + "endpoints": endpoints, + "security_groups": security_groups(endpoints), + "summary": { + "total": len(endpoints), + "online": sum(item.get("status") == "online" for item in endpoints), + "managed": sum(bool(item.get("agent_version")) for item in endpoints), + "groups": len(security_groups(endpoints)), + "queued_jobs": sum(job.get("status") in {"queued", "deferred"} for deployment in deployments for job in deployment.get("jobs", [])), + }, + "latest_discovery": sessions[-1] if isinstance(sessions, list) and sessions else None, + } + + +def create_discovery_session(value: object | None = None) -> dict[str, Any]: + if value is not None and not isinstance(value, dict): + raise ValueError("discovery request must be an object") + request = value or {} + started = time.time() + endpoints = list_endpoints() + session = { + "id": uuid.uuid4().hex[:12], + "created_at": started, + "completed_at": time.time(), + "status": "completed", + "mode": "enrolled-inventory", + "requested_by": str(request.get("requested_by", "admin-console"))[:120], + "endpoint_count": len(endpoints), + "online_count": sum(item.get("status") == "online" for item in endpoints), + "security_group_count": len(security_groups(endpoints)), + "sources": ["agent-registration", "demo-inventory"], + } + with _LOCK: + sessions = _read(_path("discovery-sessions.json"), []) + if not isinstance(sessions, list): + sessions = [] + sessions.append(session) + _write(_path("discovery-sessions.json"), sessions[-100:]) + return {**session, "endpoints": endpoints, "security_groups": security_groups(endpoints)} + + +def _deployment_command(group: str | None, endpoint_ids: list[str], version: str, execute: bool) -> str: + selector = f"--group {group}" if group else " ".join(f"--endpoint {item}" for item in endpoint_ids) + suffix = " --execute" if execute else "" + return f"uig-admin deploy --server http://intentgate.example:8787 {selector} --version {version}{suffix}" + + +def create_deployment(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError("deployment request must be an object") + group = str(value.get("security_group", "")).strip()[:80] or None + endpoint_ids_value = value.get("endpoint_ids", []) + if not isinstance(endpoint_ids_value, list): + raise ValueError("endpoint_ids must be an array") + endpoint_ids = sorted({str(item) for item in endpoint_ids_value if _ENDPOINT_ID.fullmatch(str(item))}) + if not group and not endpoint_ids: + raise ValueError("security_group or endpoint_ids is required") + version = str(value.get("version", "0.4.0")).strip() + if not _VERSION.fullmatch(version): + raise ValueError("version contains unsupported characters") + execute = bool(value.get("execute", False)) + endpoints = list_endpoints() + selected = [item for item in endpoints if (group and group in item.get("security_groups", [])) or item.get("id") in endpoint_ids] + if not selected: + raise ValueError("selector did not match any enrolled endpoints") + deployment_id = uuid.uuid4().hex[:12] + created = time.time() + jobs = [ + { + "id": uuid.uuid4().hex[:12], + "deployment_id": deployment_id, + "endpoint_id": item["id"], + "hostname": item["hostname"], + "status": "planned" if not execute else ("queued" if item.get("status") == "online" else "deferred"), + "transport": "agent-pull", + "manifest": { + "action": "install-or-upgrade-intentgate", + "package": "user-intent-gate", + "version": version, + "restart_service": True, + }, + "created_at": created, + "updated_at": created, + } + for item in selected + ] + deployment = { + "id": deployment_id, + "created_at": created, + "requested_by": str(value.get("requested_by", "admin-console"))[:120], + "security_group": group, + "endpoint_ids": [item["id"] for item in selected], + "version": version, + "execute": execute, + "status": "queued" if execute else "planned", + "matched_endpoints": len(selected), + "network_command": _deployment_command(group, endpoint_ids or [item["id"] for item in selected], version, execute), + "jobs": jobs, + } + with _LOCK: + deployments = list_deployments() + deployments.append(deployment) + _write(_path("deployments.json"), deployments[-250:]) + return deployment + + +def list_deployments(limit: int = 50) -> list[dict[str, Any]]: + value = _read(_path("deployments.json"), []) + if not isinstance(value, list): + return [] + return list(reversed([item for item in value if isinstance(item, dict)][-max(1, min(limit, 250)):])) + + +def next_job(endpoint_id: str) -> dict[str, Any] | None: + if not _ENDPOINT_ID.fullmatch(endpoint_id): + raise ValueError("invalid endpoint id") + for deployment in list_deployments(250): + for job in deployment.get("jobs", []): + if job.get("endpoint_id") == endpoint_id and job.get("status") == "queued": + return job + return None + + +def update_job(job_id: str, value: object) -> dict[str, Any] | None: + if not isinstance(value, dict): + raise ValueError("job update must be an object") + status = str(value.get("status", "")) + if status not in {"acknowledged", "running", "succeeded", "failed"}: + raise ValueError("status must be acknowledged, running, succeeded, or failed") + with _LOCK: + deployments = list(reversed(list_deployments(250))) + selected = None + for deployment in deployments: + for job in deployment.get("jobs", []): + if job.get("id") == job_id: + job["status"] = status + job["updated_at"] = time.time() + job["detail"] = str(value.get("detail", ""))[:500] or None + selected = job + break + statuses = {job.get("status") for job in deployment.get("jobs", [])} + if statuses and statuses <= {"succeeded"}: + deployment["status"] = "succeeded" + elif "failed" in statuses: + deployment["status"] = "attention-required" + elif statuses & {"acknowledged", "running"}: + deployment["status"] = "in-progress" + if selected is not None: + _write(_path("deployments.json"), deployments) + return selected diff --git a/src/intentgate/security_policies.py b/src/intentgate/security_policies.py new file mode 100644 index 0000000..02b8704 --- /dev/null +++ b/src/intentgate/security_policies.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .deployments import list_endpoints + + +DEFAULT_SECURITY_POLICIES: tuple[dict[str, Any], ...] = ( + { + "id": "endpoint-av", + "name": "Endpoint antivirus", + "domain": "AV", + "description": "Real-time file, process, memory, and removable-media protection.", + "enabled": True, + "enforcement_mode": "enforce", + "assigned_groups": ["windows", "linux", "macos"], + "rules": ["Real-time protection", "Cloud-delivered detection", "Tamper protection", "Scheduled full scan"], + "source": "endpoint-protection-baseline", + }, + { + "id": "malware-prevention", + "name": "Malware prevention", + "domain": "MALWARE", + "description": "Blocks known malware, suspicious scripts, ransomware behavior, and persistence techniques.", + "enabled": True, + "enforcement_mode": "enforce", + "assigned_groups": ["high-value", "privileged-access", "privileged-dev", "engineering"], + "rules": ["Known-malware deny", "Ransomware behavior block", "Script reputation", "Persistence prevention"], + "source": "malware-prevention-baseline", + }, + { + "id": "data-loss-prevention", + "name": "Data loss prevention", + "domain": "DLP", + "description": "Detects sensitive-data movement to unapproved destinations and removable media.", + "enabled": True, + "enforcement_mode": "review", + "assigned_groups": ["finance", "high-value", "sales"], + "rules": ["Financial data classification", "Secrets and credentials", "External upload review", "Removable-media control"], + "source": "dlp-baseline", + }, + { + "id": "behavior-monitoring", + "name": "Behavior monitoring", + "domain": "UEBA", + "description": "Correlates command rarity, velocity, privilege, sequence, and identity baselines.", + "enabled": True, + "enforcement_mode": "review", + "assigned_groups": ["operations", "privileged-access", "privileged-dev", "cluster-admins"], + "rules": ["Rare command family", "Rapid file mutation", "Privilege anomaly", "Secret-to-egress sequence"], + "source": "intentgate-local-baseline", + }, + { + "id": "vulnerability-cve", + "name": "Vulnerability & CVE scanning", + "domain": "CVE", + "description": "Prioritizes vulnerable software using severity, asset exposure, and known exploitation.", + "enabled": True, + "enforcement_mode": "enforce", + "assigned_groups": ["windows", "linux", "macos", "high-value", "ci-runners"], + "rules": ["Daily software inventory", "Known-exploited escalation", "Critical CVE deployment gate", "Remediation SLA tracking"], + "source": "demo-kev-feed", + "cve_alerts": [ + {"id": "CVE-2021-44228", "severity": "critical", "known_exploited": True, "affected_endpoints": 1, "status": "patch-required", "summary": "Log4j remote code execution exposure detected in a demo build dependency."}, + {"id": "CVE-2023-34362", "severity": "critical", "known_exploited": True, "affected_endpoints": 1, "status": "isolated", "summary": "MOVEit transfer service signature observed on a demo high-value endpoint."}, + {"id": "CVE-2024-3094", "severity": "high", "known_exploited": False, "affected_endpoints": 1, "status": "investigate", "summary": "XZ package provenance requires validation on a demo Linux runner."}, + ], + }, +) + + +def _path() -> Path: + return Path(os.environ.get("UIG_STATE_DIR", Path.home() / ".intentgate")) / "security-policies.json" + + +def _stored() -> list[dict[str, Any]]: + try: + value = json.loads(_path().read_text(encoding="utf-8")) + return value if isinstance(value, list) else [] + except (OSError, json.JSONDecodeError): + return [] + + +def _write(value: list[dict[str, Any]]) -> None: + path = _path() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _coverage(policy: dict[str, Any], endpoints: list[dict[str, Any]]) -> dict[str, Any]: + groups = set(map(str, policy.get("assigned_groups", []))) + matched = [item for item in endpoints if groups.intersection(map(str, item.get("security_groups", [])))] + return { + **policy, + "covered_endpoint_count": len(matched), + "total_endpoint_count": len(endpoints), + "online_endpoint_count": sum(item.get("status") == "online" for item in matched), + "covered_endpoints": [ + {"id": item.get("id"), "hostname": item.get("hostname"), "status": item.get("status"), "agent_version": item.get("agent_version")} + for item in matched + ], + } + + +def list_security_policies() -> dict[str, Any]: + defaults = {item["id"]: json.loads(json.dumps(item)) for item in DEFAULT_SECURITY_POLICIES} + for item in _stored(): + if isinstance(item, dict) and item.get("id") in defaults: + defaults[item["id"]].update({ + "enabled": bool(item.get("enabled", defaults[item["id"]]["enabled"])), + "enforcement_mode": str(item.get("enforcement_mode", defaults[item["id"]]["enforcement_mode"])), + "assigned_groups": list(item.get("assigned_groups", defaults[item["id"]]["assigned_groups"])), + }) + endpoints = list_endpoints() + policies = [_coverage(defaults[item["id"]], endpoints) for item in DEFAULT_SECURITY_POLICIES] + groups = sorted({str(group) for endpoint in endpoints for group in endpoint.get("security_groups", [])}) + return { + "policies": policies, + "available_groups": groups, + "summary": { + "active": sum(item["enabled"] for item in policies), + "domains": len(policies), + "endpoint_count": len(endpoints), + "known_exploited_cves": sum(alert.get("known_exploited") is True for item in policies for alert in item.get("cve_alerts", [])), + }, + "intelligence_notice": "CVE findings use a labeled demo intelligence feed for this POC; connect an authenticated scanner and current KEV source for production.", + } + + +def save_security_policy(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError("security policy update must be an object") + policy_id = str(value.get("id", "")) + defaults = {item["id"]: item for item in DEFAULT_SECURITY_POLICIES} + if policy_id not in defaults: + raise ValueError("unknown security policy id") + mode = str(value.get("enforcement_mode", defaults[policy_id]["enforcement_mode"])) + if mode not in {"monitor", "review", "enforce"}: + raise ValueError("enforcement_mode must be monitor, review, or enforce") + groups = value.get("assigned_groups", defaults[policy_id]["assigned_groups"]) + if not isinstance(groups, list): + raise ValueError("assigned_groups must be an array") + available = set(list_security_policies()["available_groups"]) + selected_groups = sorted({str(item) for item in groups if str(item) in available}) + if not selected_groups: + raise ValueError("at least one discovered security group is required") + saved = { + "id": policy_id, + "enabled": bool(value.get("enabled", True)), + "enforcement_mode": mode, + "assigned_groups": selected_groups, + } + items = [item for item in _stored() if isinstance(item, dict) and item.get("id") != policy_id] + items.append(saved) + _write(items) + return next(item for item in list_security_policies()["policies"] if item["id"] == policy_id) diff --git a/src/intentgate/service.py b/src/intentgate/service.py index 9e74dc3..bf94253 100644 --- a/src/intentgate/service.py +++ b/src/intentgate/service.py @@ -19,6 +19,15 @@ from .behavior import assess_anomaly from .catalog import DESTRUCTIVE_ACTIONS from .context import HISTORY_FILE, collect_context +from .deployments import ( + create_deployment, + create_discovery_session, + inventory, + list_deployments, + next_job, + register_endpoint, + update_job, +) from .engine import assess from .integrations import ingest, read_posture, source_postures from .model_advisory import model_status, request_model_advisory @@ -26,6 +35,7 @@ from .policy import load_policy, save_policy from .reporting import pending_report_count from .reviews import create_review, decide_review, list_reviews +from .security_policies import list_security_policies, save_security_policy from .trust_controls import apply_zero_trust, load_trust_controls, save_trust_controls @@ -216,7 +226,11 @@ def do_GET(self) -> None: self.end_headers() self.wfile.write(payload) return - if parsed.path in {"/v1/audit", "/v1/reviews", "/v1/policy", "/v1/model", "/v1/trust-controls"} and not self._authorized(): + protected = { + "/v1/audit", "/v1/reviews", "/v1/policy", "/v1/model", "/v1/trust-controls", + "/v1/endpoints", "/v1/deployments", "/v1/deployment-jobs/next", "/v1/security-policies", + } + if parsed.path in protected and not self._authorized(): self._json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) return if parsed.path == "/v1/audit": @@ -250,13 +264,36 @@ def do_GET(self) -> None: if parsed.path == "/v1/trust-controls": self._json(HTTPStatus.OK, load_trust_controls()) return + if parsed.path == "/v1/security-policies": + self._json(HTTPStatus.OK, list_security_policies()) + return + if parsed.path == "/v1/endpoints": + self._json(HTTPStatus.OK, inventory()) + return + if parsed.path == "/v1/deployments": + query = parse_qs(parsed.query) + try: + limit = max(1, min(int(query.get("limit", ["50"])[0]), 250)) + except ValueError: + limit = 50 + self._json(HTTPStatus.OK, {"deployments": list_deployments(limit)}) + return + if parsed.path == "/v1/deployment-jobs/next": + endpoint_id = parse_qs(parsed.query).get("endpoint_id", [""])[0] + self._json(HTTPStatus.OK, {"job": next_job(endpoint_id)}) + return self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) def do_POST(self) -> None: parsed = urlparse(self.path) - known = parsed.path in {"/v1/signals", "/v1/events", "/v1/assess", "/v1/model-assess", "/v1/policy", "/v1/trust-controls"} + known = parsed.path in { + "/v1/signals", "/v1/events", "/v1/assess", "/v1/model-assess", "/v1/policy", + "/v1/trust-controls", "/v1/endpoints/register", "/v1/discovery-sessions", "/v1/deployments", + "/v1/security-policies", + } review_match = re.fullmatch(r"/v1/reviews/([a-f0-9]{12})", parsed.path) - if not known and review_match is None: + job_match = re.fullmatch(r"/v1/deployment-jobs/([a-f0-9]{12})", parsed.path) + if not known and review_match is None and job_match is None: self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) return if not self._authorized(): @@ -292,6 +329,22 @@ def do_POST(self) -> None: controls = save_trust_controls(body) self._json(HTTPStatus.OK, controls) return + if parsed.path == "/v1/security-policies": + policy = save_security_policy(body) + self._json(HTTPStatus.OK, policy) + return + if parsed.path == "/v1/endpoints/register": + endpoint = register_endpoint(body) + self._json(HTTPStatus.OK, endpoint) + return + if parsed.path == "/v1/discovery-sessions": + session = create_discovery_session(body) + self._json(HTTPStatus.OK, session) + return + if parsed.path == "/v1/deployments": + deployment = create_deployment(body) + self._json(HTTPStatus.ACCEPTED, deployment) + return if review_match is not None: if not isinstance(body, dict): raise ValueError("payload must be an object") @@ -303,6 +356,13 @@ def do_POST(self) -> None: else: self._json(HTTPStatus.OK, selected) return + if job_match is not None: + selected = update_job(job_match.group(1), body) + if selected is None: + self._json(HTTPStatus.NOT_FOUND, {"error": "deployment job not found"}) + else: + self._json(HTTPStatus.OK, selected) + return items = body if isinstance(body, list) else [body] if not all(isinstance(item, dict) for item in items): raise ValueError("payload must be an object or list of objects") diff --git a/src/intentgate/web/console.css b/src/intentgate/web/console.css index 12d9d74..ddb1826 100644 --- a/src/intentgate/web/console.css +++ b/src/intentgate/web/console.css @@ -71,12 +71,17 @@ h2 { margin-bottom: 0; font: 500 24px/1.2 Georgia, serif; } .intelligence-fabric { grid-column: 1 / -1; position: relative; overflow: hidden; padding: 30px; color: #edf7f2; background: #0d1d1b; border: 1px solid #2b4942; box-shadow: 0 20px 55px #13201d1a; } .fabric-grid { position: absolute; inset: 0; opacity: .16; background-image: linear-gradient(#70d7ac19 1px, transparent 1px), linear-gradient(90deg, #70d7ac19 1px, transparent 1px); background-size: 35px 35px; mask-image: linear-gradient(to bottom, black, transparent 88%); } .intelligence-fabric::after { content: ""; position: absolute; width: 42%; height: 1px; left: -42%; top: 0; background: linear-gradient(90deg, transparent, #80f7c5, transparent); animation: fabric-scan 4s linear infinite; box-shadow: 0 0 18px #80f7c5; } -.fabric-heading, .fabric-body, .capability-grid { position: relative; z-index: 1; } +.fabric-heading, .attention-board, .fabric-body, .capability-grid { position: relative; z-index: 1; } .fabric-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; } .fabric-heading h2 { font-size: 29px; } +.fabric-summary { max-width: 760px; margin: 8px 0 0; color: #84a198; font-size: 11px; } .intelligence-fabric .eyebrow { color: #7ee0b6; } .live-chip { padding: 7px 10px; border: 1px solid #3d665b; color: #91cbb5; font: 700 8px/1 Consolas, monospace; letter-spacing: .12em; } .live-chip i { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: #6ce3ad; box-shadow: 0 0 0 4px #6ce3ad20, 0 0 14px #6ce3ad; animation: status-pulse 1.8s ease-out infinite; } +.attention-board { margin-top: 20px; padding: 10px; border: 1px solid #315149; display: grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap: 1px; background: #315149; } +.attention-board-label, .attention-action { min-height: 92px; padding: 14px 16px; background: #162d29; } +.attention-board-label { display: flex; flex-direction: column; justify-content: center; }.attention-board-label span { color: #6f9688; font: 700 7px/1 Consolas, monospace; letter-spacing: .13em; }.attention-board-label strong { margin-top: 8px; color: #e2f2eb; font: 500 17px/1.2 Georgia, serif; } +.attention-action { border: 0; display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; color: #c8ddd4; text-align: left; text-decoration: none; cursor: pointer; transition: background .18s ease, transform .18s ease; }.attention-action:hover { background: #1d3933; transform: translateY(-1px); }.attention-action span { align-self: center; color: #789b8e; font: 700 7px/1 Consolas, monospace; letter-spacing: .1em; }.attention-action strong { grid-row: 1 / span 2; grid-column: 2; align-self: center; color: #f08a7f; font: 500 30px/1 Georgia, serif; }.attention-action small { color: #91b1a5; font: 9px/1.2 system-ui, sans-serif; }.attention-action.warning strong { color: #efb16e; }.attention-action.review strong { color: #8de6bd; } .fabric-body { margin-top: 29px; display: grid; grid-template-columns: 210px 1fr; gap: 26px; align-items: stretch; } .ai-core-wrap { min-height: 190px; border: 1px solid #294840; background: #132825b3; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .ai-core { position: relative; width: 92px; height: 92px; display: grid; place-items: center; border: 1px solid #64d7a6; border-radius: 50%; color: #b8f4d8; background: radial-gradient(circle, #2a745b 0, #153b32 45%, #10231f 70%); box-shadow: inset 0 0 25px #6be7b534, 0 0 35px #52c6982e; } @@ -97,16 +102,17 @@ h2 { margin-bottom: 0; font: 500 24px/1.2 Georgia, serif; } .stream-event::before { content: ""; position: absolute; left: 1px; width: 6px; height: 6px; border-radius: 50%; background: #6ed3a8; box-shadow: 0 0 8px #6ed3a8; } .stream-event.review::before { background: #efa85d; box-shadow: 0 0 8px #efa85d; }.stream-event.block::before { background: #ef746a; box-shadow: 0 0 8px #ef746a; } .stream-event time { color: #69877d; font: 8px/1 Consolas, monospace; }.stream-event code { overflow: hidden; color: #d8e7e0; font: 10px/1 Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.stream-event b { text-align: right; color: #8bb6a6; font: 700 8px/1 Consolas, monospace; } -.capability-grid { margin-top: 13px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } -.capability-card { min-height: 248px; padding: 17px; border: 1px solid #294840; background: linear-gradient(145deg, #152c28e8, #10221fe8); display: flex; flex-direction: column; } +.capability-grid { margin-top: 13px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.capability-card { min-height: 285px; padding: 21px; border: 1px solid #294840; background: linear-gradient(145deg, #152c28e8, #10221fe8); display: flex; flex-direction: column; } .capability-card:hover { border-color: #477c6d; transform: translateY(-2px); transition: .2s ease; } -.capability-top { display: flex; align-items: center; gap: 7px; color: #91aa9f; font: 700 8px/1 Consolas, monospace; letter-spacing: .09em; } +.capability-top { display: flex; align-items: center; gap: 9px; color: #91aa9f; font: 700 9px/1 Consolas, monospace; letter-spacing: .09em; } .capability-icon { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3e685c; color: #79dbb2; font-size: 12px; } .capability-state { margin-left: auto; color: #65d4a7; font: normal 7px/1 Consolas, monospace; } .capability-state.alert { color: #efad68; } -.capability-card > strong { display: block; margin-top: 19px; color: #e3f1eb; font: 500 16px/1.2 Georgia, serif; } -.capability-card > p { margin: 8px 0 12px; color: #819c92; font-size: 10px; } +.capability-card > strong { display: block; margin-top: 19px; color: #e3f1eb; font: 500 20px/1.2 Georgia, serif; } +.capability-card > p { margin: 8px 0 12px; color: #819c92; font-size: 11px; } .observation-list { margin-top: 10px; border-top: 1px solid #29443e; }.observation-list p { min-height: 42px; margin: 0; padding: 8px 0; border-bottom: 1px solid #223b35; }.observation-list span, .observation-list b { display: block; }.observation-list span { margin-bottom: 4px; color: #64877a; font: 700 6px/1 Consolas, monospace; letter-spacing: .12em; }.observation-list b { overflow: hidden; color: #9db7ad; font-size: 9px; font-weight: 500; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.next-action { margin: 12px 0; padding: 11px 12px; border-left: 3px solid #66d6a8; background: #19322d; }.next-action span, .next-action b { display: block; }.next-action span { color: #6e9a89; font: 700 6px/1 Consolas, monospace; letter-spacing: .13em; }.next-action b { margin-top: 5px; color: #b7d2c7; font-size: 10px; font-weight: 500; line-height: 1.4; } .capability-action { width: 100%; min-height: 31px; margin-top: auto; padding: 8px 0 0; border: 0; border-top: 1px solid #315249; display: flex; align-items: center; justify-content: space-between; color: #71dab0; background: transparent; font: 700 8px/1 Consolas, monospace; letter-spacing: .07em; text-decoration: none; }.capability-action:hover { color: #b4f3d7; }.capability-action b { font-size: 12px; } .velocity-bars { height: 24px; display: flex; align-items: end; gap: 3px; } .velocity-bars i { flex: 1; height: 25%; background: #447263; animation: bar-rise 1.8s ease-in-out infinite alternate; }.velocity-bars i:nth-child(2n) { height: 60%; animation-delay: -.5s; }.velocity-bars i:nth-child(3n) { height: 95%; animation-delay: -1s; }.velocity-bars i:nth-child(5n) { background: #c87f42; } @@ -176,6 +182,9 @@ td code { color: var(--ink); font-family: Consolas, monospace; } .audit-risk-value { display: inline-grid; width: 34px; height: 34px; place-items: center; border: 1px solid currentColor; border-radius: 50%; font: 700 10px/1 Consolas, monospace; }.risk-allow { color: var(--green); }.risk-review { color: var(--amber); }.risk-block { color: var(--red); } .audit-detail-row td { padding: 0 10px 16px; background: #edf3ee; }.audit-explanation { padding: 22px; border: 1px solid #b8c8bf; border-left: 4px solid currentColor; color: var(--green); background: #fbfcf9; box-shadow: 0 12px 28px #18352d0d; }.audit-explanation.decision-review { color: var(--amber); }.audit-explanation.decision-block { color: var(--red); } .audit-filter-bar { margin: -8px 0 18px; padding: 11px 13px; border-left: 3px solid var(--amber); background: #f6eee4; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.audit-filter-bar[hidden] { display: none; }.audit-filter-bar span, .audit-filter-bar strong { display: block; }.audit-filter-bar span { color: #9a6a35; font: 700 7px/1 Consolas, monospace; letter-spacing: .12em; }.audit-filter-bar strong { margin-top: 4px; font-size: 11px; }.audit-filter-bar button { padding: 6px 8px; border: 1px solid #d4b38c; color: #8a541f; background: transparent; font-size: 9px; } +.audit-page-tools { min-height: 48px; padding: 8px 11px; border: 1px solid var(--line); border-bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 15px; background: #f3f5f1; }.audit-page-tools strong, .audit-page-tools span { display: block; }.audit-page-tools strong { color: var(--ink); font: 700 10px/1.2 Consolas, monospace; }.audit-page-tools span { margin-top: 3px; color: #84918b; font-size: 8px; }.audit-page-tools label { margin: 0; display: flex; align-items: center; gap: 8px; color: #6c7974; font-size: 9px; }.audit-page-tools select { height: 30px; min-width: 62px; padding: 0 8px; border: 1px solid #c7d0c9; background: white; } +.audit-table-wrap { max-height: 650px; border: 1px solid var(--line); overflow: auto; }.audit-table-wrap table { min-width: 850px; }.audit-table-wrap thead th { position: sticky; top: 0; z-index: 2; background: #edf1ec; box-shadow: 0 1px #cfd7d0; } +.audit-pagination { min-height: 50px; padding: 10px 0 0; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }.audit-pagination button { min-width: 92px; padding: 8px 10px; border: 1px solid #bdc9c1; color: var(--green); background: #f7f8f4; font-size: 9px; }.audit-pagination button:hover:not(:disabled) { border-color: var(--green); background: var(--green-soft); }.audit-pagination button:disabled { color: #a1aaa5; cursor: default; opacity: .55; }.audit-pagination span { min-width: 88px; color: #66736e; font: 700 9px/1 Consolas, monospace; text-align: center; } .audit-explanation-head { display: flex; gap: 20px; align-items: center; }.audit-explanation-head > div:first-child { flex: 1; }.audit-explanation-head h3 { margin: 0; color: var(--ink); font: 500 23px/1.2 Georgia, serif; }.audit-explanation-head p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); }.audit-risk-orb { flex: 0 0 74px; height: 74px; border: 1px solid currentColor; border-radius: 50%; display: grid; place-content: center; text-align: center; }.audit-risk-orb strong { color: var(--ink); font: 500 25px/1 Georgia, serif; }.audit-risk-orb small { margin-top: 4px; font: 7px/1 Consolas, monospace; } .audit-risk-track { height: 4px; margin: 18px 0; overflow: hidden; background: #dfe6e1; }.audit-risk-track span { display: block; height: 100%; background: currentColor; transition: width .5s ease; } .audit-context-grid { display: grid; grid-template-columns: .8fr 1fr 1.5fr .8fr; gap: 1px; border: 1px solid var(--line); background: var(--line); }.audit-context-grid > div { min-height: 63px; padding: 12px; background: #f3f6f2; }.audit-context-grid small, .audit-context-grid strong { display: block; }.audit-context-grid small { color: #84918b; font: 7px/1 Consolas, monospace; letter-spacing: .1em; }.audit-context-grid strong { margin-top: 7px; color: var(--ink); font-size: 11px; } @@ -183,6 +192,11 @@ td code { color: var(--ink); font-family: Consolas, monospace; } .decision-pill { padding: 4px 7px; font: 700 8px/1 Consolas, monospace; text-transform: uppercase; } .decision-pill.allow { color: var(--green); background: var(--green-soft); }.decision-pill.review { color: #8f5016; background: var(--amber-soft); }.decision-pill.block { color: var(--red); background: var(--red-soft); } .policy-grid { display: grid; grid-template-columns: .85fr 1.15fr; gap: 20px; align-items: start; } +.security-policy-panel { grid-column: 1 / -1; border-top: 3px solid #315f50; }.security-policy-panel .section-heading p:not(.eyebrow) { max-width: 760px; color: var(--muted); } +.security-policy-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.security-policy-card { min-width: 0; padding: 19px; border: 1px solid #d0d9d2; border-top: 3px solid #3a8a6c; background: #f5f7f3; }.security-policy-card.disabled { opacity: .65; border-top-color: #929e98; }.security-policy-card.has-cve { grid-column: 1 / -1; }.security-policy-head { display: flex; justify-content: space-between; align-items: center; }.security-policy-head > span { width: 44px; height: 29px; display: grid; place-items: center; color: #eafff5; background: var(--green); font: 700 8px/1 Consolas, monospace; letter-spacing: .08em; }.security-policy-head > div { display: flex; gap: 6px; align-items: center; }.security-policy-head small, .security-policy-head b { padding: 4px 6px; font: 700 7px/1 Consolas, monospace; }.security-policy-head small { color: var(--green); background: var(--green-soft); }.security-policy-head b { color: #52665e; border: 1px solid #c6d1ca; }.security-policy-card h3 { margin: 15px 0 5px; color: var(--ink); font: 500 20px/1.2 Georgia, serif; }.security-policy-card > p:not(.config-caption) { min-height: 34px; margin: 0; color: var(--muted); font-size: 10px; } +.policy-coverage { margin-top: 15px; padding: 11px; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: center; background: #eaf0eb; }.policy-coverage div strong, .policy-coverage div span { display: block; }.policy-coverage div strong { font: 600 15px/1 Consolas, monospace; }.policy-coverage div span { margin-top: 4px; color: #7c8c85; font: 6px/1 Consolas, monospace; letter-spacing: .1em; }.policy-coverage > b { color: var(--green); font: 500 25px/1 Georgia, serif; }.policy-coverage-track { height: 4px; background: #d5ddd7; }.policy-coverage-track span { display: block; height: 100%; background: linear-gradient(90deg, #20815f, #70d9ac); } +.policy-rule-list { margin: 13px 0; display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }.policy-rule-list span { color: #52665e; font-size: 9px; }.policy-rule-list span::first-letter { color: var(--green); }.policy-group-list { display: flex; flex-wrap: wrap; gap: 5px; }.policy-group-list button { padding: 5px 7px; border: 1px solid #b9c9bf; color: var(--green); background: #edf3ef; font: 700 7px/1 Consolas, monospace; }.policy-group-list button:hover { border-color: var(--green); background: #dcece3; } +.policy-cve-list { margin-top: 17px; padding-top: 14px; border-top: 1px solid var(--line); display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }.policy-cve-list > .config-caption { grid-column: 1 / -1; margin-top: 0; }.policy-cve-alert { padding: 11px; border-left: 3px solid var(--amber); background: #f2eee7; }.policy-cve-alert.critical { border-left-color: var(--red); background: #f4eae8; }.policy-cve-alert > div { display: flex; justify-content: space-between; gap: 8px; }.policy-cve-alert strong { font: 700 10px/1 Consolas, monospace; }.policy-cve-alert span { color: var(--red); font: 700 6px/1 Consolas, monospace; }.policy-cve-alert p { min-height: 43px; margin: 8px 0; color: #5d6763; font-size: 9px; }.policy-cve-alert small { color: #7d8984; font: 7px/1 Consolas, monospace; text-transform: uppercase; }.policy-endpoints { margin-top: 14px; border-top: 1px solid var(--line); }.policy-endpoints summary { padding: 10px 0 0; color: var(--green); cursor: pointer; font: 700 8px/1 Consolas, monospace; }.policy-endpoints ul { margin: 10px 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; list-style: none; }.policy-endpoints li { padding: 7px; display: flex; justify-content: space-between; align-items: center; background: #eaf0eb; }.policy-endpoints li small { color: #7a8983; font-size: 8px; }.policy-intelligence-notice { margin: 13px 0 0; padding: 10px 12px; border-left: 3px solid var(--amber); color: #746557; background: #f6f1e8; font-size: 9px; } #policy-form > input { margin-bottom: 19px; } .threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .threshold-row label { padding: 14px; background: #f1f4ef; } @@ -211,6 +225,15 @@ select:focus { border-color: var(--green); box-shadow: 0 0 0 2px #d9ebe2; } .flow-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }.flow-selector label { min-height: 47px; margin: 0; padding: 9px 10px; border: 1px solid #d1d8d2; display: grid; grid-template-columns: 37px 16px 37px 1fr; gap: 3px; align-items: center; cursor: pointer; color: #74827c; transition: .18s ease; }.flow-selector label span { padding: 4px; border: 1px solid #cbd5cd; font: 700 7px/1 Consolas, monospace; text-align: center; }.flow-selector label b { color: #9ba7a1; text-align: center; }.flow-selector label small { text-align: right; font-size: 8px; }.flow-selector label:has(input:checked) { border-color: #5a9d83; color: var(--green); background: #e8f3ed; }.flow-selector label:has(input:checked) span { border-color: #78ad98; } .deployment-note { margin: 17px 0 0; padding: 10px 12px; border-left: 3px solid var(--amber); color: var(--muted); background: #f6f1e9; font-size: 10px; }.deployment-note b { color: #83501f; } .trust-save-bar { grid-column: 1 / -1; min-height: 66px; padding: 12px 14px 12px 18px; border: 1px solid #31534a; background: #203a35; display: flex; align-items: center; justify-content: space-between; gap: 20px; }.trust-save-bar strong, .trust-save-bar small { display: block; }.trust-save-bar strong { color: #dff0e8; font-size: 12px; }.trust-save-bar small { margin-top: 3px; color: #779087; font-size: 9px; } +.admin-panel { padding: 30px; border: 1px solid #294840; color: #dcebe5; background: #10221f; box-shadow: 0 20px 55px #13201d1a; } +.admin-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }.admin-heading h2 { margin: 0; color: #eef8f3; font-size: 29px; }.admin-heading > div:first-child > p:last-child { max-width: 680px; color: #84a096; }.admin-actions { display: flex; align-items: center; gap: 10px; }.admin-actions .control-live-dot { color: #81e1b9; background: #18332d; }.admin-actions .control-live-dot i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #70e3b2; box-shadow: 0 0 10px #70e3b2; }.admin-actions .secondary-button { white-space: nowrap; } +.admin-stat-grid { margin: 24px 0 12px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; border: 1px solid #315149; background: #315149; }.admin-stat-grid article { min-height: 102px; padding: 17px; background: #19312d; }.admin-stat-grid small, .admin-stat-grid strong, .admin-stat-grid span { display: block; }.admin-stat-grid small { color: #72978a; font: 700 7px/1 Consolas, monospace; letter-spacing: .11em; }.admin-stat-grid strong { margin: 11px 0 6px; color: #9af0ca; font: 500 27px/1 Georgia, serif; }.admin-stat-grid span { color: #68857b; font-size: 9px; } +.admin-layout { display: grid; grid-template-columns: 1.35fr .85fr; gap: 12px; }.admin-layout .panel { min-width: 0; }.discovery-card, .deployment-card { background: #f5f7f3; }.security-group-list { margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 7px; }.security-group-list button { padding: 9px 10px; border: 1px solid #c8d4cd; color: #50615b; background: #fff; text-align: left; }.security-group-list button:hover { border-color: #4f967b; background: #e8f3ed; }.security-group-list b, .security-group-list span { display: block; }.security-group-list b { font: 700 9px/1 Consolas, monospace; }.security-group-list span { margin-top: 4px; color: #7d8c86; font-size: 8px; } +.endpoint-table-wrap { max-height: 435px; overflow: auto; }.endpoint-table-wrap table { min-width: 840px; }.endpoint-table-wrap td { vertical-align: middle; }.endpoint-table-wrap td > strong, .endpoint-table-wrap td > small { display: block; }.endpoint-table-wrap td > small { margin-top: 3px; color: #8a9791; font: 7px/1 Consolas, monospace; }.endpoint-table-wrap td code { font-size: 9px; }.endpoint-table-wrap td em { color: #a26328; font-size: 9px; }.endpoint-groups { display: flex; flex-wrap: wrap; gap: 3px; }.endpoint-groups span { padding: 3px 4px; color: #527666; background: #e5eee8; font: 6px/1 Consolas, monospace; }.endpoint-state { display: inline-flex; align-items: center; gap: 5px; text-transform: uppercase; font: 700 7px/1 Consolas, monospace; }.endpoint-state i { width: 6px; height: 6px; border-radius: 50%; background: #84928d; }.endpoint-state.online { color: var(--green); }.endpoint-state.online i { background: #4fc38f; box-shadow: 0 0 8px #4fc38f; }.endpoint-state.offline { color: #8c6d50; } +.deployment-fields { grid-template-columns: 1fr 1fr; }.network-command { min-height: 68px; padding: 13px; border: 1px solid #284b42; display: block; color: #a7e7cc; background: #142b26; font: 9px/1.6 Consolas, monospace; word-break: break-word; }.deployment-guard { padding: 10px 12px; border-left: 3px solid var(--green); color: #687870; background: #edf2ed; font-size: 10px; }.deployment-guard b { color: #315d4b; }.deployment-buttons { margin-top: 16px; display: flex; gap: 8px; }.deployment-history-heading { margin: 25px 0 10px; padding-top: 17px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }.deployment-history-heading p { margin: 0; }.deployment-list { max-height: 250px; overflow: auto; display: grid; gap: 6px; }.deployment-wave { padding: 11px; border-left: 3px solid #809189; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; color: var(--ink); background: #edf1ed; }.deployment-wave.queued, .deployment-wave.in-progress { border-left-color: var(--green); }.deployment-wave.attention-required { border-left-color: var(--red); }.deployment-wave strong, .deployment-wave small { display: block; }.deployment-wave strong { font-size: 10px; }.deployment-wave small { margin-top: 4px; color: var(--muted); font-size: 8px; }.deployment-wave span { color: var(--green); font: 700 7px/1 Consolas, monospace; }.deployment-wave > b { grid-column: 1 / -1; color: #77857f; font-size: 8px; } +.inventory-panel { border-top: 3px solid #315f50; }.inventory-heading > div:first-child p:not(.eyebrow) { max-width: 760px; color: var(--muted); }.inventory-search { width: min(320px, 100%); }.inventory-search input { height: 38px; } +.inventory-summary-grid { margin-bottom: 15px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; border: 1px solid var(--line); background: var(--line); }.inventory-summary-grid div { padding: 13px 15px; background: #eff3ee; }.inventory-summary-grid small, .inventory-summary-grid strong { display: block; }.inventory-summary-grid small { color: #7c8a84; font: 700 7px/1 Consolas, monospace; letter-spacing: .12em; }.inventory-summary-grid strong { margin-top: 7px; color: var(--ink); font: 500 22px/1 Georgia, serif; } +.inventory-group-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }.inventory-group-card { min-width: 0; padding: 16px; border: 1px solid #ccd6ce; background: #f5f7f3; }.inventory-group-head { display: flex; justify-content: space-between; align-items: center; }.inventory-group-head span { color: #819089; font: 700 6px/1 Consolas, monospace; letter-spacing: .12em; }.inventory-group-head h3 { margin: 5px 0 0; font: 500 18px/1.2 Georgia, serif; }.inventory-group-head > strong { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid #7da38f; border-radius: 50%; color: var(--green); font: 500 16px/1 Georgia, serif; }.inventory-group-stats { margin: 13px 0 8px; display: flex; flex-wrap: wrap; gap: 5px; }.inventory-group-stats span { padding: 4px 6px; color: #5e7269; background: #e4ebe5; font: 7px/1 Consolas, monospace; }.inventory-policy-tags { min-height: 31px; display: flex; flex-wrap: wrap; gap: 4px; }.inventory-policy-tags span { padding: 4px 5px; color: #2f7359; border: 1px solid #bdd0c5; font: 6px/1 Consolas, monospace; }.inventory-group-card ul { margin: 11px 0 0; padding: 0; list-style: none; }.inventory-group-card li { padding: 9px 0; border-top: 1px solid #dce3dd; display: flex; justify-content: space-between; gap: 8px; align-items: center; }.inventory-group-card li > div > small { display: block; margin-top: 4px; color: #7b8983; font-size: 8px; }.inventory-group-card li > b { color: #5d6f67; font: 700 7px/1 Consolas, monospace; } .model-advisory-result { margin-top: 18px; padding: 18px; border: 1px solid #aabbb3; background: #edf3ef; } .model-advisory-head { display: flex; align-items: center; gap: 12px; } .model-orb { width: 37px; height: 37px; border: 1px solid var(--green); border-radius: 50%; display: grid; place-items: center; color: var(--green); font: 700 9px/1 Consolas, monospace; } @@ -240,9 +263,11 @@ dialog h2 { margin-bottom: 10px; } dialog p:not(.eyebrow) { color: var(--muted); #toast.show { transform: translateY(0); opacity: 1; } @media (max-width: 1050px) { - .app-shell { grid-template-columns: 82px 1fr; }.sidebar { padding: 27px 13px; }.brand > span:last-child, .nav-link:not(.active) { font-size: 0; }.brand { justify-content: center; }.sidebar nav { margin-top: 45px; }.nav-link { justify-content: center; gap: 0; }.nav-link span { font-size: 9px; }.nav-link b { display: none; }.sidebar-foot div { display: none; }.sidebar-foot { justify-content: center; }.overview-grid, .policy-grid { grid-template-columns: 1fr; }.capability-grid, .trust-controls-layout { grid-template-columns: 1fr 1fr; }.recent-model-determinations { grid-template-columns: 1fr 1fr; } + .app-shell { grid-template-columns: 82px 1fr; }.sidebar { padding: 27px 13px; }.brand > span:last-child, .nav-link:not(.active) { font-size: 0; }.brand { justify-content: center; }.sidebar nav { margin-top: 45px; }.nav-link { justify-content: center; gap: 0; }.nav-link span { font-size: 9px; }.nav-link b { display: none; }.sidebar-foot div { display: none; }.sidebar-foot { justify-content: center; }.overview-grid, .policy-grid { grid-template-columns: 1fr; }.capability-grid, .trust-controls-layout { grid-template-columns: 1fr 1fr; }.admin-stat-grid { grid-template-columns: repeat(3, 1fr); }.admin-layout { grid-template-columns: 1fr; }.recent-model-determinations { grid-template-columns: 1fr 1fr; } } @media (max-width: 700px) { - .app-shell { display: block; }.sidebar { position: static; width: 100%; height: auto; padding: 15px 18px; flex-direction: row; align-items: center; }.sidebar nav { margin: 0 0 0 auto; display: flex; }.nav-link { padding: 9px; }.nav-link span { display: none; }.nav-link.active { font-size: 0; }.nav-link.active::after { content: "MENU"; font-size: 9px; }.sidebar-foot { display: none; } main { padding: 0 17px 20px; }.topbar { height: 88px; }.topbar h1 { font-size: 23px; }.token-button span:last-child { display: none; }.overview-grid { grid-template-columns: 1fr; }.hero-card { min-height: 315px; padding: 25px; flex-direction: column; align-items: flex-start; }.hero-card h2 { font-size: 29px; }.posture-ring { margin: 25px 0 0; }.metric-grid { grid-template-columns: 1fr 1fr; }.panel { padding: 21px 17px; }.intelligence-fabric, .trust-controls-grid { padding: 22px 16px; }.fabric-heading h2 { font-size: 24px; }.fabric-body, .capability-grid, .trust-controls-layout { grid-template-columns: 1fr; }.ai-core-wrap { min-height: 180px; }.form-row, .threshold-row, .model-config-grid, .recent-model-determinations { grid-template-columns: 1fr; }.model-live-banner { grid-template-columns: 55px 1fr; }.model-health-grid { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); }.model-pipeline { align-items: flex-start; flex-direction: column; }.zone-selector { grid-template-columns: 1fr 1fr; }.flow-selector { grid-template-columns: 1fr; }.audit-context-grid { grid-template-columns: 1fr 1fr; }.safe-label { display: none; }.signal { grid-template-columns: 42px 1fr; }.signal span { grid-column: 1 / -1; }.review-item { grid-template-columns: 1fr; }.review-actions { justify-content: flex-start; } footer { gap: 15px; flex-direction: column; } + .app-shell { display: block; }.sidebar { position: static; width: 100%; height: auto; padding: 15px 18px; flex-direction: row; align-items: center; }.sidebar nav { margin: 0 0 0 auto; display: flex; }.nav-link { padding: 9px; }.nav-link span { display: none; }.nav-link.active { font-size: 0; }.nav-link.active::after { content: "MENU"; font-size: 9px; }.sidebar-foot { display: none; } main { padding: 0 17px 20px; }.topbar { height: 88px; }.topbar h1 { font-size: 23px; }.token-button span:last-child { display: none; }.overview-grid { grid-template-columns: 1fr; }.hero-card { min-height: 315px; padding: 25px; flex-direction: column; align-items: flex-start; }.hero-card h2 { font-size: 29px; }.posture-ring { margin: 25px 0 0; }.metric-grid { grid-template-columns: 1fr 1fr; }.panel { padding: 21px 17px; }.intelligence-fabric, .trust-controls-grid, .admin-panel { padding: 22px 16px; }.fabric-heading h2 { font-size: 24px; }.fabric-body, .capability-grid, .trust-controls-layout, .admin-layout { grid-template-columns: 1fr; }.admin-heading { flex-direction: column; }.admin-actions { width: 100%; justify-content: space-between; }.admin-stat-grid { grid-template-columns: 1fr 1fr; }.deployment-buttons { align-items: stretch; flex-direction: column; }.ai-core-wrap { min-height: 180px; }.form-row, .threshold-row, .model-config-grid, .recent-model-determinations { grid-template-columns: 1fr; }.model-live-banner { grid-template-columns: 55px 1fr; }.model-health-grid { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); }.model-pipeline { align-items: flex-start; flex-direction: column; }.zone-selector { grid-template-columns: 1fr 1fr; }.flow-selector { grid-template-columns: 1fr; }.audit-context-grid { grid-template-columns: 1fr 1fr; }.safe-label { display: none; }.signal { grid-template-columns: 42px 1fr; }.signal span { grid-column: 1 / -1; }.review-item { grid-template-columns: 1fr; }.review-actions { justify-content: flex-start; } footer { gap: 15px; flex-direction: column; } } +@media (max-width: 1050px) { .attention-board { grid-template-columns: 1fr 1fr; }.security-policy-cards { grid-template-columns: 1fr; }.security-policy-card.has-cve { grid-column: auto; }.inventory-group-grid { grid-template-columns: 1fr 1fr; } } +@media (max-width: 700px) { .attention-board { grid-template-columns: 1fr; }.policy-cve-list, .policy-endpoints ul, .inventory-group-grid { grid-template-columns: 1fr; }.inventory-heading { align-items: stretch; flex-direction: column; }.inventory-search { width: 100%; }.inventory-summary-grid { grid-template-columns: 1fr 1fr; }.audit-page-tools { align-items: flex-start; flex-direction: column; }.audit-pagination { justify-content: space-between; }.audit-pagination button { min-width: 82px; }.audit-table-wrap { max-height: 540px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } } diff --git a/src/intentgate/web/console.js b/src/intentgate/web/console.js index df2a38c..53c0ed4 100644 --- a/src/intentgate/web/console.js +++ b/src/intentgate/web/console.js @@ -2,10 +2,15 @@ const isLoopbackDemo = ["127.0.0.1", "localhost", "::1"].includes(window.locatio const state = { token: sessionStorage.getItem("uig-token") || (isLoopbackDemo ? "local-dev-change-me" : ""), policy: null, + securityPolicies: null, model: null, trustControls: null, + inventory: null, + deployments: [], auditEvents: [], auditFilter: null, + auditPage: 1, + auditPageSize: 10, }; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; @@ -64,6 +69,12 @@ function renderIntelligenceFabric(events) { const controls = state.trustControls; const zero = controls?.zero_trust; const micro = controls?.microsegmentation; + const urgentConditions = highRisk.length || trustGaps.length || burst.length >= 3; + $("#priority-high-risk").textContent = highRisk.length; + $("#priority-trust-gaps").textContent = trustGaps.length; + $("#attention-summary").textContent = urgentConditions + ? `${highRisk.length} high-risk actions and ${trustGaps.length} trust-context gaps need triage. Start with scored evidence.` + : "No urgent conditions detected. Continue monitoring the live decision stream."; $("#fabric-event-count").textContent = `${events.length} EVENTS`; $("#ml-sample-count").textContent = events.length; $("#ml-coverage").textContent = `${maturity}%`; @@ -85,6 +96,11 @@ function renderIntelligenceFabric(events) { $("#behavior-outlier").textContent = topAnomaly ? `${topAnomaly.command} · ${topAnomaly.user_name || "unknown user"} · deviation ${Number(topAnomaly.anomaly_score || 0)}/40` : "No behavioral outlier in the current window."; + $("#behavior-recommendation").textContent = burst.length >= 3 + ? `Validate the ${burst.length}-command mutation burst and temporarily step up the actor if unexpected.` + : topAnomaly + ? `Confirm whether ${topAnomaly.user_name || "the operator"} expected the highest-deviation command sequence.` + : "Keep collecting representative behavior before tightening anomaly policy."; $("#micro-headline").textContent = `${micro?.enabled_zones?.length ?? 5} zones · default ${micro?.default_action || "deny"}`; $("#micro-flows").textContent = `${micro?.allowed_flows?.length ?? 5} explicitly allowed service paths`; const pendingTopology = micro?.deployment_status === "redeploy-required"; @@ -93,6 +109,11 @@ function renderIntelligenceFabric(events) { $("#micro-observation").textContent = pendingTopology ? "A saved topology change is waiting for Docker redeployment." : `${micro?.log_denied === false ? "Denied-flow logging is disabled." : "Denied flows are logged; no topology drift is pending."}`; + $("#micro-recommendation").textContent = pendingTopology + ? "Review the staged path changes and schedule a controlled redeployment." + : micro?.log_denied === false + ? "Enable denied-flow logging before changing the allowed-path policy." + : "Review denied-flow telemetry before adding any new service path."; $("#trust-headline").textContent = `${coverage}% actor + endpoint coverage`; $("#trust-policy").textContent = zero ? `${zero.enforcement_mode} mode · step-up at ${zero.step_up_threshold}/100` @@ -102,6 +123,12 @@ function renderIntelligenceFabric(events) { : "No identity, endpoint, or intent gaps in the current window."; $("#trust-state").textContent = trustGaps.length ? "GAPS FOUND" : "VERIFIED"; $("#trust-state").classList.toggle("alert", trustGaps.length > 0); + $("#trust-recommendation").textContent = trustGaps.length + ? `Enrich ${trustGaps.length} records with identity, endpoint, or declared intent before enforce mode.` + : "Trust context is complete; review step-up thresholds for least privilege."; + $("#ml-recommendation").textContent = highRisk.length + ? `Label the ${highRisk.length} high-risk outliers as expected or suspicious to improve precision.` + : maturity < 80 ? "Collect more representative command families before relying on rarity." : "Baseline coverage is mature; review drift weekly."; const visible = events.slice(0, 5); $("#fabric-event-stream").innerHTML = visible.length ? visible.map((item) => `
@@ -213,11 +240,12 @@ function getFilteredAuditEvents() { : state.auditFilter === "trust-gap" ? events.filter((item) => !item.purpose || !item.user_name || !item.endpoint_name || item.endpoint_name === "unknown-endpoint") : events; - return filtered.slice(0, 100); + return filtered; } function setAuditFilter(filter) { state.auditFilter = filter; + state.auditPage = 1; const labels = { behavior: "Behavioral anomalies and sequence deviations", "high-risk": "High-risk policy outliers (80+)", @@ -232,10 +260,18 @@ function setAuditFilter(filter) { function renderAudit(events) { const body = $("#audit-body"); - if (!events.length) { body.innerHTML = 'No assessments recorded.'; return; } - body.innerHTML = events.map((item, index) => { + const pageCount = Math.max(1, Math.ceil(events.length / state.auditPageSize)); + state.auditPage = Math.max(1, Math.min(state.auditPage, pageCount)); + const start = (state.auditPage - 1) * state.auditPageSize; + const visible = events.slice(start, start + state.auditPageSize); + $("#audit-range").textContent = events.length ? `Showing ${start + 1}–${start + visible.length} of ${events.length}` : "0 events"; + $("#audit-page-label").textContent = `Page ${state.auditPage} of ${pageCount}`; + $("#audit-previous").disabled = state.auditPage <= 1; + $("#audit-next").disabled = state.auditPage >= pageCount; + if (!visible.length) { body.innerHTML = 'No assessments recorded.'; return; } + body.innerHTML = visible.map((item, index) => { const risk = Math.max(0, Math.min(100, Number(item.risk_score || 0))); - const detailId = `audit-detail-${index}`; + const detailId = `audit-detail-${start + index}`; const evidence = Array.isArray(item.signal_details) && item.signal_details.length ? item.signal_details : (item.signals || []).map((name) => ({ name, score: null, detail: "Signal recorded before detailed evidence capture was enabled." })); @@ -267,6 +303,12 @@ function renderAudit(events) { }).join(""); } +function changeAuditPage(delta) { + state.auditPage += delta; + renderAudit(getFilteredAuditEvents()); + $("#audit").scrollIntoView({ behavior: "smooth", block: "start" }); +} + function toggleAuditDetail(row) { const detail = document.getElementById(row.dataset.auditToggle); if (!detail) return; @@ -284,6 +326,7 @@ async function loadAudit() { function renderReviews(reviews) { const pending = reviews.filter((item) => item.status === "pending"); $("#review-badge").textContent = pending.length; + $("#priority-reviews").textContent = pending.length; const list = $("#review-list"); if (!reviews.length) { list.innerHTML = '

No commands are waiting for review.

'; return; } list.innerHTML = reviews.slice(0, 30).map((item) => `
@@ -408,6 +451,145 @@ async function loadTrustControls() { catch (error) { if (state.token) toast(error.message); } } +function renderSecurityPolicies(data) { + state.securityPolicies = data; + const summary = data.summary || {}; + $("#security-policy-summary").textContent = `${summary.active || 0}/${summary.domains || 0} ACTIVE · ${summary.known_exploited_cves || 0} EXPLOITED CVEs`; + $("#cve-intelligence-notice").textContent = data.intelligence_notice || ""; + $("#security-policy-cards").innerHTML = (data.policies || []).map((policy) => { + const coverage = policy.total_endpoint_count ? Math.round(policy.covered_endpoint_count / policy.total_endpoint_count * 100) : 0; + const groups = (policy.assigned_groups || []).map((group) => ``).join(""); + const endpoints = (policy.covered_endpoints || []).map((endpoint) => `
  • ${escapeHtml(endpoint.hostname)}${endpoint.agent_version ? `agent v${escapeHtml(endpoint.agent_version)}` : "agent not installed"}
  • `).join(""); + const cves = (policy.cve_alerts || []).map((alert) => `
    ${escapeHtml(alert.id)}${alert.known_exploited ? "KNOWN EXPLOITED" : "INVESTIGATE"}

    ${escapeHtml(alert.summary)}

    ${alert.affected_endpoints} affected · ${escapeHtml(alert.status)}
    `).join(""); + return `
    +
    ${escapeHtml(policy.domain)}
    ${policy.enabled ? "ACTIVE" : "DISABLED"}${escapeHtml(policy.enforcement_mode).toUpperCase()}
    +

    ${escapeHtml(policy.name)}

    ${escapeHtml(policy.description)}

    +
    ${policy.covered_endpoint_count}/${policy.total_endpoint_count}ENDPOINTS COVERED
    ${policy.online_endpoint_count}ONLINE
    ${coverage}%
    +
    +
    ${(policy.rules || []).map((rule) => `✓ ${escapeHtml(rule)}`).join("")}
    +

    ASSIGNED SECURITY GROUPS

    ${groups}
    + ${cves ? `

    MALICIOUS CVE INTELLIGENCE

    ${cves}
    ` : ""} +
    View ${policy.covered_endpoint_count} covered endpoints
      ${endpoints}
    +
    `; + }).join(""); + if (state.inventory) renderGroupInventory(state.inventory, $("#inventory-search")?.value || ""); +} + +async function loadSecurityPolicies() { + try { renderSecurityPolicies(await api("/v1/security-policies")); } + catch (error) { if (state.token) toast(error.message); } +} + +function updateDeploymentCommand() { + const group = $("#deploy-group").value || ""; + const version = $("#deploy-version").value || "0.4.0"; + $("#network-command-preview").textContent = `uig-admin deploy --server http://intentgate.example:8787 --group ${group} --version ${version}`; +} + +function renderInventory(data) { + state.inventory = data; + const summary = data.summary || {}; + $("#admin-total").textContent = summary.total || 0; + $("#admin-online").textContent = summary.online || 0; + $("#admin-managed").textContent = summary.managed || 0; + $("#admin-groups").textContent = summary.groups || 0; + $("#admin-queued").textContent = summary.queued_jobs || 0; + const latest = data.latest_discovery; + $("#discovery-status").textContent = latest ? `${String(latest.status).toUpperCase()} · ${formatTime(latest.completed_at)}` : "INVENTORY READY"; + const groups = data.security_groups || []; + $("#security-group-list").innerHTML = groups.length ? groups.map((group) => ``).join("") : '

    No security groups discovered.

    '; + const selectedGroup = $("#deploy-group").value; + $("#deploy-group").innerHTML = '' + groups.map((group) => ``).join(""); + if (groups.some((group) => group.name === selectedGroup)) $("#deploy-group").value = selectedGroup; + const endpoints = data.endpoints || []; + $("#endpoint-body").innerHTML = endpoints.length ? endpoints.map((item) => ` + ${escapeHtml(item.hostname)}${escapeHtml(item.id)} + ${escapeHtml(item.ip_address)}${escapeHtml(item.operating_system)} +
    ${(item.security_groups || []).map((group) => `${escapeHtml(group)}`).join("")}
    + ${item.agent_version ? `v${escapeHtml(item.agent_version)}` : 'not installed'} + ${escapeHtml(item.status)} + `).join("") : 'No endpoints discovered.'; + renderGroupInventory(data, $("#inventory-search")?.value || ""); + updateDeploymentCommand(); +} + +function renderGroupInventory(data, query = "") { + const endpoints = data.endpoints || []; + const groups = data.security_groups || []; + const normalized = query.trim().toLowerCase(); + $("#inventory-group-count").textContent = groups.length; + $("#inventory-endpoint-count").textContent = endpoints.length; + $("#inventory-online-count").textContent = endpoints.filter((item) => item.status === "online").length; + $("#inventory-managed-count").textContent = endpoints.filter((item) => item.agent_version).length; + const policies = state.securityPolicies?.policies || []; + const visible = groups.map((group) => ({ + ...group, + endpoints: endpoints.filter((item) => (item.security_groups || []).includes(group.name)), + policies: policies.filter((policy) => (policy.assigned_groups || []).includes(group.name)), + })).filter((group) => !normalized || group.name.toLowerCase().includes(normalized) || group.endpoints.some((item) => `${item.hostname} ${item.id}`.toLowerCase().includes(normalized))); + $("#inventory-group-grid").innerHTML = visible.length ? visible.map((group) => `
    +
    SECURITY GROUP

    ${escapeHtml(group.name)}

    ${group.endpoint_count}
    +
    ${group.online_count} online${group.managed_count} managed${group.policies.length} policies
    +
    ${group.policies.length ? group.policies.map((policy) => `${escapeHtml(policy.domain)} · ${escapeHtml(policy.enforcement_mode)}`).join("") : "NO POLICY ASSIGNMENT"}
    +
      ${group.endpoints.map((endpoint) => `
    • ${escapeHtml(endpoint.hostname)}${escapeHtml(endpoint.operating_system)} · ${escapeHtml(endpoint.ip_address)}
      ${endpoint.agent_version ? `v${escapeHtml(endpoint.agent_version)}` : "UNMANAGED"}
    • `).join("")}
    +
    `).join("") : '

    No security groups or endpoints match this filter.

    '; +} + +function openInventoryGroup(group) { + $("#inventory-search").value = group; + if (state.inventory) renderGroupInventory(state.inventory, group); + window.location.hash = "inventory"; + $("#inventory").scrollIntoView({ behavior: "smooth", block: "start" }); +} + +async function loadInventory() { + try { renderInventory(await api("/v1/endpoints")); } + catch (error) { if (state.token) toast(error.message); } +} + +function renderDeployments(deployments) { + state.deployments = deployments; + $("#deployment-list").innerHTML = deployments.length ? deployments.slice(0, 8).map((item) => { + const queued = (item.jobs || []).filter((job) => ["queued", "deferred"].includes(job.status)).length; + return `
    ${escapeHtml(item.security_group || "selected endpoints")} · v${escapeHtml(item.version)}${formatTime(item.created_at)} · ${escapeHtml(item.requested_by)}
    ${item.execute ? escapeHtml(item.status).toUpperCase() : "DRY-RUN PLAN"}${item.matched_endpoints} targets${queued ? ` · ${queued} queued` : ""}
    `; + }).join("") : '

    No deployments have been planned.

    '; +} + +async function loadDeployments() { + try { const data = await api("/v1/deployments?limit=20"); renderDeployments(data.deployments || []); } + catch (error) { if (state.token) toast(error.message); } +} + +async function runDiscovery() { + const button = $("#run-discovery"); + button.disabled = true; + button.textContent = "Discovering…"; + try { + const result = await api("/v1/discovery-sessions", { method: "POST", body: JSON.stringify({ requested_by: $("#deploy-requestor").value || "console-admin" }) }); + toast(`Discovery complete: ${result.endpoint_count} endpoints across ${result.security_group_count} groups.`); + await loadInventory(); + } catch (error) { toast(error.message); } + finally { button.disabled = false; button.textContent = "Run discovery session"; } +} + +async function submitDeployment(event) { + event.preventDefault(); + const execute = event.submitter?.value === "execute"; + const payload = { + security_group: $("#deploy-group").value, + version: $("#deploy-version").value, + execute, + requested_by: $("#deploy-requestor").value, + }; + event.submitter.disabled = true; + try { + const result = await api("/v1/deployments", { method: "POST", body: JSON.stringify(payload) }); + toast(execute ? `Deployment queued to ${result.matched_endpoints} endpoints.` : `Plan validated for ${result.matched_endpoints} endpoints.`); + await Promise.all([loadDeployments(), loadInventory()]); + } catch (error) { toast(error.message); } + finally { event.submitter.disabled = false; } +} + async function saveTrustControls(event) { event.preventDefault(); const submit = event.submitter; @@ -455,9 +637,30 @@ function bindEvents() { $$("[data-investigate]").forEach((button) => button.addEventListener("click", () => setAuditFilter(button.dataset.investigate))); $("#clear-audit-filter").addEventListener("click", () => setAuditFilter(null)); $("#refresh-audit").addEventListener("click", loadAudit); + $("#audit-previous").addEventListener("click", () => changeAuditPage(-1)); + $("#audit-next").addEventListener("click", () => changeAuditPage(1)); + $("#audit-page-size").addEventListener("change", (event) => { + state.auditPageSize = Number(event.target.value); + state.auditPage = 1; + renderAudit(getFilteredAuditEvents()); + }); $("#refresh-reviews").addEventListener("click", loadReviews); $("#policy-form").addEventListener("submit", savePolicy); + $("#security-policy-cards").addEventListener("click", (event) => { const group = event.target.closest("[data-inventory-group]"); if (group) openInventoryGroup(group.dataset.inventoryGroup); }); $("#trust-controls-form").addEventListener("submit", saveTrustControls); + $("#run-discovery").addEventListener("click", runDiscovery); + $("#deployment-form").addEventListener("submit", submitDeployment); + $("#refresh-deployments").addEventListener("click", loadDeployments); + $("#deploy-group").addEventListener("change", updateDeploymentCommand); + $("#deploy-version").addEventListener("input", updateDeploymentCommand); + $("#inventory-search").addEventListener("input", (event) => { if (state.inventory) renderGroupInventory(state.inventory, event.target.value); }); + $("#security-group-list").addEventListener("click", (event) => { + const selected = event.target.closest("[data-deploy-group]"); + if (!selected) return; + $("#deploy-group").value = selected.dataset.deployGroup; + updateDeploymentCommand(); + $("#deployment-form").scrollIntoView({ behavior: "smooth", block: "center" }); + }); $("#token-button").addEventListener("click", openTokenDialog); $("#token-form").addEventListener("submit", (event) => { if (event.submitter?.value === "cancel") return; @@ -474,7 +677,7 @@ function bindEvents() { sections.forEach((section) => observer.observe(section)); } -async function refreshAll() { await Promise.all([loadOverview(), loadReviews(), loadPolicy(), loadModelStatus(), loadTrustControls()]); } +async function refreshAll() { await Promise.all([loadOverview(), loadReviews(), loadPolicy(), loadSecurityPolicies(), loadModelStatus(), loadTrustControls(), loadInventory(), loadDeployments()]); } bindEvents(); $("#token-label").textContent = state.token ? "Token configured" : "Set API token"; diff --git a/src/intentgate/web/index.html b/src/intentgate/web/index.html index d1e5db4..9d35368 100644 --- a/src/intentgate/web/index.html +++ b/src/intentgate/web/index.html @@ -21,7 +21,9 @@ 04Audit Trail 05Policy 06Trust Controls - 07AI Advisor + 07Fleet Admin + 08Inventory + 09AI Advisor +
    +
    ACTION CENTERWhat needs attention now
    + + + PENDING REVIEWS0Make a human decision → +
    AI
    @@ -85,6 +94,7 @@

    AI-assisted. Behavior-aware. Zero trust.

    OBSERVATIONBuilding a behavioral baseline.

    TOP OUTLIERNot enough evidence yet

    +
    NEXT BEST ACTIONValidate the highest-deviation sequence with its owner.
    @@ -95,6 +105,7 @@

    AI-assisted. Behavior-aware. Zero trust.

    OBSERVATIONCollecting representative command families.

    +
    NEXT BEST ACTIONLabel high-risk outliers to improve baseline precision.
    @@ -105,6 +116,7 @@

    AI-assisted. Behavior-aware. Zero trust.

    OBSERVATIONNo pending topology changes.

    EDGEAPPOBSMGMTOUT
    +
    NEXT BEST ACTIONReview denied-flow telemetry before changing paths.
    Edit network paths →
    @@ -115,6 +127,7 @@

    AI-assisted. Behavior-aware. Zero trust.

    TRUST GAPSAnalyzing recent decisions.

    IDENTITY→INTENT→POLICY→AUDIT
    +
    NEXT BEST ACTIONRestore identity, endpoint, and intent coverage.
    @@ -161,15 +174,29 @@

    AI-assisted. Behavior-aware. Zero trust.

    -
    +
    +
    0 eventsNewest decisions first
    + +
    +
    TimeDecisionUserEndpointCommandRisk
    No assessments recorded.
    +
    +
    +

    DEFENSE POLICY CATALOG

    Endpoint security policies

    See enforcement, rules, assigned security groups, endpoint coverage, and current vulnerability intelligence.

    LOADING
    +

    Loading endpoint security policies.

    +

    +

    ACTIVE POLICY

    Decision thresholds

    v1
    @@ -244,6 +271,65 @@

    AI-assisted. Behavior-aware. Zero trust.

    +
    +
    +

    NETWORK DEPLOYMENT CONTROL PLANE

    Fleet administration

    Discover enrolled assets, inspect security-group coverage, and distribute a fixed Intent Gate installation manifest.

    +
    ORCHESTRATOR ONLINE
    +
    +
    +
    DISCOVERED ENDPOINTS0inventory records
    +
    ONLINE NOW0agent heartbeat
    +
    MANAGED0Intent Gate installed
    +
    SECURITY GROUPS0deployment selectors
    +
    QUEUED JOBS0agent-pull transport
    +
    + +
    +
    +

    LATEST DISCOVERY

    Endpoints & security groups

    NOT RUN
    +

    Connect to load security groups.

    +
    + + + +
    EndpointAddressOSGroupsAgentStatus
    No endpoints discovered.
    +
    +
    + +
    +

    CONTROLLED SOFTWARE DISTRIBUTION

    Deploy Intent Gate

    FIXED MANIFEST
    +
    + + +
    +
    +
    +
    +

    NETWORK COMMAND PREVIEW

    + uig-admin deploy --server http://intentgate.example:8787 --group <group> --version 0.4.0 +

    Deployment guard: Plan validates scope without queueing work. Deploy creates auditable agent-pull jobs; it never accepts arbitrary shell commands.

    +
    +
    +

    RECENT WAVES

    +

    No deployments have been planned.

    +
    +
    +
    + +
    +
    +

    ASSET & IDENTITY INVENTORY

    Security groups and endpoints

    Explore policy targets by group and inspect every member endpoint, platform, agent version, and connectivity state.

    + +
    +
    +
    GROUPS0
    +
    ENDPOINTS0
    +
    ONLINE0
    +
    MANAGED0
    +
    +

    Connect to load group inventory.

    +
    +

    PLUGGABLE INTELLIGENCE

    AI model advisor

    diff --git a/tests/test_service.py b/tests/test_service.py index 3187454..7f332dd 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -127,6 +127,62 @@ def test_operator_console_assessment_review_policy_and_audit(self): self.assertEqual(detailed["endpoint_name"], "DEV-WS-042") self.assertTrue(detailed["signal_details"]) + def test_fleet_discovery_and_controlled_deployment(self): + token = "synthetic-admin-token" + with tempfile.TemporaryDirectory() as directory, patch.dict( + os.environ, {"UIG_STATE_DIR": directory, "UIG_INGEST_TOKEN": token} + ): + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + try: + inventory = json.loads(self._request(f"{base}/v1/endpoints", token).read()) + security_policies = json.loads(self._request(f"{base}/v1/security-policies", token).read()) + updated_dlp = json.loads(self._request( + f"{base}/v1/security-policies", token, "POST", + {"id": "data-loss-prevention", "enabled": True, "enforcement_mode": "enforce", "assigned_groups": ["finance", "high-value"]}, + ).read()) + discovery = json.loads(self._request( + f"{base}/v1/discovery-sessions", token, "POST", {"requested_by": "test-admin"} + ).read()) + planned = json.loads(self._request( + f"{base}/v1/deployments", token, "POST", + {"security_group": "engineering", "version": "0.4.0", "execute": False}, + ).read()) + queued = json.loads(self._request( + f"{base}/v1/deployments", token, "POST", + {"security_group": "engineering", "version": "0.4.0", "execute": True}, + ).read()) + job = json.loads(self._request( + f"{base}/v1/deployment-jobs/next?endpoint_id=dev-ws-042", token + ).read())["job"] + completed = json.loads(self._request( + f"{base}/v1/deployment-jobs/{job['id']}", token, "POST", + {"status": "succeeded", "detail": "package installed"}, + ).read()) + deployments = json.loads(self._request(f"{base}/v1/deployments", token).read()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + self.assertEqual(inventory["summary"]["total"], 8) + self.assertGreaterEqual(inventory["summary"]["groups"], 8) + self.assertEqual(security_policies["summary"]["domains"], 5) + self.assertEqual(security_policies["summary"]["known_exploited_cves"], 2) + self.assertEqual(updated_dlp["enforcement_mode"], "enforce") + self.assertEqual(updated_dlp["assigned_groups"], ["finance", "high-value"]) + self.assertEqual(updated_dlp["covered_endpoint_count"], 1) + self.assertEqual(discovery["status"], "completed") + self.assertEqual(discovery["endpoint_count"], 8) + self.assertEqual(planned["status"], "planned") + self.assertEqual(planned["matched_endpoints"], 3) + self.assertEqual(queued["status"], "queued") + self.assertTrue(all(item["manifest"]["action"] == "install-or-upgrade-intentgate" for item in queued["jobs"])) + self.assertNotIn("command", queued["jobs"][0]["manifest"]) + self.assertEqual(completed["status"], "succeeded") + self.assertEqual(len(deployments["deployments"]), 2) + if __name__ == "__main__": unittest.main()