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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mp"
version = "1.28.7"
version = "1.28.8"
description = "General Purpose CLI tool for Google SecOps Marketplace"
readme = "README.md"
authors = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from __future__ import annotations

import dataclasses
from pathlib import Path
from pathlib import Path # noqa: TC003
from typing import TYPE_CHECKING, Annotated

import typer
Expand All @@ -28,8 +28,6 @@
from mp.telemetry import track_command

if TYPE_CHECKING:
from pathlib import Path

from mp.core.config import RuntimeParams

app: typer.Typer = typer.Typer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,43 @@ class EntityUsage(BaseModel):
list[EntityType],
Field(
description=(
"The types of the entities the action runs on, if it runs on"
" entities. Use the code and the metadata json to determine this field."
" If an action runs on entities it most likely will use the target_entities"
" attribute to go over the entities or filter them by type or other"
" attributes. It is possible that an action doesn't run on entities."
" In that case leave this list empty. An action that doesn't use any entity doesn't"
" run on a generic entity, but simply it works on other sources of data."
" Make sure you check this carefully and distinguish correctly between actions that"
" run on/use entities and ones that don't. Then, if it does use entities, make sure"
" to check which types of entities (specific ones or all of them). Note that it is"
" possible for the code to contain the word 'entity' in some variables, but it"
" doesn't always mean it uses entities."
"""### Entity Types Determination Logic

**Field Definition:** `entity_types` (List of `EntityType`)

This field identifies the specific categories of entities an action processes. It is critical to
distinguish between actions that perform operations **on** SecOps entities versus actions that
simply process general data.

#### **1. Assessment Criteria**

* **Presence of Entities:** An action "runs on entities" if it iterates over the `target_entities`
attribute or uses entity-specific identifiers to perform its task.
* **Empty State:** If the action works on other data sources (e.g., fetching a static URL, checking
global system status) without referencing specific entities, this list **must be empty**.
* **Avoid String Matching:** Do not assume an action uses entities just because the variable name
`entity` appears in the code. Verify that the logic actually interacts with the SecOps entity
object model.

#### **2. Filtering & Scope**

* **Specific Types:** If the code filters entities by type (e.g., `if entity.type == "USER"`), list
only those specific types.
* **Unfiltered (Global) Scope:** If the action processes the `target_entities` list without any
type-based filtering, it is considered to run on **all** supported entity types.
In this case, include every available `EntityType` from the metadata.
* **The `GenericEntity` Distinction:** `GenericEntity` is a specific, standalone entity type.
Do **not** use it as a placeholder for "all types." Only include it if the action explicitly
supports the `GenericEntity` type or if no filters are applied (as part of the full list).

#### **3. Logic Summary Table**

| If the code... | Then `entity_types` should be... |
| --- | --- |
| Does not reference `target_entities` | `[]` (Empty List) |
| Filters for specific types (IP, Host) | Only the specific types identified: `["IP", "HOSTNAME"]` |
| Processes all entities without any filtering | **All** possible entity types from the metadata |
| Explicitly checks for "Generic" type only | `["GenericEntity"]` |"""
)
),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from mp.core import exclusions
from mp.core.data_models.abc import ComponentMetadata, RepresentableEnum

from .ai.entity_types import EntityType
from .ai.metadata import ActionAiMetadata
from .dynamic_results_metadata import (
BuiltDynamicResultsMetadata,
Expand All @@ -50,6 +51,7 @@
class AiFields(NamedTuple):
description: str | None
categories: list[ActionAiCategories]
entity_types: list[EntityType]


class ActionAiCategories(RepresentableEnum):
Expand All @@ -72,6 +74,7 @@ class BuiltActionMetadata(TypedDict):
Version: float
AIDescription: str | None
AICategories: NotRequired[list[str] | None]
EntityTypes: NotRequired[list[str] | None]


class NonBuiltActionMetadata(TypedDict):
Expand All @@ -90,6 +93,7 @@ class NonBuiltActionMetadata(TypedDict):
version: NotRequired[float]
ai_description: NotRequired[str | None]
ai_categories: NotRequired[list[str]]
entity_types: NotRequired[list[str]]


class ActionMetadata(ComponentMetadata[BuiltActionMetadata, NonBuiltActionMetadata]):
Expand Down Expand Up @@ -132,6 +136,7 @@ class ActionMetadata(ComponentMetadata[BuiltActionMetadata, NonBuiltActionMetada
]
ai_description: str | None
ai_categories: list[ActionAiCategories]
entity_types: list[EntityType]

@classmethod
def from_built_path(cls, path: Path) -> list[Self]:
Expand Down Expand Up @@ -182,6 +187,7 @@ def from_non_built_path(cls, path: Path) -> list[Self]:
ai_fields: AiFields = _get_ai_fields(action_metadata_json["name"], path)
action_metadata_json["ai_description"] = ai_fields.description
action_metadata_json["ai_categories"] = [c.value for c in ai_fields.categories]
action_metadata_json["entity_types"] = [t.value for t in ai_fields.entity_types]

metadata_object: Self = cls.from_non_built(p.stem, action_metadata_json)
metadata_objects.append(metadata_object)
Expand Down Expand Up @@ -223,6 +229,7 @@ def _from_built(cls, file_name: str, built: BuiltActionMetadata) -> Self:
version=version,
ai_description=built.get("AIDescription"),
ai_categories=[ActionAiCategories(c) for c in built.get("AICategories") or []],
entity_types=[EntityType(e) for e in built.get("EntityTypes") or []],
)

@classmethod
Expand Down Expand Up @@ -260,6 +267,7 @@ def _from_non_built(cls, file_name: str, non_built: NonBuiltActionMetadata) -> A
version=non_built.get("version", mp.core.constants.MINIMUM_SCRIPT_VERSION),
ai_description=non_built.get("ai_description"),
ai_categories=[ActionAiCategories(c) for c in non_built.get("ai_categories") or []],
entity_types=[EntityType(e) for e in non_built.get("entity_types") or []],
)

def to_built(self) -> BuiltActionMetadata:
Expand All @@ -285,6 +293,7 @@ def to_built(self) -> BuiltActionMetadata:
Version=float(self.version),
AIDescription=self.ai_description,
AICategories=[c.value for c in self.ai_categories] or None,
EntityTypes=[e.value for e in self.entity_types] or None,
)
mp.core.utils.remove_none_entries_from_mapping(built)
return built
Expand Down Expand Up @@ -338,7 +347,7 @@ def _load_json_examples(
"""Load JSON examples from files and return a new list of DRMs with their content.

Returns:
A list of non-built DRM dicts, with the json examples content.
A list of non-built DRM dicts, with the JSON examples content.

"""
loaded_drms: list[NonBuiltDynamicResultsMetadata] = []
Expand All @@ -365,7 +374,7 @@ def _load_json_examples(


def _get_ai_fields(action_name: str, integration_path: Path) -> AiFields:
empty_results: AiFields = AiFields(description=None, categories=[])
empty_results: AiFields = AiFields(description=None, categories=[], entity_types=[])
actions_desc: Path = (
integration_path
/ mp.core.constants.RESOURCES_DIR
Expand All @@ -388,4 +397,5 @@ def _get_ai_fields(action_name: str, integration_path: Path) -> AiFields:
for category, is_true in ai_meta.categories.model_dump().items()
if is_true
],
entity_types=ai_meta.entity_usage.entity_types,
)
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def from_toml_str(cls, text: str) -> Self:
a `PyProjectToml` object.

"""
pyproject_data: PyProjectTomlFile = cast("PyProjectTomlFile", toml.loads(text))
pyproject_data: PyProjectTomlFile = cast(
"PyProjectTomlFile", cast("object", toml.loads(text))
)
Comment thread
TalShafir1 marked this conversation as resolved.
Resolved
return cls.model_load(pyproject_data)


Expand Down
118 changes: 118 additions & 0 deletions packages/mp/src/mp/core/update_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import dataclasses
import importlib.metadata
import threading
import tomllib
from contextlib import suppress
from typing import Any

import requests
import rich
import typer
from packaging.version import InvalidVersion
from packaging.version import parse as parse_version

PYPROJECT_URL: str = (
"https://raw.githubusercontent.com/chronicle/content-hub/main/packages/mp/pyproject.toml"
)
TIMEOUT_SECONDS: float = 2.0


class UpdateChecker:
__slots__ = ("_check_thread", "_new_version")

def __init__(self) -> None:
self._new_version: str | None = None
self._check_thread: threading.Thread | None = None

def start_background_check(self, current_version: str) -> None:
"""Start a background thread to check for updates."""
if current_version == "unknown":
return

self._check_thread = threading.Thread(
target=self._check_update_worker,
args=(current_version,),
)
self._check_thread.daemon = True
self._check_thread.start()

def print_warning_if_needed(self) -> None:
"""Print a warning if a newer version is available."""
if self._check_thread and self._check_thread.is_alive():
self._check_thread.join(timeout=0.5)

if self._new_version:
_print_version_warning(self._new_version)

def _check_update_worker(self, current_version: str) -> None:
with suppress(
requests.RequestException,
requests.HTTPError,
tomllib.TOMLDecodeError,
KeyError,
InvalidVersion,
):
response: requests.Response = requests.get(PYPROJECT_URL, timeout=TIMEOUT_SECONDS)
response.raise_for_status()
data: dict[str, Any] = tomllib.loads(response.text)
remote_version: str | None = data.get("project", {}).get("version")

if remote_version and parse_version(remote_version) > parse_version(current_version):
self._new_version = remote_version


def _print_version_warning(remote_version: str) -> None:
current_version: str = get_mp_version()
message: str = (
f"\n[bold yellow]WARNING:[/bold yellow] A newer version of mp "
f"({current_version} -> {remote_version}) is available.\n"
f"Run 'mp self update' to update.\n"
)
rich.print(message)


def print_mp_version(*, value: bool) -> None:
"""Print the current version of the mp tool.

Raises:
typer.Exit: If the version is printed.

"""
if value:
version: str = get_mp_version()
rich.print(f"mp {version}")
raise typer.Exit


def get_mp_version() -> str:
"""Get the current version of mp.

Returns:
str: The current version of mp.

"""
try:
return importlib.metadata.version("mp")
except importlib.metadata.PackageNotFoundError:
return "unknown"


@dataclasses.dataclass(frozen=True, slots=True)
class AppState:
update_checker: UpdateChecker = dataclasses.field(default_factory=UpdateChecker)
35 changes: 32 additions & 3 deletions packages/mp/src/mp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@

from __future__ import annotations

import atexit
from typing import Annotated

import typer

from mp.core import config as mp_config
from mp.core.logging_utils import setup_logging
from mp.core.update_checker import UpdateChecker, get_mp_version, print_mp_version

from . import describe
from .build_project.typer_app import build_app
Expand All @@ -35,14 +39,14 @@
from .dev_env.typer_app import dev_env_app
from .format.typer_app import format_app
from .run_pre_build_tests.typer_app import test_app
from .self_update.typer_app import self_app
from .validate.typer_app import validate_app

app: typer.Typer = typer.Typer()


def main() -> None:
"""Entry point for the `mp` CLI tool, initializing all sub-applications."""
setup_logging(verbose=mp_config.is_verbose(), quiet=mp_config.is_quiet())

app: typer.Typer = typer.Typer()
app.add_typer(build_app, name="build")
app.add_typer(check_app)
app.add_typer(config_app, name="config")
Expand All @@ -51,8 +55,33 @@ def main() -> None:
app.add_typer(dev_env_app, name="dev-env")
app.add_typer(validate_app, name="validate")
app.add_typer(describe.app, name="describe")
app.add_typer(self_app, name="self")
app()


@app.callback(invoke_without_command=True)
def setup(
ctx: typer.Context,
*,
_version: Annotated[
bool,
typer.Option(
"--version",
"-V",
callback=print_mp_version,
is_eager=True,
help="Show the version of the mp tool.",
),
] = False,
) -> None:
"""Set up mp tool and initialize background tasks."""
setup_logging(verbose=mp_config.is_verbose(), quiet=mp_config.is_quiet())

checker: UpdateChecker = UpdateChecker()
ctx.obj = checker
Comment thread
TalShafir1 marked this conversation as resolved.
Resolved
checker.start_background_check(get_mp_version())
atexit.register(checker.print_warning_if_needed)


if __name__ == "__main__":
main()
13 changes: 13 additions & 0 deletions packages/mp/src/mp/self_update/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
Loading
Loading