diff --git a/limacharlie/commands/_adapter_types.py b/limacharlie/commands/_adapter_types.py index a5063343..3ccb50c0 100644 --- a/limacharlie/commands/_adapter_types.py +++ b/limacharlie/commands/_adapter_types.py @@ -9,6 +9,7 @@ from __future__ import annotations +import re from typing import Any import click @@ -156,6 +157,176 @@ def adapter_types(org: Any, hive_name: str = "cloud_sensor") -> list[dict[str, s """ +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _record_root(schema: Any) -> tuple[dict | None, dict | None]: + """Return (root_schema, record_node) for an adapter hive schema. + + Unwraps the {"schema": {...}} envelope and follows the root $ref into the + record definition (CloudSensorRecord / ExternalAdapterConfig). + """ + if isinstance(schema, dict) and isinstance(schema.get("schema"), dict): + schema = schema["schema"] + if not isinstance(schema, dict): + return None, None + record = schema + ref = schema.get("$ref") + if isinstance(ref, str): + resolved = _resolve_ref(schema, ref) + if resolved is not None: + record = resolved + return schema, record + + +def adapter_type_schema(org: Any, hive_name: str, type_name: str) -> tuple[Any, dict | None]: + """Resolve one adapter type's config sub-schema. + + Returns (root_schema, type_node): type_node is the JSON-Schema node for the + per-type config (e.g. the threatlocker sub-struct), or None if the type is + unknown. root_schema is returned so the caller can flatten/render with $ref + resolution against $defs. + """ + schema = Hive(org, hive_name).get_schema() + root, record = _record_root(schema) + if record is None: + return schema, None + props = record.get("properties") + if not isinstance(props, dict) or type_name not in props: + return root, None + return root, props[type_name] + + +def adapter_sensors(org: Any, hive_name: str, key: str) -> dict[str, Any]: + """Find the live sensor(s) belonging to an adapter record. + + Reads the adapter record, extracts its installation key (the sensor iid), + and returns the sensors whose iid matches. Falls back to matching the + adapter's configured hostname when the installation key isn't a bare iid + (UUID). The sensors list is empty when the adapter hasn't registered a + sensor yet (e.g. it has not delivered any events). + """ + record = Hive(org, hive_name).get(key) + data = getattr(record, "data", None) or {} + sensor_type = data.get("sensor_type") + sub = data.get(sensor_type, {}) if sensor_type else {} + client_options = sub.get("client_options", {}) if isinstance(sub, dict) else {} + identity = client_options.get("identity", {}) if isinstance(client_options, dict) else {} + install_key = identity.get("installation_key") + hostname = client_options.get("hostname") + + if isinstance(install_key, str) and _UUID_RE.match(install_key): + match_by, match_val = "iid", install_key + elif hostname: + match_by, match_val = "hostname", hostname + else: + return {"adapter": key, "match_by": None, "match_value": None, "selector": None, "sensors": [], + "note": "Adapter record has no resolvable installation_key (iid) or hostname to match on."} + + # Filter server-side with a sensor selector (both iid and hostname are + # supported selector fields), so this scales to large fleets instead of + # paging every sensor and filtering client-side. + selector = f'{match_by} == "{match_val}"' + sensors = [ + {"sid": s.get("sid"), "hostname": s.get("hostname"), "iid": s.get("iid"), + "is_online": s.get("is_online"), "last_seen": s.get("alive")} + for s in org.list_sensors(selector=selector) + ] + out = {"adapter": key, "match_by": match_by, "match_value": match_val, "selector": selector, "sensors": sensors} + if not sensors: + out["note"] = ("No sensor registered for this adapter yet — it has not delivered any " + "events (a cloud adapter materializes a sensor on first event). Normal " + "for a freshly-created adapter.") + return out + + +_EXPLAIN_SCHEMA = """\ +Show the configuration schema for ONE adapter/sensor type as a flat field +listing (field | type | required | notes), resolved from the live adapter hive +JSON-Schema. Use this before 'set' to learn the exact field set and where each +field lives (e.g. that hostname goes under client_options, not at the top +level). Pass --output json for the raw JSON-Schema node. + +Run 'list-types' first to see the valid --type values. +""" + +_EXPLAIN_SENSORS = """\ +List the live sensor(s) this adapter has produced. Reads the adapter record's +installation key (iid) and returns sensors whose iid matches — the reliable way +to get a cloud/external adapter's SID without decoding the installation key. + +An empty result means the adapter has not registered a sensor yet (it has not +delivered any events); that is expected for a freshly-created adapter. +""" + + +def add_schema(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: + """Attach a ``schema --type `` subcommand to an adapter command group.""" + from ..cli import pass_context + from ..client import Client + from ..sdk.organization import Organization + from ..output import format_output, detect_output_format + from ..discovery import register_explain + from .hive import _flatten_schema + + @group.command("schema", help="Show one adapter type's config schema.") + @click.option("--type", "type_name", required=True, help="Adapter type (see list-types).") + @pass_context + def schema_cmd(ctx, type_name) -> None: + client = Client( + oid=ctx.obj.oid, environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, debug_verbose=ctx.obj.debug_verbose, + ) + org = Organization(client) + root, node = adapter_type_schema(org, hive_name, type_name) + if node is None: + types = ", ".join(t["type"] for t in adapter_types(org, hive_name)) + raise click.UsageError(f"Unknown adapter type '{type_name}'. Valid types: {types}") + if ctx.obj.quiet: + return + fmt = ctx.obj.output_format or detect_output_format() + if fmt == "table" and isinstance(root, dict): + rows = _flatten_schema(node, root) + if rows: + click.echo(format_output(rows, fmt)) + return + # Raw JSON-Schema node for machine formats: resolve a top-level $ref and + # carry the root $defs so nested $refs in the node stay resolvable. + resolved = _resolve_ref(root, node["$ref"]) if isinstance(node, dict) and "$ref" in node and isinstance(root, dict) else node + if isinstance(resolved, dict) and isinstance(root, dict) and isinstance(root.get("$defs"), dict): + resolved = {**resolved, "$defs": root["$defs"]} + click.echo(format_output(resolved, fmt)) + + register_explain(command_path, _EXPLAIN_SCHEMA) + + +def add_sensors(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: + """Attach a ``sensors --key `` subcommand to an adapter command group.""" + from ..cli import pass_context + from ..client import Client + from ..sdk.organization import Organization + from ..output import format_output, detect_output_format + from ..discovery import register_explain + + @group.command("sensors", help="List the live sensor(s) this adapter produced.") + @click.option("--key", required=True, help="Adapter record key.") + @pass_context + def sensors_cmd(ctx, key) -> None: + client = Client( + oid=ctx.obj.oid, environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, debug_verbose=ctx.obj.debug_verbose, + ) + org = Organization(client) + data = adapter_sensors(org, hive_name, key) + if not ctx.obj.quiet: + fmt = ctx.obj.output_format or detect_output_format() + click.echo(format_output(data, fmt)) + + register_explain(command_path, _EXPLAIN_SENSORS) + + def add_list_types(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: """Attach a ``list-types`` subcommand to an adapter command group. diff --git a/limacharlie/commands/adapter.py b/limacharlie/commands/adapter.py index 2ad636e5..ca901117 100644 --- a/limacharlie/commands/adapter.py +++ b/limacharlie/commands/adapter.py @@ -3,11 +3,13 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group -from ._adapter_types import add_list_types +from ._adapter_types import add_list_types, add_schema, add_sensors from ..discovery import register_explain group = make_hive_group("external-adapter", "external_adapter", "external adapter") add_list_types(group, "external-adapter.list-types", "external_adapter") +add_schema(group, "external-adapter.schema", "external_adapter") +add_sensors(group, "external-adapter.sensors", "external_adapter") # Override the generic hive explains with adapter-specific documentation. diff --git a/limacharlie/commands/api_key.py b/limacharlie/commands/api_key.py index dadaf0cf..4472b554 100644 --- a/limacharlie/commands/api_key.py +++ b/limacharlie/commands/api_key.py @@ -128,8 +128,19 @@ def list_keys(ctx, name) -> None: "--permissions", required=True, help="Comma-separated list of permissions (e.g., 'dr.list,dr.set').", ) +@click.option( + "--store-secret", "store_secret", default=None, + help="Also store the freshly-minted key value into the secret hive under this " + "key name ({data: {secret: }}), so it can be referenced as " + "hive://secret/. The value is written directly without transiting " + "intermediate files — collapses the mint -> store -> reference chain.", +) +@click.option( + "--store-secret-tag", "store_secret_tags", multiple=True, + help="Tag to apply to the stored secret record (repeatable). Only used with --store-secret.", +) @pass_context -def create(ctx, name, permissions) -> None: +def create(ctx, name, permissions, store_secret, store_secret_tags) -> None: perm_list = [p.strip() for p in permissions.split(",") if p.strip()] if not perm_list: click.echo("Error: At least one permission is required.", err=True) @@ -140,6 +151,37 @@ def create(ctx, name, permissions) -> None: data = org.add_api_key(name, perm_list) if not ctx.obj.quiet: click.echo(f"API key '{name}' created.") + + if store_secret: + # The key value is only shown once; persist it into the secret hive in + # the same step so callers never have to capture and re-pipe it. + value = data.get("api_key") or data.get("secret") or data.get("key") + if not value: + # The key was already created; surface it so it isn't lost, then fail. + click.echo( + "Error: API key created but no key value was returned to store as a secret.", + err=True, + ) + _output(ctx, data) + ctx.exit(4) + return + from ..sdk.hive import Hive, HiveRecord + secret_hive = Hive(org, "secret") + # If a secret of this name already exists, carry its etag so the write is + # a conditional update (no lost update on a concurrent change) and we can + # report create-vs-overwrite instead of silently clobbering it. + existing_etag = None + try: + existing_etag = secret_hive.get_metadata(store_secret).etag + except Exception: + existing_etag = None # not found -> creating a new secret + record = HiveRecord(store_secret, data={"secret": value}, + tags=list(store_secret_tags), enabled=True, etag=existing_etag) + secret_hive.set(record) + if not ctx.obj.quiet: + verb = "Updated existing" if existing_etag else "Stored key value in new" + click.echo(f"{verb} secret '{store_secret}' (reference it as hive://secret/{store_secret}).") + _output(ctx, data) diff --git a/limacharlie/commands/cloud_sensor.py b/limacharlie/commands/cloud_sensor.py index b35adcca..5201b249 100644 --- a/limacharlie/commands/cloud_sensor.py +++ b/limacharlie/commands/cloud_sensor.py @@ -3,11 +3,13 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group -from ._adapter_types import add_list_types +from ._adapter_types import add_list_types, add_schema, add_sensors from ..discovery import register_explain group = make_hive_group("cloud-adapter", "cloud_sensor", "cloud adapter") add_list_types(group, "cloud-adapter.list-types", "cloud_sensor") +add_schema(group, "cloud-adapter.schema", "cloud_sensor") +add_sensors(group, "cloud-adapter.sensors", "cloud_sensor") # Override the generic hive explains with cloud adapter documentation. diff --git a/tests/unit/test_cli_ergonomics.py b/tests/unit/test_cli_ergonomics.py index b19df74e..ce3ca301 100644 --- a/tests/unit/test_cli_ergonomics.py +++ b/tests/unit/test_cli_ergonomics.py @@ -495,3 +495,195 @@ def test_json_keeps_raw(self, mock_hive_cls, _org, _client): parsed = json.loads(result.output) # Raw JSON Schema preserved, nothing lost. assert parsed == self._SCHEMA + + +# --------------------------------------------------------------------------- +# adapter schema --type (per-type config schema) +# --------------------------------------------------------------------------- + +class TestAdapterTypeSchema: + _SCHEMA = {"schema": { + "$ref": "#/$defs/CloudSensorRecord", + "$defs": { + "CloudSensorRecord": {"properties": { + "sensor_type": {"type": "string"}, + "threatlocker": {"$ref": "#/$defs/ThreatLockerConfig"}, + }}, + "ThreatLockerConfig": {"properties": { + "api_key": {"type": "string"}, "instance_letter": {"type": "string"}, + }, "required": ["api_key"]}, + }, + }} + + @patch("limacharlie.commands._adapter_types.Hive") + def test_resolves_known_type(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_type_schema + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + root, node = adapter_type_schema(None, "cloud_sensor", "threatlocker") + assert node is not None + assert node.get("$ref", "").endswith("ThreatLockerConfig") + # root retains $defs so the caller can resolve/flatten. + assert "ThreatLockerConfig" in root["$defs"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_unknown_type_returns_none(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_type_schema + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + _root, node = adapter_type_schema(None, "cloud_sensor", "bogus") + assert node is None + + @patch("limacharlie.commands._adapter_types.Hive") + def test_cli_table_render(self, mock_hive_cls): + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + runner = CliRunner() + with patch("limacharlie.client.Client"): + result = runner.invoke(cli, [ + "--output", "table", "cloud-adapter", "schema", "--type", "threatlocker"]) + assert result.exit_code == 0, result.output + # Flattened field listing resolves the type config's properties. + assert "api_key" in result.output and "instance_letter" in result.output + + @patch("limacharlie.commands._adapter_types.Hive") + def test_cli_unknown_type_errors_with_valid_list(self, mock_hive_cls): + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + runner = CliRunner() + with patch("limacharlie.client.Client"): + result = runner.invoke(cli, ["cloud-adapter", "schema", "--type", "bogus"]) + assert result.exit_code != 0 + assert "Unknown adapter type 'bogus'" in result.output + assert "threatlocker" in result.output # lists the valid types + + +# --------------------------------------------------------------------------- +# adapter sensors --key (find an adapter's sensor(s) by iid) +# --------------------------------------------------------------------------- + +class TestAdapterSensors: + _IID = "11111111-1111-1111-1111-111111111111" + + @patch("limacharlie.commands._adapter_types.Hive") + def test_matches_by_iid_via_selector(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": { + "identity": {"installation_key": self._IID}, "hostname": "ignored"}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + # The server applies the selector; the mock returns the filtered set. + org.list_sensors.return_value = [ + {"iid": self._IID, "sid": "S1", "hostname": "h1", "is_online": True, "alive": "t"}, + ] + out = adapter_sensors(org, "cloud_sensor", "my-adapter") + org.list_sensors.assert_called_once_with(selector=f'iid == "{self._IID}"') + assert out["match_by"] == "iid" and out["match_value"] == self._IID + assert out["selector"] == f'iid == "{self._IID}"' + assert [s["sid"] for s in out["sensors"]] == ["S1"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_empty_when_not_registered(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": { + "identity": {"installation_key": self._IID}}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock(); org.list_sensors.return_value = [] + out = adapter_sensors(org, "cloud_sensor", "a") + assert out["sensors"] == [] and "note" in out + + @patch("limacharlie.commands._adapter_types.Hive") + def test_hostname_fallback_when_key_not_uuid(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "webhook", "webhook": {"client_options": { + "identity": {"installation_key": "AAAAbase64notuuid=="}, "hostname": "wh-1"}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + org.list_sensors.return_value = [ + {"iid": "x", "sid": "S1", "hostname": "wh-1", "is_online": True, "alive": "t"}, + ] + out = adapter_sensors(org, "cloud_sensor", "wh") + org.list_sensors.assert_called_once_with(selector='hostname == "wh-1"') + assert out["match_by"] == "hostname" and [s["sid"] for s in out["sensors"]] == ["S1"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_no_resolvable_identity_does_not_query(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": {}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + out = adapter_sensors(org, "cloud_sensor", "a") + assert out["match_by"] is None and out["sensors"] == [] + org.list_sensors.assert_not_called() + + +# --------------------------------------------------------------------------- +# api-key create --store-secret (atomic mint -> secret) +# --------------------------------------------------------------------------- + +class TestApiKeyStoreSecret: + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_store_secret_writes_value_new(self, mock_org_cls, _client, mock_hive_cls): + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "minted-value", "key_name": "k", "success": True} + mock_org_cls.return_value = mock_org + mock_hive = MagicMock(); mock_hive_cls.return_value = mock_hive + # No existing secret of this name -> get_metadata raises -> create (no etag). + mock_hive.get_metadata.side_effect = Exception("not found") + + runner = CliRunner() + result = runner.invoke(cli, [ + "api-key", "create", "--name", "k", "--permissions", "org.get", + "--store-secret", "k-secret", "--store-secret-tag", "clitest", + ]) + assert result.exit_code == 0, result.output + # The secret hive received the minted value verbatim. + mock_hive_cls.assert_called_once() + assert mock_hive_cls.call_args[0][1] == "secret" + record = mock_hive.set.call_args[0][0] + assert record.data == {"secret": "minted-value"} + assert record.tags == ["clitest"] + assert record.enabled is True + assert record.etag is None # creating a new secret, no conditional etag + assert "new secret" in result.output + + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_store_secret_updates_existing_with_etag(self, mock_org_cls, _client, mock_hive_cls): + from limacharlie.sdk.hive import HiveRecord + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "minted-value", "key_name": "k"} + mock_org_cls.return_value = mock_org + mock_hive = MagicMock(); mock_hive_cls.return_value = mock_hive + # An existing secret -> carry its etag so the write is a conditional update. + mock_hive.get_metadata.return_value = HiveRecord(name="k-secret", etag="ETAG-1") + + runner = CliRunner() + result = runner.invoke(cli, [ + "api-key", "create", "--name", "k", "--permissions", "org.get", + "--store-secret", "k-secret", + ]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.data == {"secret": "minted-value"} + assert record.etag == "ETAG-1" + assert "Updated existing" in result.output + + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_no_store_secret_does_not_touch_hive(self, mock_org_cls, _client, mock_hive_cls): + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "v", "key_name": "k"} + mock_org_cls.return_value = mock_org + runner = CliRunner() + result = runner.invoke(cli, ["api-key", "create", "--name", "k", "--permissions", "org.get"]) + assert result.exit_code == 0, result.output + mock_hive_cls.assert_not_called() diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 3315d909..1141e405 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -142,7 +142,7 @@ "entity", "export", "get", "list", "merge", "orgs", "report", "tag", "telemetry", "update", "update-note", }), - "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), + "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "schema", "sensors", "set", "tag"}), "detection": frozenset({"get", "list"}), "download": frozenset({"adapter", "list", "sensor"}), "dr": frozenset({ @@ -160,7 +160,7 @@ "list", "list-available", "rekey", "request", "schema", "subscribe", "unsubscribe", }), - "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), + "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "schema", "sensors", "set", "tag"}), "fp": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "group": frozenset({ "create", "delete", "get", "list", "logs",