Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc9fe7e
Add simple workspace creation script
annaerdi Nov 28, 2025
ec4cba9
Move workspace creation dir to src
annaerdi Nov 28, 2025
681c23e
Add mate as a cli command in pyproject.toml
annaerdi Nov 28, 2025
759572e
Refactor workspace generator
annaerdi Dec 1, 2025
e22fc30
Add workspace creation tests
annaerdi Dec 1, 2025
d79e7b0
Add step to create pyproject.toml for workspace
annaerdi Dec 2, 2025
452a91a
Add prek as optional dependency and update readme
annaerdi Dec 2, 2025
148720c
Update templates
annaerdi Dec 2, 2025
221b9ab
Update readme with usage of workspace creation
annaerdi Dec 3, 2025
35b454b
Merge branch 'main' into workspace
ipmach Dec 3, 2025
7343063
Merge branch 'main' into workspace
ipmach Dec 3, 2025
b94a937
Merge branch 'main' into workspace
annaerdi Dec 3, 2025
e6952aa
add comments
ipmach Dec 3, 2025
c009dc6
Merge branch 'workspace' of https://github.com/ait-detectmate/DetectM…
ipmach Dec 3, 2025
7fd4098
remove deprecatedwarnings
ipmach Dec 3, 2025
4cf1c5a
Normalize package name to lowercase
annaerdi Dec 3, 2025
c71059b
Add DetectMateService as optional dependency with usage description
annaerdi Dec 3, 2025
b0de5d9
Update bandit hook so it ignores any file starting with `test_`
annaerdi Dec 4, 2025
0bea01d
Create tests folder with custom component test file
annaerdi Dec 4, 2025
bdaa3fe
Add detectmatelibrary as workspace dependency
annaerdi Dec 4, 2025
53805fb
Make the template tests run from both library and generated workspace
annaerdi Dec 4, 2025
f4dfe00
Add tests to ensure that the generated workspace tests pass
annaerdi Dec 4, 2025
c549273
Update README.md
annaerdi Dec 4, 2025
e2b08eb
Update generated README.md
annaerdi Dec 5, 2025
6ad08a2
Use a safe package name function
annaerdi Dec 5, 2025
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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ repos:
rev: 1.8.6
hooks:
- id: bandit
exclude: ^tests/
exclude: (^tests/|.*/test_.*|^test_.*)

# Unused code detection
- repo: https://github.com/jendrikseipp/vulture
Expand Down
56 changes: 52 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Main library to run the different components in DetectMate.

The library contains the next components:

* **Readers**: insert logs into the system.
* **Readers**: insert logs into the system.
* **Parsers**: parse the logs receive from the reader.
* **Detectors**: return alerts if anomalies are detected.
* **Schemas**: standard data classes use in DetectMate.
Expand All @@ -26,13 +26,16 @@ uv run prek install
```

### Step 2: Install Protobuf dependencies
To installed in linux do:

To install in Linux do:

```bash
sudo apt install -y protobuf-compiler
protoc --version
```
This dependency is only need if a proto files is modifiy. To compile the proto file do:
```

This dependency is only needed if a proto file is modified. To compile the proto file do:
```bash
protoc --proto_path=src/schemas/ --python_out=src/schemas/ src/schemas/schemas.proto
```

Expand All @@ -48,3 +51,48 @@ Run the tests with coverage (add --cov-report=html to generate an HTML report):
```bash
uv run pytest --cov=. --cov-report=term-missing
```

## Workspace generator (`mate create`)

DetectMateLibrary includes a small CLI helper to bootstrap standalone workspaces
for custom parsers and detectors. This is useful if you want to develop and test
components in isolation while still using the same library and schemas.

### Usage

The CLI entry point is `mate` with a `create` command:

```bash
mate create --type <parser|detector> --name <workspace_name> --dir <target_dir>
```

| Option | Description |
|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--type` | Component type to generate:<br>- `parser`: CoreParser-based template<br>- `detector`: CoreDetector-based template |
| `--name` | Name of the component and package:<br>- Creates package dir: `<target_dir>/<name>/`<br>- Creates main file: `<name>.py`<br>- Derives class names: `<Name>` and `<Name>Config` |
| `--dir` | Directory where the workspace will be created |


### What gets generated

For example:

```bash
mate create --type parser --name custom_parser --dir ./workspaces/custom_parser
```

will create:

```text
workspaces/custom_parser/ # workspace root
├── custom_parser/ # Python package
│ ├── __init__.py
│ └── custom_parser.py # CoreParser-based template
├── tests/
│ └── test_custom_parser.py # generated from template to test custom_parser
├── LICENSE.md # copied from main project
├── .gitignore # copied from main project
├── .pre-commit-config.yaml # copied from main project
├── pyproject.toml # minimal project + dev extras
└── README.md # setup instructions
```
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ dependencies = [
"regex>=2025.11.3",
]


[project.optional-dependencies]
# add dependencies in this section with: uv add --optional dev <package>
# install with all the dev dependencies: uv pip install -e .[dev]
Expand All @@ -29,3 +28,6 @@ build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[project.scripts]
mate = "tools.workspace.create_workspace:main"
2 changes: 1 addition & 1 deletion src/detectmatelibrary/utils/time_format_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _parse_with_format(self, time_str: str, fmt: str) -> str | None:
try:
# handle syslog-like formats that lack a year by prepending the current year
if fmt == "%b %d %H:%M:%S" and re.match(r"^[A-Za-z]{3} \d{1,2} ", time_str):
year = datetime.utcnow().year
year = datetime.now().year
dt = datetime.strptime(f"{year} {time_str}", f"%Y {fmt}")
else:
dt = datetime.strptime(time_str, fmt)
Expand Down
Empty file added src/tools/__init__.py
Empty file.
Empty file added src/tools/workspace/__init__.py
Empty file.
175 changes: 175 additions & 0 deletions src/tools/workspace/create_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import argparse
import shutil
import sys
from pathlib import Path

from .utils import create_readme, create_pyproject, normalize_package_name

# resolve paths relative to this file
BASE_DIR = Path(__file__).resolve().parent.parent # tools/
PROJECT_ROOT = BASE_DIR.parent.parent # root of project
TEMPLATE_DIR = BASE_DIR / "workspace" / "templates"

META_FILES = ["LICENSE.md", ".gitignore", ".pre-commit-config.yaml"]


def copy_file(src: Path, dst: Path) -> None:
"""Copy file while ensuring destination directory exists."""
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)


def camelize(name: str) -> str:
"""Convert names like "custom_parser" or "customParser" to
"CustomParser"."""
if "_" in name or "-" in name:
parts = name.replace("-", "_").split("_")
return "".join(p.capitalize() for p in parts if p)
return name[0].upper() + name[1:]


def create_tests(type_: str, name: str, workspace_root: Path, pkg_name: str) -> None:
"""Create a tests/ directory with a basic pytest file for the component.

- Reads template tests from src/tools/workspace/templates/test_templates/
- Rewrites the import to point to <pkg_name>.<name>
- Renames CustomParser/CustomDetector to the camelized class name
"""

tests_dir = workspace_root / "tests"
tests_dir.mkdir(parents=True, exist_ok=True)
template_file = TEMPLATE_DIR / "test_templates" / f"test_Custom{type_.capitalize()}.py"
test_file = tests_dir / f"test_{name}.py"

if not template_file.exists():
print(f"WARNING: Test template {template_file} not found. Creating empty test file.", file=sys.stderr)
test_file.touch()
return

template_content = template_file.read_text()
base_class = f"Custom{type_.capitalize()}" # base names in the template (CustomParser/CustomDetector)
# The exact import line in the template, e.g:
# from ..CustomParser import CustomParser, CustomParserConfig
# from ..CustomDetector import CustomDetector, CustomDetectorConfig
original_import = f"from ..{base_class} import {base_class}, {base_class}Config"
new_class = camelize(name)
# new import line for the generated workspace:
# from <pkg_name>.<name> import <NewClass>, <NewClass>Config
new_import = f"from {pkg_name}.{normalize_package_name(name)} import {new_class}, {new_class}Config"
# replace the import line
content = template_content.replace(original_import, new_import)
# replace the remaining occurrences of CustomParser/CustomDetector
# with the new class name (inside the tests)
content = content.replace(base_class, new_class)
content = content.rstrip() + "\n"

test_file.write_text(content)
print(f"- Added tests file: {test_file}")


def create_workspace(type_: str, name: str, target_dir: Path) -> None:
"""Main workspace creation logic.

- `target_dir` is the workspace root (from --dir)
- Code lives in a subpackage: <target_dir>/<name>/
- Meta files (LICENSE, .gitignore, etc.) + README.md + pyproject.toml live in workspace root
"""

# Workspace root
workspace_root = Path(target_dir).expanduser().resolve()
workspace_root.mkdir(parents=True, exist_ok=True)

# Package directory inside the workspace
pkg_name = normalize_package_name(name)
pkg_dir = workspace_root / pkg_name

# Fail if the package directory already exists
if pkg_dir.exists():
print(f"ERROR: Target directory already exists: {pkg_dir}", file=sys.stderr)
sys.exit(1)

print(f"Creating workspace at: {pkg_dir}")
pkg_dir.mkdir(parents=True, exist_ok=False)

# Template selection
template_file = TEMPLATE_DIR / f"Custom{type_.capitalize()}.py"
module_name = normalize_package_name(name)
target_code_file = pkg_dir / f"{module_name}.py"

if not template_file.exists():
print(f"WARNING: Template not found: {template_file}. Creating empty file.", file=sys.stderr)
target_code_file.touch()
else:
template_content = template_file.read_text()

# Replace default class name inside template
original_class = f"Custom{type_.capitalize()}"
new_class = camelize(name)
template_content = template_content.replace(original_class, new_class)

target_code_file.write_text(template_content)

print(f"- Added implementation file: {target_code_file}")

# Make the package importable
(pkg_dir / "__init__.py").touch()

create_tests(type_=type_, name=name, workspace_root=workspace_root, pkg_name=pkg_name)

# Copy meta/root files
for file_name in META_FILES:
src = PROJECT_ROOT / file_name
dst = workspace_root / file_name

if src.exists():
copy_file(src, dst)
print(f"- Copied {file_name}")
else:
print(f"! Warning: {file_name} not found in project root.")

# Create pyproject.toml
create_pyproject(name, type_, workspace_root)
print(f"- Created pyproject.toml in {workspace_root}")

# Create README
create_readme(name, type_, target_code_file, workspace_root)
print(f"- Created README.md in {workspace_root}")
print("\nWorkspace created successfully!")


def main() -> None:
parser = argparse.ArgumentParser(description="Create a new workspace")

subparsers = parser.add_subparsers(dest="command", required=True)
create_cmd = subparsers.add_parser("create", help="Create a workspace")

create_cmd.add_argument(
"--type",
required=True,
choices=["parser", "detector"],
help="Type of component to generate",
)

create_cmd.add_argument(
"--name",
required=True,
help="Name for the generated file (e.g., customParser)",
)

create_cmd.add_argument(
"--dir",
required=True,
help="Directory where the workspace will be created",
)

args = parser.parse_args()

if args.command == "create":
target_dir = Path(args.dir).expanduser().resolve()
create_workspace(args.type, args.name, target_dir)
else:
parser.print_help()


if __name__ == "__main__":
main()
56 changes: 56 additions & 0 deletions src/tools/workspace/templates/CustomDetector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import Any, List

from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig
from detectmatelibrary.utils.data_buffer import BufferMode
from detectmatelibrary import schemas


class CustomDetectorConfig(CoreDetectorConfig):
"""Configuration for CustomDetector."""
# You can change this to whatever method_type you need
method_type: str = "custom_detector"


class CustomDetector(CoreDetector):
"""Template detector implementation based on CoreDetector.

Replace this docstring with a description of what your detector does
and how it should be used.
"""

def __init__(
self,
name: str = "CustomDetector",
config: CustomDetectorConfig | dict[str, Any] = CustomDetectorConfig(),
) -> None:

# Allow passing either a config instance or a plain dict
if isinstance(config, dict):
config = CustomDetectorConfig.from_dict(config, name)

super().__init__(name=name, buffer_mode=BufferMode.NO_BUF, config=config)
self._call_count = 0

def detect(
self,
input_: List[schemas.ParserSchema] | schemas.ParserSchema,
output_: schemas.DetectorSchema,
) -> bool | None:
"""Run detection on parser output and populate the detector schema.

:param input_: One or many ParserSchema instances
:param output_: DetectorSchema instance to be mutated in-place
:return: Detection result (True/False) or None
"""

output_["description"] = "Dummy detection process" # Description of the detection

# Alternating pattern: True, False, True, False, etc
self._call_count += 1
pattern = [True, False]
result = pattern[self._call_count % len(pattern)]
if result:
output_["score"] = 1.0 # Score of the detector
output_["alertsObtain"]["type"] = "Anomaly detected by CustomDetector" # Additional info

return result
45 changes: 45 additions & 0 deletions src/tools/workspace/templates/CustomParser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from typing import Any

from detectmatelibrary.common.parser import CoreParser, CoreParserConfig
from detectmatelibrary import schemas


class CustomParserConfig(CoreParserConfig):
"""Configuration for CustomParser."""
# You can change this to whatever method_type you need
method_type: str = "custom_parser"


class CustomParser(CoreParser):
"""Template parser implementation based on CoreParser.

Replace this docstring with a description of what your parser does.
"""

def __init__(
self,
name: str = "CustomParser",
config: CustomParserConfig | dict[str, Any] = CustomParserConfig(),
) -> None:
# Allow passing either a config instance or a plain dict
if isinstance(config, dict):
config = CustomParserConfig.from_dict(config, name)

super().__init__(name=name, config=config)

def parse(
self,
input_: schemas.LogSchema,
output_: schemas.ParserSchema,
) -> None:
"""Parse a single log entry and populate the output schema.

:param input_: Input log schema instance
:param output_: Parser output schema instance to be mutated in-
place
"""

# Dummy implementation example (replace with real logic)
output_["EventID"] = 2 # Number of the log template
output_["variables"].extend(["dummy_variable"]) # Variables found in the log
output_["template"] = "This is a dummy template" # Log template
Empty file.
Empty file.
Loading