agentic-threat-hunting-framework 0.5.2__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/METADATA +1 -1
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/RECORD +27 -26
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/WHEEL +1 -1
- athf/__version__.py +1 -1
- athf/agents/llm/hunt_researcher.py +5 -5
- athf/cli.py +1 -2
- athf/commands/agent.py +1 -2
- athf/commands/hunt.py +75 -7
- athf/commands/investigate.py +84 -2
- athf/core/hunt_manager.py +21 -3
- athf/core/research_manager.py +30 -1
- athf/data/docs/INSTALL.md +1 -1
- athf/data/docs/getting-started.md +1 -1
- athf/data/docs/lock-pattern.md +3 -3
- athf/data/hunts/FORMAT_GUIDELINES.md +1 -1
- athf/data/hunts/README.md +3 -3
- athf/data/prompts/README.md +1 -1
- athf/data/prompts/ai-workflow.md +1 -1
- athf/data/prompts/basic-prompts.md +1 -1
- athf/plugin_system.py +7 -5
- athf/utils/validation.py +170 -0
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/entry_points.txt +0 -0
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {agentic_threat_hunting_framework-0.5.2.dist-info → agentic_threat_hunting_framework-0.7.0.dist-info}/top_level.txt +0 -0
- /athf/data/hunts/{H-0001.md → production/2026/Q1/H-0001.md} +0 -0
- /athf/data/hunts/{H-0002.md → production/2026/Q1/H-0002.md} +0 -0
- /athf/data/hunts/{H-0003.md → production/2026/Q1/H-0003.md} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-threat-hunting-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Agentic Threat Hunting Framework - Memory and AI for threat hunters
|
|
5
5
|
Author-email: Sydney Marrone <athf@nebulock.io>
|
|
6
6
|
Maintainer-email: Sydney Marrone <athf@nebulock.io>
|
|
@@ -1,59 +1,60 @@
|
|
|
1
|
-
agentic_threat_hunting_framework-0.
|
|
1
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/licenses/LICENSE,sha256=_KObErRfiKoolznt-DF0nJnr3U9Rdh7Z4Ba7G5qqckk,1071
|
|
2
2
|
athf/__init__.py,sha256=OrjZe8P97_BTEkscapnwSsqKSjwXNP9d8-HtGr19Ni0,241
|
|
3
|
-
athf/__version__.py,sha256=
|
|
4
|
-
athf/cli.py,sha256=
|
|
5
|
-
athf/plugin_system.py,sha256=
|
|
3
|
+
athf/__version__.py,sha256=F90ZPthTNEvn7TDS840_bjxhiK6dF6YhliP-VF4Wj98,59
|
|
4
|
+
athf/cli.py,sha256=xbbgqAzwyb1ygWnn0lgmUOuO2H-vAYczgD20TdFFOiQ,5065
|
|
5
|
+
athf/plugin_system.py,sha256=QoXSFPPFDqdSE46d5gHhvgaUDU2Ym0Dq6C1BJHOqEWs,2325
|
|
6
6
|
athf/agents/__init__.py,sha256=iaSJpvnXm9rz4QS7gBrsaLEjm49uvsMs4BLPOJeyp78,346
|
|
7
7
|
athf/agents/base.py,sha256=ZLqSW6I0pKqs1Z3OIoV8urkysMRzNDs52yNIxDgQjTU,3981
|
|
8
8
|
athf/agents/llm/__init__.py,sha256=qSGA-NaInjsDkMpGQwnTz3S1OgCVlzetpMcDS_to1co,671
|
|
9
|
-
athf/agents/llm/hunt_researcher.py,sha256=
|
|
9
|
+
athf/agents/llm/hunt_researcher.py,sha256=Eb4BQZxxTTln07Wicgs1YhPi5Me-MjV0FErDRWysKDY,27078
|
|
10
10
|
athf/agents/llm/hypothesis_generator.py,sha256=XkbJz8IS4zwQjEy-ZD0zy2XW5uRnAy87Lii-5XTY0WU,8564
|
|
11
11
|
athf/commands/__init__.py,sha256=-ZOfg6uV1eSh7RDW7dKzdufuYvQTT0KGMF4JB6waHsY,635
|
|
12
|
-
athf/commands/agent.py,sha256=
|
|
12
|
+
athf/commands/agent.py,sha256=c4mIZQaB2Gf7EHYEWrlHHX4W_bNg35UfBxPFw5WSEhc,18937
|
|
13
13
|
athf/commands/context.py,sha256=V-at81-OgKcLY-In48-AccTnHfTgdofmnjE8S5kypoI,12678
|
|
14
14
|
athf/commands/env.py,sha256=JPKRsv48cgsIAjSFaGJ1-Nu0nQKGSVg4AbiFxb9jVX4,11887
|
|
15
|
-
athf/commands/hunt.py,sha256=
|
|
15
|
+
athf/commands/hunt.py,sha256=a9dbXFNTKR65tC5CdtTlBbEe-AqbmrcAMnoaBpMBPuY,27845
|
|
16
16
|
athf/commands/init.py,sha256=Qn0iETNyuQvM-ySqCeoDz-pPemeuzROX_karQF5yN_o,12685
|
|
17
|
-
athf/commands/investigate.py,sha256=
|
|
17
|
+
athf/commands/investigate.py,sha256=g4GKbvRglhM444IcSPabqu76nJ6IGxCFR_px_kg7Uac,28135
|
|
18
18
|
athf/commands/research.py,sha256=FrLph4agaGQ_rIxMh0OQwh1MIGDFtj40zJ3E1ZFwaAw,18112
|
|
19
19
|
athf/commands/similar.py,sha256=FTTVr4zzP9bdJrirscp6pOxdQbE8zot6pa20-_TYiuo,11804
|
|
20
20
|
athf/commands/splunk.py,sha256=7n7Jl1ExqZCNxUhG0kAKgAvZMqbIoGSgx2Moq7vAu-Y,11622
|
|
21
21
|
athf/core/__init__.py,sha256=yG7C8ljx3UW4QZoYvDjUxsWHlbS8M-GLGB7Je7rRfqo,31
|
|
22
22
|
athf/core/attack_matrix.py,sha256=QZKKmxckQ6-U7lqVdGUJoj2jEAhP3Juvr3sqaNx2oTw,3238
|
|
23
|
-
athf/core/hunt_manager.py,sha256=
|
|
23
|
+
athf/core/hunt_manager.py,sha256=PA7uDW69weMgCosN5a8GMlVgg9zRWfD9k33mdnE7R4w,12403
|
|
24
24
|
athf/core/hunt_parser.py,sha256=FUj0yyBIcZnaS9aItMImeBDhegQwpkewIwUMNXW_ZWU,5122
|
|
25
25
|
athf/core/investigation_parser.py,sha256=wbfjnq4gFgIc0a4bHIAnidVNPhbHDpIXWY1SGLk0Xls,6804
|
|
26
|
-
athf/core/research_manager.py,sha256=
|
|
26
|
+
athf/core/research_manager.py,sha256=rwhAyaqtBbs6lgQeHS03ZMXzLNOSiVdNII3pX2gvtUE,15600
|
|
27
27
|
athf/core/splunk_client.py,sha256=Xib2zVwV2l8eChzqUahI3PZ7Z2XS2wz01sPbF1E0Q18,11611
|
|
28
28
|
athf/core/template_engine.py,sha256=Awp0n9E5Q1dYA35XDKKAd5VJLdpaDl2N967hackUVa8,6010
|
|
29
29
|
athf/core/web_search.py,sha256=B9IhmwH7gy2RVA6WSN3L7yGp3Q4L8OsiiwcEvnnZejU,10320
|
|
30
30
|
athf/data/__init__.py,sha256=QtgONloCaS3E9Ow995FMxyy6BbszpfmYeWpySQ2b9Mc,502
|
|
31
31
|
athf/data/docs/CHANGELOG.md,sha256=JKkzzs1n5jSERHFi6fDt6sYEe52MSaY127dfzthkUA8,8655
|
|
32
32
|
athf/data/docs/CLI_REFERENCE.md,sha256=pb76UqkY_WHJMBEXwEmK0TJR8kcGzoBPlJ0WdGMKDQM,54875
|
|
33
|
-
athf/data/docs/INSTALL.md,sha256=
|
|
33
|
+
athf/data/docs/INSTALL.md,sha256=6-cTDf1AO_KQ84sqhO2DcRr96OGk3Wc6ywsWECBHEV8,13155
|
|
34
34
|
athf/data/docs/README.md,sha256=rp-XQZeqteXJz7M2qKX3sl6o0AVfhGmz8GcNNKAt8pM,1061
|
|
35
35
|
athf/data/docs/environment.md,sha256=K88NBWZM2bI1Jztd0ORa6AYaMgPVjVB-K2fJl8S5-g8,8306
|
|
36
|
-
athf/data/docs/getting-started.md,sha256=
|
|
36
|
+
athf/data/docs/getting-started.md,sha256=WPYwRS7Jxhp1yBpbYh4LpAdi3-3ZdcyjZFl3BOFufWg,15335
|
|
37
37
|
athf/data/docs/level4-agentic-workflows.md,sha256=68crKsDaLyrgxVG37nPIuJyO9NobLi09Obv7D1AnpYs,14123
|
|
38
|
-
athf/data/docs/lock-pattern.md,sha256=
|
|
38
|
+
athf/data/docs/lock-pattern.md,sha256=S-T-LGMtuR7X-nRSsCVURtQ-tUhaX7TWFKOrqbQ1Y7A,4920
|
|
39
39
|
athf/data/docs/maturity-model.md,sha256=O1FDIKPkO9twNdZmA0w-TUwPvLP331tul2fPpUnCXD4,18181
|
|
40
40
|
athf/data/docs/why-athf.md,sha256=rIoUb7iqdZKbuWNyRlGxhZrRkLx7gWAGS-kurEZDt04,2148
|
|
41
|
-
athf/data/hunts/FORMAT_GUIDELINES.md,sha256=
|
|
42
|
-
athf/data/hunts/
|
|
43
|
-
athf/data/hunts/H-
|
|
44
|
-
athf/data/hunts/H-
|
|
45
|
-
athf/data/hunts/
|
|
41
|
+
athf/data/hunts/FORMAT_GUIDELINES.md,sha256=Q1kje_-nMhXpFFWS7uSVYIyoP_AnR86FRcgjFrfcWO8,15067
|
|
42
|
+
athf/data/hunts/README.md,sha256=NGEjIMTURPyRJnTqT_BKmBeY0NWZn63erowV2Oj3FzM,5869
|
|
43
|
+
athf/data/hunts/production/2026/Q1/H-0001.md,sha256=rdUIpQ_uN8bx7XS1ED85rW5aRKxFOpMg0X7PANY7eCY,23220
|
|
44
|
+
athf/data/hunts/production/2026/Q1/H-0002.md,sha256=yF5ZEfl7NAJJMjuVf9ZitafwDfWMTzyU5fgkrAQ4U6I,20405
|
|
45
|
+
athf/data/hunts/production/2026/Q1/H-0003.md,sha256=w0iAaplcM0kFWRmVhQsX53LVIWaRDJsB3TWalI1zz_o,27436
|
|
46
46
|
athf/data/integrations/MCP_CATALOG.md,sha256=hJ_cyHijEjWdkFiX7WEyBtJqlLtKuRzZCKlqrhbSLrU,1782
|
|
47
47
|
athf/data/integrations/README.md,sha256=jkiK0u5pNjodmFuNKKMR0G40Soq8pqBRVsaP89wP70w,4336
|
|
48
48
|
athf/data/integrations/quickstart/splunk.md,sha256=6REsD05zQOPcT6ezxyeysOtTRsSp7JO6vK_epd7GCJU,4897
|
|
49
49
|
athf/data/knowledge/hunting-knowledge.md,sha256=djublWCzFexl5ssssL6KfMm4RnUI0ANoWMY9zLSQDd0,91107
|
|
50
|
-
athf/data/prompts/README.md,sha256=
|
|
51
|
-
athf/data/prompts/ai-workflow.md,sha256=
|
|
52
|
-
athf/data/prompts/basic-prompts.md,sha256=
|
|
50
|
+
athf/data/prompts/README.md,sha256=bQetg4OxuyI-UZJTOlrMjyjzGXgL5H1Sion7xyXOa80,5067
|
|
51
|
+
athf/data/prompts/ai-workflow.md,sha256=8bpITJeeOx6zv1sIlPtulC2DMZ4m-eSHvbgElifY9u8,17125
|
|
52
|
+
athf/data/prompts/basic-prompts.md,sha256=eX1eM_lWph3fNZgH2aPaLBZZntbAl9XpSeO53RqW6Ws,8501
|
|
53
53
|
athf/data/templates/HUNT_LOCK.md,sha256=zXxHaKMWbRDLewLTegYJMbXRM72s9gFFvjdwFfGNeJE,7386
|
|
54
54
|
athf/utils/__init__.py,sha256=aEAPI1xnAsowOtc036cCb9ZOek5nrrfevu8PElhbNgk,30
|
|
55
|
-
|
|
56
|
-
agentic_threat_hunting_framework-0.
|
|
57
|
-
agentic_threat_hunting_framework-0.
|
|
58
|
-
agentic_threat_hunting_framework-0.
|
|
59
|
-
agentic_threat_hunting_framework-0.
|
|
55
|
+
athf/utils/validation.py,sha256=j1efs0RsNT0YsI9-ttOgImkBAMQ_is84WNXbVu5SPL8,4789
|
|
56
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/METADATA,sha256=39SV82zgEtWOD8WeqcKsWLCRQDLlpqJICcxq4rrwP_0,15949
|
|
57
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
58
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/entry_points.txt,sha256=GopR2iTiBs-yNMWiUZ2DaFIFglXxWJx1XPjTa3ePtfE,39
|
|
59
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/top_level.txt,sha256=Cxxg6SMLfawDJWBITsciRzq27XV8fiaAor23o9Byoes,5
|
|
60
|
+
agentic_threat_hunting_framework-0.7.0.dist-info/RECORD,,
|
athf/__version__.py
CHANGED
|
@@ -13,7 +13,7 @@ import os
|
|
|
13
13
|
import time
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Any, Dict, List, Optional
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
17
|
|
|
18
18
|
from athf.agents.base import AgentResult, LLMAgent
|
|
19
19
|
|
|
@@ -457,7 +457,7 @@ class HuntResearcherAgent(LLMAgent[ResearchInput, ResearchOutput]):
|
|
|
457
457
|
topic: str,
|
|
458
458
|
sources: List[Dict[str, str]],
|
|
459
459
|
search_results: Optional[Any],
|
|
460
|
-
) ->
|
|
460
|
+
) -> Tuple[str, List[str]]:
|
|
461
461
|
"""Use LLM to summarize system research findings."""
|
|
462
462
|
try:
|
|
463
463
|
client = self._get_llm_client()
|
|
@@ -502,7 +502,7 @@ Return JSON format:
|
|
|
502
502
|
technique: Optional[str],
|
|
503
503
|
sources: List[Dict[str, str]],
|
|
504
504
|
search_results: Optional[Any],
|
|
505
|
-
) ->
|
|
505
|
+
) -> Tuple[str, List[str]]:
|
|
506
506
|
"""Use LLM to summarize adversary tradecraft findings."""
|
|
507
507
|
try:
|
|
508
508
|
client = self._get_llm_client()
|
|
@@ -549,7 +549,7 @@ Return JSON format:
|
|
|
549
549
|
technique: Optional[str],
|
|
550
550
|
ocsf_schema: str,
|
|
551
551
|
environment_data: str,
|
|
552
|
-
) ->
|
|
552
|
+
) -> Tuple[str, List[str]]:
|
|
553
553
|
"""Use LLM to map topic to OCSF telemetry fields."""
|
|
554
554
|
try:
|
|
555
555
|
client = self._get_llm_client()
|
|
@@ -590,7 +590,7 @@ Return JSON format:
|
|
|
590
590
|
topic: str,
|
|
591
591
|
technique: Optional[str],
|
|
592
592
|
skills: List[ResearchSkillOutput],
|
|
593
|
-
) ->
|
|
593
|
+
) -> Tuple[str, List[str]]:
|
|
594
594
|
"""Use LLM to synthesize all research findings."""
|
|
595
595
|
try:
|
|
596
596
|
client = self._get_llm_client()
|
athf/cli.py
CHANGED
|
@@ -12,6 +12,7 @@ load_dotenv()
|
|
|
12
12
|
from athf.__version__ import __version__ # noqa: E402
|
|
13
13
|
from athf.commands import context, env, hunt, init, investigate, research, similar, splunk # noqa: E402
|
|
14
14
|
from athf.commands.agent import agent # noqa: E402
|
|
15
|
+
from athf.plugin_system import PluginRegistry # noqa: E402
|
|
15
16
|
|
|
16
17
|
console = Console()
|
|
17
18
|
|
|
@@ -100,8 +101,6 @@ if splunk is not None:
|
|
|
100
101
|
cli.add_command(splunk)
|
|
101
102
|
|
|
102
103
|
# Load and register plugins
|
|
103
|
-
from athf.plugin_system import PluginRegistry
|
|
104
|
-
|
|
105
104
|
PluginRegistry.load_plugins()
|
|
106
105
|
for name, cmd in PluginRegistry._commands.items():
|
|
107
106
|
cli.add_command(cmd, name=name)
|
athf/commands/agent.py
CHANGED
|
@@ -41,9 +41,8 @@ def agent() -> None:
|
|
|
41
41
|
LLM agents use Claude API for creative and analytical tasks.
|
|
42
42
|
|
|
43
43
|
\b
|
|
44
|
-
Agent Execution
|
|
44
|
+
Agent Execution Mode:
|
|
45
45
|
• INTERACTIVE (default): Step-by-step execution with user approval
|
|
46
|
-
• AUTONOMOUS (--auto): Runs all steps without check-ins
|
|
47
46
|
"""
|
|
48
47
|
pass
|
|
49
48
|
|
athf/commands/hunt.py
CHANGED
|
@@ -15,10 +15,29 @@ from rich.table import Table
|
|
|
15
15
|
from athf.core.hunt_manager import HuntManager
|
|
16
16
|
from athf.core.hunt_parser import validate_hunt_file
|
|
17
17
|
from athf.core.template_engine import render_hunt_template
|
|
18
|
+
from athf.utils.validation import validate_hunt_id, validate_research_id
|
|
18
19
|
|
|
19
20
|
console = Console()
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def get_hunt_directory(is_test: bool = False) -> Path:
|
|
24
|
+
"""Calculate hunt directory based on current date.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
is_test: If True, creates in test/ directory, otherwise production/
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Path to hunt directory (hunts/{environment}/{YYYY}/{QX}/)
|
|
31
|
+
"""
|
|
32
|
+
now = datetime.now()
|
|
33
|
+
year = now.year
|
|
34
|
+
quarter = f"Q{(now.month - 1) // 3 + 1}"
|
|
35
|
+
|
|
36
|
+
environment = "test" if is_test else "production"
|
|
37
|
+
|
|
38
|
+
return Path("hunts") / environment / str(year) / quarter
|
|
39
|
+
|
|
40
|
+
|
|
22
41
|
def get_config_path() -> Path:
|
|
23
42
|
"""Get config file path, checking new location first, then falling back to root."""
|
|
24
43
|
new_location = Path("config/.athfconfig.yaml")
|
|
@@ -94,6 +113,7 @@ def hunt() -> None:
|
|
|
94
113
|
@click.option("--tactic", multiple=True, help="MITRE tactics (can specify multiple)")
|
|
95
114
|
@click.option("--platform", multiple=True, help="Target platforms (can specify multiple)")
|
|
96
115
|
@click.option("--data-source", multiple=True, help="Data sources (can specify multiple)")
|
|
116
|
+
@click.option("--test", is_flag=True, help="Create as test hunt (hunts/test/...) instead of production")
|
|
97
117
|
@click.option("--non-interactive", is_flag=True, help="Skip interactive prompts")
|
|
98
118
|
@click.option("--hypothesis", help="Full hypothesis statement")
|
|
99
119
|
@click.option("--threat-context", help="Threat intel or context motivating the hunt")
|
|
@@ -109,6 +129,7 @@ def new(
|
|
|
109
129
|
tactic: Tuple[str, ...],
|
|
110
130
|
platform: Tuple[str, ...],
|
|
111
131
|
data_source: Tuple[str, ...],
|
|
132
|
+
test: bool,
|
|
112
133
|
non_interactive: bool,
|
|
113
134
|
hypothesis: Optional[str],
|
|
114
135
|
threat_context: Optional[str],
|
|
@@ -171,7 +192,23 @@ def new(
|
|
|
171
192
|
|
|
172
193
|
# Validate research document if provided
|
|
173
194
|
if research:
|
|
195
|
+
# Validate research ID format
|
|
196
|
+
if not validate_research_id(research):
|
|
197
|
+
console.print(f"[red]Error: Invalid research ID format: {research}[/red]")
|
|
198
|
+
console.print("[yellow]Expected format: R-0001[/yellow]")
|
|
199
|
+
return
|
|
200
|
+
|
|
174
201
|
research_file = Path("research") / f"{research}.md"
|
|
202
|
+
|
|
203
|
+
# Validate path is within research directory
|
|
204
|
+
try:
|
|
205
|
+
if not research_file.resolve().is_relative_to(Path("research").resolve()):
|
|
206
|
+
console.print("[red]Error: Invalid research path[/red]")
|
|
207
|
+
return
|
|
208
|
+
except (ValueError, OSError):
|
|
209
|
+
console.print("[red]Error: Invalid research path[/red]")
|
|
210
|
+
return
|
|
211
|
+
|
|
175
212
|
if not research_file.exists():
|
|
176
213
|
console.print(f"[yellow]Warning: Research document {research} not found at {research_file}[/yellow]")
|
|
177
214
|
console.print("[yellow]Hunt will still be created, but research link may be broken.[/yellow]\n")
|
|
@@ -233,9 +270,19 @@ def new(
|
|
|
233
270
|
spawned_from=research,
|
|
234
271
|
)
|
|
235
272
|
|
|
236
|
-
# Write hunt file
|
|
237
|
-
|
|
238
|
-
|
|
273
|
+
# Write hunt file using hierarchical directory structure
|
|
274
|
+
hunt_dir = get_hunt_directory(is_test=test)
|
|
275
|
+
hunt_dir.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
hunt_file = hunt_dir / f"{hunt_id}.md"
|
|
277
|
+
|
|
278
|
+
# Validate path is within hunts directory
|
|
279
|
+
try:
|
|
280
|
+
if not hunt_file.resolve().is_relative_to(Path("hunts").resolve()):
|
|
281
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
282
|
+
return
|
|
283
|
+
except (ValueError, OSError):
|
|
284
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
285
|
+
return
|
|
239
286
|
|
|
240
287
|
with open(hunt_file, "w", encoding="utf-8") as f:
|
|
241
288
|
f.write(hunt_content)
|
|
@@ -370,10 +417,31 @@ def validate(hunt_id: str) -> None:
|
|
|
370
417
|
• Verify hunt files are AI-assistant readable
|
|
371
418
|
"""
|
|
372
419
|
if hunt_id:
|
|
373
|
-
# Validate
|
|
374
|
-
|
|
420
|
+
# Validate hunt ID format
|
|
421
|
+
if not validate_hunt_id(hunt_id):
|
|
422
|
+
console.print(f"[red]Error: Invalid hunt ID format: {hunt_id}[/red]")
|
|
423
|
+
console.print("[yellow]Expected format: H-0001[/yellow]")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Validate specific hunt - search recursively for backward compatibility
|
|
427
|
+
hunts_dir = Path("hunts")
|
|
428
|
+
hunt_file = hunts_dir / f"{hunt_id}.md"
|
|
429
|
+
|
|
430
|
+
# If not found in flat structure, search recursively
|
|
375
431
|
if not hunt_file.exists():
|
|
376
|
-
|
|
432
|
+
matching_files = list(hunts_dir.rglob(f"{hunt_id}.md"))
|
|
433
|
+
if not matching_files:
|
|
434
|
+
console.print(f"[red]Hunt not found: {hunt_id}[/red]")
|
|
435
|
+
return
|
|
436
|
+
hunt_file = matching_files[0] # Use first match
|
|
437
|
+
|
|
438
|
+
# Validate path is within hunts directory
|
|
439
|
+
try:
|
|
440
|
+
if not hunt_file.resolve().is_relative_to(hunts_dir.resolve()):
|
|
441
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
442
|
+
return
|
|
443
|
+
except (ValueError, OSError):
|
|
444
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
377
445
|
return
|
|
378
446
|
|
|
379
447
|
_validate_single_hunt(hunt_file)
|
|
@@ -386,7 +454,7 @@ def validate(hunt_id: str) -> None:
|
|
|
386
454
|
console.print("[yellow]No hunts directory found.[/yellow]")
|
|
387
455
|
return
|
|
388
456
|
|
|
389
|
-
hunt_files = list(hunts_dir.
|
|
457
|
+
hunt_files = list(hunts_dir.rglob("*.md"))
|
|
390
458
|
|
|
391
459
|
if not hunt_files:
|
|
392
460
|
console.print("[yellow]No hunt files found.[/yellow]")
|
athf/commands/investigate.py
CHANGED
|
@@ -13,6 +13,7 @@ from rich.prompt import Prompt
|
|
|
13
13
|
from rich.table import Table
|
|
14
14
|
|
|
15
15
|
from athf.core.investigation_parser import get_all_investigations, get_next_investigation_id, validate_investigation_file
|
|
16
|
+
from athf.utils.validation import validate_investigation_id
|
|
16
17
|
|
|
17
18
|
console = Console()
|
|
18
19
|
|
|
@@ -183,6 +184,15 @@ def new(
|
|
|
183
184
|
# Write investigation file
|
|
184
185
|
investigation_file = investigations_dir / f"{investigation_id}.md"
|
|
185
186
|
|
|
187
|
+
# Validate path is within investigations directory
|
|
188
|
+
try:
|
|
189
|
+
if not investigation_file.resolve().is_relative_to(investigations_dir.resolve()):
|
|
190
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
191
|
+
return
|
|
192
|
+
except (ValueError, OSError):
|
|
193
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
194
|
+
return
|
|
195
|
+
|
|
186
196
|
with open(investigation_file, "w", encoding="utf-8") as f:
|
|
187
197
|
f.write(investigation_content)
|
|
188
198
|
|
|
@@ -542,9 +552,24 @@ def validate(investigation_id: str) -> None:
|
|
|
542
552
|
# Validate after editing
|
|
543
553
|
athf investigate validate I-0001
|
|
544
554
|
"""
|
|
555
|
+
# Validate investigation ID format
|
|
556
|
+
if not validate_investigation_id(investigation_id):
|
|
557
|
+
console.print(f"[red]Error: Invalid investigation ID format: {investigation_id}[/red]")
|
|
558
|
+
console.print("[yellow]Expected format: I-0001[/yellow]")
|
|
559
|
+
return
|
|
560
|
+
|
|
545
561
|
investigations_dir = Path("investigations")
|
|
546
562
|
investigation_file = investigations_dir / f"{investigation_id}.md"
|
|
547
563
|
|
|
564
|
+
# Validate path is within investigations directory
|
|
565
|
+
try:
|
|
566
|
+
if not investigation_file.resolve().is_relative_to(investigations_dir.resolve()):
|
|
567
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
568
|
+
return
|
|
569
|
+
except (ValueError, OSError):
|
|
570
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
571
|
+
return
|
|
572
|
+
|
|
548
573
|
if not investigation_file.exists():
|
|
549
574
|
console.print(f"[red]Error: Investigation file not found: {investigation_file}[/red]")
|
|
550
575
|
return
|
|
@@ -606,10 +631,25 @@ def promote(
|
|
|
606
631
|
|
|
607
632
|
console.print("\n[bold cyan]🔄 Promoting investigation to hunt[/bold cyan]\n")
|
|
608
633
|
|
|
634
|
+
# Validate investigation ID format
|
|
635
|
+
if not validate_investigation_id(investigation_id):
|
|
636
|
+
console.print(f"[red]Error: Invalid investigation ID format: {investigation_id}[/red]")
|
|
637
|
+
console.print("[yellow]Expected format: I-0001[/yellow]")
|
|
638
|
+
return
|
|
639
|
+
|
|
609
640
|
# Check investigation file exists
|
|
610
641
|
investigations_dir = Path("investigations")
|
|
611
642
|
investigation_file = investigations_dir / f"{investigation_id}.md"
|
|
612
643
|
|
|
644
|
+
# Validate path is within investigations directory
|
|
645
|
+
try:
|
|
646
|
+
if not investigation_file.resolve().is_relative_to(investigations_dir.resolve()):
|
|
647
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
648
|
+
return
|
|
649
|
+
except (ValueError, OSError):
|
|
650
|
+
console.print("[red]Error: Invalid investigation file path[/red]")
|
|
651
|
+
return
|
|
652
|
+
|
|
613
653
|
if not investigation_file.exists():
|
|
614
654
|
console.print(f"[red]Error: Investigation file not found: {investigation_file}[/red]")
|
|
615
655
|
return
|
|
@@ -724,18 +764,60 @@ def promote(
|
|
|
724
764
|
hunts_dir.mkdir(exist_ok=True)
|
|
725
765
|
hunt_file = hunts_dir / f"{hunt_id}.md"
|
|
726
766
|
|
|
767
|
+
# Validate path is within hunts directory
|
|
768
|
+
try:
|
|
769
|
+
if not hunt_file.resolve().is_relative_to(hunts_dir.resolve()):
|
|
770
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
771
|
+
return
|
|
772
|
+
except (ValueError, OSError):
|
|
773
|
+
console.print("[red]Error: Invalid hunt file path[/red]")
|
|
774
|
+
return
|
|
775
|
+
|
|
727
776
|
with open(hunt_file, "w", encoding="utf-8") as f:
|
|
728
777
|
f.write(hunt_content)
|
|
729
778
|
|
|
730
779
|
console.print(f"\n[bold green]✅ Promoted {investigation_id} to {hunt_id}[/bold green]")
|
|
731
780
|
|
|
732
|
-
# Update investigation
|
|
781
|
+
# Update investigation's related_hunts field in frontmatter
|
|
782
|
+
try:
|
|
783
|
+
# Read the investigation file
|
|
784
|
+
with open(investigation_file, "r", encoding="utf-8") as f:
|
|
785
|
+
content = f.read()
|
|
786
|
+
|
|
787
|
+
# Split frontmatter and body
|
|
788
|
+
parts = content.split("---")
|
|
789
|
+
if len(parts) >= 3:
|
|
790
|
+
frontmatter_yaml = parts[1]
|
|
791
|
+
body = "---".join(parts[2:])
|
|
792
|
+
|
|
793
|
+
# Parse and update frontmatter
|
|
794
|
+
frontmatter = yaml.safe_load(frontmatter_yaml)
|
|
795
|
+
if "related_hunts" not in frontmatter or frontmatter["related_hunts"] is None:
|
|
796
|
+
frontmatter["related_hunts"] = []
|
|
797
|
+
|
|
798
|
+
# Add new hunt ID if not already present
|
|
799
|
+
if hunt_id not in frontmatter["related_hunts"]:
|
|
800
|
+
frontmatter["related_hunts"].append(hunt_id)
|
|
801
|
+
|
|
802
|
+
# Rebuild file with updated frontmatter
|
|
803
|
+
updated_yaml = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False)
|
|
804
|
+
updated_content = f"---\n{updated_yaml}---{body}"
|
|
805
|
+
|
|
806
|
+
# Write back to file
|
|
807
|
+
with open(investigation_file, "w", encoding="utf-8") as f:
|
|
808
|
+
f.write(updated_content)
|
|
809
|
+
|
|
810
|
+
console.print(f"[dim]Updated {investigation_file} with hunt reference in related_hunts[/dim]")
|
|
811
|
+
except Exception as e:
|
|
812
|
+
console.print(f"[yellow]Warning: Could not update investigation frontmatter: {e}[/yellow]")
|
|
813
|
+
|
|
814
|
+
# Add promotion note to the end of the document
|
|
733
815
|
promotion_note = f"\n\n---\n\n**Promoted to Hunt:** {hunt_id} on {today}\n"
|
|
734
816
|
|
|
735
817
|
with open(investigation_file, "a", encoding="utf-8") as f:
|
|
736
818
|
f.write(promotion_note)
|
|
737
819
|
|
|
738
|
-
console.print(f"[dim]
|
|
820
|
+
console.print(f"[dim]Added promotion note to {investigation_file}[/dim]")
|
|
739
821
|
|
|
740
822
|
console.print("\n[bold]Next steps:[/bold]")
|
|
741
823
|
console.print(f" 1. Edit [cyan]{hunt_file}[/cyan] to refine hunt hypothesis")
|
athf/core/hunt_manager.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Set
|
|
|
6
6
|
|
|
7
7
|
from athf.core.attack_matrix import ATTACK_TACTICS, TOTAL_TECHNIQUES, get_sorted_tactics
|
|
8
8
|
from athf.core.hunt_parser import parse_hunt_file
|
|
9
|
+
from athf.utils.validation import validate_hunt_id
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class HuntManager:
|
|
@@ -42,8 +43,8 @@ class HuntManager:
|
|
|
42
43
|
"""
|
|
43
44
|
hunts = []
|
|
44
45
|
|
|
45
|
-
# Find all hunt files
|
|
46
|
-
hunt_files = sorted(self.hunts_dir.
|
|
46
|
+
# Find all hunt files recursively (supports both flat and hierarchical structure)
|
|
47
|
+
hunt_files = sorted(self.hunts_dir.rglob("*.md"))
|
|
47
48
|
|
|
48
49
|
for hunt_file in hunt_files:
|
|
49
50
|
try:
|
|
@@ -102,9 +103,26 @@ class HuntManager:
|
|
|
102
103
|
Returns:
|
|
103
104
|
Hunt data dict or None if not found
|
|
104
105
|
"""
|
|
106
|
+
# Validate hunt ID format and prevent path traversal
|
|
107
|
+
if not validate_hunt_id(hunt_id):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# First try flat structure for backward compatibility
|
|
105
111
|
hunt_file = self.hunts_dir / f"{hunt_id}.md"
|
|
106
112
|
|
|
113
|
+
# If not found in flat structure, search recursively
|
|
107
114
|
if not hunt_file.exists():
|
|
115
|
+
# Search recursively for the hunt file
|
|
116
|
+
matching_files = list(self.hunts_dir.rglob(f"{hunt_id}.md"))
|
|
117
|
+
if not matching_files:
|
|
118
|
+
return None
|
|
119
|
+
hunt_file = matching_files[0] # Use first match
|
|
120
|
+
|
|
121
|
+
# Validate path is within hunts directory
|
|
122
|
+
try:
|
|
123
|
+
if not hunt_file.resolve().is_relative_to(self.hunts_dir.resolve()):
|
|
124
|
+
return None
|
|
125
|
+
except (ValueError, OSError):
|
|
108
126
|
return None
|
|
109
127
|
|
|
110
128
|
return parse_hunt_file(hunt_file)
|
|
@@ -157,7 +175,7 @@ class HuntManager:
|
|
|
157
175
|
# Exclude documentation files
|
|
158
176
|
exclude_files = {"README.md", "FORMAT_GUIDELINES.md"}
|
|
159
177
|
|
|
160
|
-
for hunt_file in self.hunts_dir.
|
|
178
|
+
for hunt_file in self.hunts_dir.rglob("*.md"):
|
|
161
179
|
# Skip documentation files
|
|
162
180
|
if hunt_file.name in exclude_files:
|
|
163
181
|
continue
|
athf/core/research_manager.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import Any, Dict, List, Optional
|
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
+
from athf.utils.validation import validate_research_id
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class ResearchParser:
|
|
12
14
|
"""Parser for research files (YAML frontmatter + markdown)."""
|
|
@@ -240,15 +242,34 @@ class ResearchManager:
|
|
|
240
242
|
Returns:
|
|
241
243
|
Research data dict or None if not found
|
|
242
244
|
"""
|
|
245
|
+
# Validate research ID format and prevent path traversal
|
|
246
|
+
if not validate_research_id(research_id):
|
|
247
|
+
return None
|
|
248
|
+
|
|
243
249
|
# Try direct file
|
|
244
250
|
research_file = self.research_dir / f"{research_id}.md"
|
|
251
|
+
|
|
252
|
+
# Validate path is within research directory
|
|
253
|
+
try:
|
|
254
|
+
if not research_file.resolve().is_relative_to(self.research_dir.resolve()):
|
|
255
|
+
return None
|
|
256
|
+
except (ValueError, OSError):
|
|
257
|
+
return None
|
|
258
|
+
|
|
245
259
|
if research_file.exists():
|
|
246
260
|
return parse_research_file(research_file)
|
|
247
261
|
|
|
248
262
|
# Try nested search
|
|
249
263
|
research_files = list(self.research_dir.rglob(f"{research_id}.md"))
|
|
250
264
|
if research_files:
|
|
251
|
-
|
|
265
|
+
# Validate nested file is also within research directory
|
|
266
|
+
nested_file = research_files[0]
|
|
267
|
+
try:
|
|
268
|
+
if not nested_file.resolve().is_relative_to(self.research_dir.resolve()):
|
|
269
|
+
return None
|
|
270
|
+
except (ValueError, OSError):
|
|
271
|
+
return None
|
|
272
|
+
return parse_research_file(nested_file)
|
|
252
273
|
|
|
253
274
|
return None
|
|
254
275
|
|
|
@@ -368,6 +389,14 @@ class ResearchManager:
|
|
|
368
389
|
|
|
369
390
|
# Write file
|
|
370
391
|
file_path = self.research_dir / f"{research_id}.md"
|
|
392
|
+
|
|
393
|
+
# Validate path is within research directory
|
|
394
|
+
try:
|
|
395
|
+
if not file_path.resolve().is_relative_to(self.research_dir.resolve()):
|
|
396
|
+
raise ValueError("Invalid research file path")
|
|
397
|
+
except (ValueError, OSError) as e:
|
|
398
|
+
raise ValueError(f"Invalid research file path: {e}") from e
|
|
399
|
+
|
|
371
400
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
372
401
|
f.write(file_content)
|
|
373
402
|
|
athf/data/docs/INSTALL.md
CHANGED
|
@@ -572,7 +572,7 @@ After installation:
|
|
|
572
572
|
2. **Read the getting started guide**: [getting-started.md](getting-started.md)
|
|
573
573
|
3. **Review the CLI reference**: [CLI_REFERENCE.md](CLI_REFERENCE.md)
|
|
574
574
|
4. **Create your first hunt**: `athf hunt new`
|
|
575
|
-
5. **Explore example hunts**: [../hunts/H-0001.md](../hunts/H-0001.md)
|
|
575
|
+
5. **Explore example hunts**: [../hunts/production/2026/Q1/H-0001.md](../hunts/production/2026/Q1/H-0001.md)
|
|
576
576
|
|
|
577
577
|
---
|
|
578
578
|
|
|
@@ -120,7 +120,7 @@ athf hunt list
|
|
|
120
120
|
|
|
121
121
|
### Example Structure
|
|
122
122
|
|
|
123
|
-
See [hunts/H-0001.md](../hunts/H-0001.md) for a complete example. Here's a simplified structure:
|
|
123
|
+
See [hunts/H-0001.md](../hunts/production/2026/Q1/H-0001.md) for a complete example. Here's a simplified structure:
|
|
124
124
|
|
|
125
125
|
```markdown
|
|
126
126
|
# H-0001: SSH Brute Force Detection
|
athf/data/docs/lock-pattern.md
CHANGED
|
@@ -104,9 +104,9 @@ Next iteration: expand to include remote registry and PSExec telemetry for broad
|
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
**See full hunt examples:**
|
|
107
|
-
- [H-0001: macOS Information Stealer Detection](../hunts/H-0001.md) - Complete hunt with YAML frontmatter, detailed LOCK sections, query evolution, and results
|
|
108
|
-
- [H-0002: Linux Crontab Persistence Detection](../hunts/H-0002.md) - Multi-query approach with behavioral analysis
|
|
109
|
-
- [H-0003: AWS Lambda Persistence Detection](../hunts/H-0003.md) - Cloud hunting with CloudTrail correlation
|
|
107
|
+
- [H-0001: macOS Information Stealer Detection](../hunts/production/2026/Q1/H-0001.md) - Complete hunt with YAML frontmatter, detailed LOCK sections, query evolution, and results
|
|
108
|
+
- [H-0002: Linux Crontab Persistence Detection](../hunts/production/2026/Q1/H-0002.md) - Multi-query approach with behavioral analysis
|
|
109
|
+
- [H-0003: AWS Lambda Persistence Detection](../hunts/production/2026/Q1/H-0003.md) - Cloud hunting with CloudTrail correlation
|
|
110
110
|
- [Hunt Showcase](../../../SHOWCASE.md) - Side-by-side comparison of all three hunts
|
|
111
111
|
|
|
112
112
|
## Best Practices
|
|
@@ -504,4 +504,4 @@ The result is a condensed, practical template that guides hunters from hypothesi
|
|
|
504
504
|
|
|
505
505
|
## Example Reference
|
|
506
506
|
|
|
507
|
-
See [H-0001.md](H-0001.md), [H-0002.md](H-0002.md), and [H-0003.md](H-0003.md) for complete hunt examples.
|
|
507
|
+
See [H-0001.md](production/2026/Q1/H-0001.md), [H-0002.md](production/2026/Q1/H-0002.md), and [H-0003.md](production/2026/Q1/H-0003.md) for complete hunt examples.
|
athf/data/hunts/README.md
CHANGED
|
@@ -225,7 +225,7 @@ Claude:
|
|
|
225
225
|
|
|
226
226
|
## Example Hunts
|
|
227
227
|
|
|
228
|
-
- [H-0001.md](H-0001.md) - macOS Data Collection via AppleScript Detection (T1005, T1059.002, T1555.003)
|
|
229
|
-
- [H-0002.md](H-0002.md) - Linux Crontab Persistence Detection (T1053.003)
|
|
230
|
-
- [H-0003.md](H-0003.md) - AWS Lambda Persistence Detection (T1546.004, T1098)
|
|
228
|
+
- [H-0001.md](production/2026/Q1/H-0001.md) - macOS Data Collection via AppleScript Detection (T1005, T1059.002, T1555.003)
|
|
229
|
+
- [H-0002.md](production/2026/Q1/H-0002.md) - Linux Crontab Persistence Detection (T1053.003)
|
|
230
|
+
- [H-0003.md](production/2026/Q1/H-0003.md) - AWS Lambda Persistence Detection (T1546.004, T1098)
|
|
231
231
|
- [FORMAT_GUIDELINES.md](FORMAT_GUIDELINES.md) - Template structure reference
|
athf/data/prompts/README.md
CHANGED
|
@@ -142,7 +142,7 @@ AI: [Searches hunts/, reads environment.md, generates context-aware hypothesis]
|
|
|
142
142
|
|
|
143
143
|
### After Level 2 Workflows
|
|
144
144
|
|
|
145
|
-
1. See real examples in [hunts/H-0001.md](../hunts/H-0001.md) and [hunts/H-0002.md](../hunts/H-0002.md)
|
|
145
|
+
1. See real examples in [hunts/H-0001.md](../hunts/production/2026/Q1/H-0001.md) and [hunts/H-0002.md](../hunts/production/2026/Q1/H-0002.md)
|
|
146
146
|
2. Review format guidelines in [hunts/FORMAT_GUIDELINES.md](../hunts/FORMAT_GUIDELINES.md)
|
|
147
147
|
3. Consider Level 3 (MCP integrations) in [integrations/](../integrations/)
|
|
148
148
|
|
athf/data/prompts/ai-workflow.md
CHANGED
|
@@ -575,7 +575,7 @@ Before finalizing any AI-generated content:
|
|
|
575
575
|
|
|
576
576
|
- **Basic Prompts:** [basic-prompts.md](basic-prompts.md) for Level 0-1
|
|
577
577
|
- **Hunt Template:** [../templates/HUNT_LOCK.md](../templates/HUNT_LOCK.md)
|
|
578
|
-
- **Real Examples:** [../hunts/H-0001.md](../hunts/H-0001.md), [../hunts/H-0002.md](../hunts/H-0002.md)
|
|
578
|
+
- **Real Examples:** [../hunts/H-0001.md](../hunts/production/2026/Q1/H-0001.md), [../hunts/H-0002.md](../hunts/production/2026/Q1/H-0002.md)
|
|
579
579
|
- **Repository Context:** [AGENTS.md](../../../AGENTS.md)
|
|
580
580
|
|
|
581
581
|
**Remember: AI augments, doesn't replace. Always validate, always learn, always improve.**
|
|
@@ -302,7 +302,7 @@ Once you're comfortable with these basic prompts:
|
|
|
302
302
|
|
|
303
303
|
1. **Build your hunt repository** - Document hunts using [templates/HUNT_LOCK.md](../templates/HUNT_LOCK.md)
|
|
304
304
|
2. **Progress to Level 2** - Use [ai-workflow.md](ai-workflow.md) for AI tools with repository access
|
|
305
|
-
3. **See real examples** - Review [H-0001.md](../hunts/H-0001.md) and [H-0002.md](../hunts/H-0002.md)
|
|
305
|
+
3. **See real examples** - Review [H-0001.md](../hunts/production/2026/Q1/H-0001.md) and [H-0002.md](../hunts/production/2026/Q1/H-0002.md)
|
|
306
306
|
|
|
307
307
|
---
|
|
308
308
|
|
athf/plugin_system.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Plugin system for ATHF extensions."""
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import sys
|
|
4
|
+
from typing import Any, Dict, Optional, Type
|
|
5
|
+
|
|
4
6
|
from click import Command
|
|
5
7
|
|
|
6
8
|
# Handle importlib.metadata API changes across Python versions
|
|
@@ -46,9 +48,9 @@ class PluginRegistry:
|
|
|
46
48
|
try:
|
|
47
49
|
# Python 3.10+ uses group= parameter, 3.8-3.9 uses dict-like access
|
|
48
50
|
if sys.version_info >= (3, 10):
|
|
49
|
-
eps = entry_points(group=
|
|
51
|
+
eps = entry_points(group="athf.commands")
|
|
50
52
|
else:
|
|
51
|
-
eps = entry_points().get(
|
|
53
|
+
eps = entry_points().get("athf.commands", [])
|
|
52
54
|
|
|
53
55
|
for ep in eps:
|
|
54
56
|
command = ep.load()
|
|
@@ -59,9 +61,9 @@ class PluginRegistry:
|
|
|
59
61
|
try:
|
|
60
62
|
# Python 3.10+ uses group= parameter, 3.8-3.9 uses dict-like access
|
|
61
63
|
if sys.version_info >= (3, 10):
|
|
62
|
-
eps = entry_points(group=
|
|
64
|
+
eps = entry_points(group="athf.agents")
|
|
63
65
|
else:
|
|
64
|
-
eps = entry_points().get(
|
|
66
|
+
eps = entry_points().get("athf.agents", [])
|
|
65
67
|
|
|
66
68
|
for ep in eps:
|
|
67
69
|
agent = ep.load()
|
athf/utils/validation.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Input validation utilities to prevent path traversal and injection attacks."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_hunt_id(hunt_id: str) -> bool:
|
|
9
|
+
"""Validate hunt ID format and prevent path traversal.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
hunt_id: Hunt ID to validate (e.g., H-0001)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
True if valid, False otherwise
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
>>> validate_hunt_id("H-0001")
|
|
19
|
+
True
|
|
20
|
+
>>> validate_hunt_id("H-9999")
|
|
21
|
+
True
|
|
22
|
+
>>> validate_hunt_id("../../etc/passwd")
|
|
23
|
+
False
|
|
24
|
+
>>> validate_hunt_id("H-0001/../secrets.txt")
|
|
25
|
+
False
|
|
26
|
+
"""
|
|
27
|
+
if not hunt_id or not isinstance(hunt_id, str):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# Check format: single letter(s) + dash + 4 digits
|
|
31
|
+
if not re.match(r"^[A-Z]+-\d{4}$", hunt_id):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
# Prevent path traversal
|
|
35
|
+
if ".." in hunt_id or "/" in hunt_id or "\\" in hunt_id:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_investigation_id(investigation_id: str) -> bool:
|
|
42
|
+
"""Validate investigation ID format and prevent path traversal.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
investigation_id: Investigation ID to validate (e.g., I-0001)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if valid, False otherwise
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
>>> validate_investigation_id("I-0001")
|
|
52
|
+
True
|
|
53
|
+
>>> validate_investigation_id("../../../secrets")
|
|
54
|
+
False
|
|
55
|
+
"""
|
|
56
|
+
if not investigation_id or not isinstance(investigation_id, str):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# Check format: single letter + dash + 4 digits
|
|
60
|
+
if not re.match(r"^[A-Z]+-\d{4}$", investigation_id):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# Prevent path traversal
|
|
64
|
+
if ".." in investigation_id or "/" in investigation_id or "\\" in investigation_id:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_research_id(research_id: str) -> bool:
|
|
71
|
+
"""Validate research ID format and prevent path traversal.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
research_id: Research ID to validate (e.g., R-0001)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if valid, False otherwise
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> validate_research_id("R-0001")
|
|
81
|
+
True
|
|
82
|
+
>>> validate_research_id("R-0001/../../secrets")
|
|
83
|
+
False
|
|
84
|
+
"""
|
|
85
|
+
if not research_id or not isinstance(research_id, str):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# Check format: single letter + dash + 4 digits
|
|
89
|
+
if not re.match(r"^[A-Z]+-\d{4}$", research_id):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Prevent path traversal
|
|
93
|
+
if ".." in research_id or "/" in research_id or "\\" in research_id:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_file_path(file_path: Path, base_dir: Path) -> bool:
|
|
100
|
+
"""Validate that resolved file path is within base directory.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
file_path: File path to validate
|
|
104
|
+
base_dir: Base directory that file must be within
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if file is within base directory, False otherwise
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> base = Path("/app/hunts")
|
|
111
|
+
>>> validate_file_path(Path("/app/hunts/H-0001.md"), base)
|
|
112
|
+
True
|
|
113
|
+
>>> validate_file_path(Path("/etc/passwd"), base)
|
|
114
|
+
False
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
# Resolve to absolute paths
|
|
118
|
+
resolved_file = file_path.resolve()
|
|
119
|
+
resolved_base = base_dir.resolve()
|
|
120
|
+
|
|
121
|
+
# Check if file is relative to base directory
|
|
122
|
+
return resolved_file.is_relative_to(resolved_base)
|
|
123
|
+
except (ValueError, OSError):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def safe_path_join(base_dir: Path, id_value: str, extension: str = ".md") -> Optional[Path]:
|
|
128
|
+
"""Safely join base directory with ID to create file path.
|
|
129
|
+
|
|
130
|
+
Validates ID format and ensures resulting path is within base directory.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
base_dir: Base directory (e.g., Path("hunts"))
|
|
134
|
+
id_value: ID value (e.g., "H-0001")
|
|
135
|
+
extension: File extension (default: .md)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Validated Path object or None if validation fails
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
>>> safe_path_join(Path("hunts"), "H-0001")
|
|
142
|
+
Path('hunts/H-0001.md')
|
|
143
|
+
>>> safe_path_join(Path("hunts"), "../../etc/passwd")
|
|
144
|
+
None
|
|
145
|
+
"""
|
|
146
|
+
# Validate ID format based on first letter
|
|
147
|
+
if id_value.startswith("H-"):
|
|
148
|
+
if not validate_hunt_id(id_value):
|
|
149
|
+
return None
|
|
150
|
+
elif id_value.startswith("I-"):
|
|
151
|
+
if not validate_investigation_id(id_value):
|
|
152
|
+
return None
|
|
153
|
+
elif id_value.startswith("R-"):
|
|
154
|
+
if not validate_research_id(id_value):
|
|
155
|
+
return None
|
|
156
|
+
else:
|
|
157
|
+
# Unknown prefix - validate generic format
|
|
158
|
+
if not re.match(r"^[A-Z]+-\d{4}$", id_value):
|
|
159
|
+
return None
|
|
160
|
+
if ".." in id_value or "/" in id_value or "\\" in id_value:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
# Construct path
|
|
164
|
+
file_path = base_dir / f"{id_value}{extension}"
|
|
165
|
+
|
|
166
|
+
# Validate path is within base directory
|
|
167
|
+
if not validate_file_path(file_path, base_dir):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return file_path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|