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 Flames/H-2026-001.md → Flames/H200.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# H-2026-001
# H200

Threat actors are using DLL side-loading to force legitimate signed VirtualBox executables (DbgView.exe) to load malicious DLLs (vboxrt.dll) that download additional malicious code from attacker-controlled servers to evade security monitoring and achieve privileged code execution.

Expand Down
2 changes: 1 addition & 1 deletion Flames/H-2026-002.md → Flames/H201.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# H-2026-002
# H201

Adversaries are creating domain-level content compliance rules with regular expression patterns matching strategic intelligence keywords to silently BCC-forward emails to external attacker-controlled Gmail accounts for covert data exfiltration.

Expand Down
2 changes: 1 addition & 1 deletion Flames/H-2026-003.md → Flames/H202.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# H-2026-003
# H202

Threat actors are exfiltrating API credentials stored in JetBrains IDE plugin settings by transmitting them unencrypted over HTTP to a hardcoded command and control server at 39.107.60[.]51 immediately after users click "Apply" in the plugin configuration interface.

Expand Down
2 changes: 1 addition & 1 deletion Flames/H-2026-004.md → Flames/H203.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# H-2026-004
# H203

Adversaries are sending crafted requests to bypass peering authentication mechanisms on Cisco Catalyst SD-WAN Controllers, Managers, and Validators to establish unauthorized control connections with state "up" and zero challenge-ack values in connection statistics, enabling administrative access to NETCONF for SD-WAN fabric manipulation.

Expand Down
214 changes: 107 additions & 107 deletions hunts-data.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/actor-mentions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-06-22T14:38:25Z",
"generated_at": "2026-06-25T00:22:49Z",
"mentions": {
"actor:G0007": [
"B005",
Expand Down
214 changes: 107 additions & 107 deletions public/hunts-data.json

Large diffs are not rendered by default.

214 changes: 126 additions & 88 deletions scripts/generate_from_cti.py

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions scripts/hunt_ids.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Shared helpers for HEARTH hunt-ID parsing, allocation, and rewriting.

Hunt IDs use the format ``H-YYYY-NNN`` (files under ``Flames/``). Older hunts
use ``HNNN`` (and ``B``/``A`` prefixes for Embers/Alchemy); those are a separate
namespace and are intentionally ignored by the ``H-YYYY-NNN`` allocator here.
Flames hunt IDs use the format ``HNNN`` (e.g. ``H200``), one monotonic sequence
across the whole catalog. Embers/Alchemy use ``BNNN``/``MNNN`` in their own
namespaces; this allocator is Flames-only and ignores them.
"""

from __future__ import annotations
Expand All @@ -11,17 +11,17 @@
from pathlib import Path
from typing import Iterable

HUNT_STEM_RE = re.compile(r"^H-(\d{4})-(\d{3,})$")
HUNT_STEM_RE = re.compile(r"^H(\d+)$")


def parse_hunt_number(stem: str) -> int | None:
"""Return the numeric part of an ``H-YYYY-NNN`` stem, or None if it isn't one."""
"""Return the numeric part of an ``HNNN`` stem, or None if it isn't one."""
match = HUNT_STEM_RE.match(stem)
return int(match.group(2)) if match else None
return int(match.group(1)) if match else None


def existing_numbers(names: Iterable[str]) -> set[int]:
"""Collect the numeric IDs from an iterable of ``H-YYYY-NNN(.md)`` names/paths."""
"""Collect the numeric IDs from an iterable of ``HNNN(.md)`` names/paths."""
nums: set[int] = set()
for name in names:
num = parse_hunt_number(Path(name).stem)
Expand All @@ -39,8 +39,8 @@ def next_free_number(existing: set[int]) -> int:
return max(existing) + 1 if existing else 1


def format_hunt_id(year: int, num: int) -> str:
return f"H-{year}-{num:03d}"
def format_hunt_id(num: int) -> str:
return f"H{num:03d}"


def rewrite_hunt_id(path: Path, new_id: str) -> Path:
Expand All @@ -59,7 +59,7 @@ def rewrite_hunt_id(path: Path, new_id: str) -> Path:
lines[0] = f"# {new_id}"
text = "\n".join(lines)

# Replace a populated Hunt# cell (e.g. "| H-2026-001 |"); a no-op for the
# Replace a populated Hunt# cell (e.g. "| H200 |"); a no-op for the
# common case where generated files leave that cell empty.
text = re.sub(rf"\|\s*{re.escape(old_id)}\s*\|", f"| {new_id} |", text, count=1)

Expand Down
87 changes: 43 additions & 44 deletions scripts/process_hunt_submission.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import os
import re
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv
from datetime import datetime

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
Expand Down Expand Up @@ -57,29 +57,29 @@
def parse_issue_body(body):
"""Parses the structured data from the HEARTH Hunt Submission Form issue."""
details = {}
sections = body.split('###')[1:]
sections = body.split("###")[1:]
for section in sections:
try:
lines = section.strip().split('\n')
lines = section.strip().split("\n")
header = lines[0].strip().lower()
content = "\n".join(lines[1:]).strip()

if "hunt type" in header:
details['hunt_type'] = content
details["hunt_type"] = content
elif "hunt idea / hypothesis" in header:
details['hypothesis'] = content
details["hypothesis"] = content
elif "mitre att&ck tactic" in header:
details['tactic'] = content
details["tactic"] = content
elif "implementation notes" in header:
details['notes'] = content
details["notes"] = content
elif "search tags" in header:
details['tags'] = content
details["tags"] = content
elif "value and impact" in header:
details['why'] = content
details["why"] = content
elif "knowledge base" in header:
details['references'] = content
details["references"] = content
elif "hearth crafter" in header:
details['submitter'] = content
details["submitter"] = content
except IndexError:
continue # Ignore malformed sections
return details
Expand All @@ -88,37 +88,37 @@ def parse_issue_body(body):
def generate_hunt_file(details):
"""Generates the hunt file content using the AI."""
prompt = USER_TEMPLATE.format(
hunt_type=details.get('hunt_type', 'Flames'),
hypothesis=details.get('hypothesis', ''),
tactic=details.get('tactic', ''),
notes=details.get('notes', 'N/A'),
tags=details.get('tags', ''),
why=details.get('why', ''),
references=details.get('references', ''),
submitter=details.get('submitter', 'A Helpful Contributor')
hunt_type=details.get("hunt_type", "Flames"),
hypothesis=details.get("hypothesis", ""),
tactic=details.get("tactic", ""),
notes=details.get("notes", "N/A"),
tags=details.get("tags", ""),
why=details.get("why", ""),
references=details.get("references", ""),
submitter=details.get("submitter", "A Helpful Contributor"),
)

response = client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
],
temperature=0.1,
max_tokens=1200
max_tokens=1200,
)
return response.choices[0].message.content.strip()


def get_next_hunt_id(hunt_type_prefix, hunt_dir):
"""Determines the next hunt ID for a given type."""
hunt_files = list(Path(hunt_dir).glob(f"{hunt_type_prefix}*.md"))
next_hunt_num = 1
if hunt_files:
hunt_numbers = [int(f.stem.split('-')[-1]) for f in hunt_files if f.stem.split('-')[-1].isdigit()]
if hunt_numbers:
next_hunt_num = max(hunt_numbers) + 1
return next_hunt_num
"""Next number for a prefix: max existing ``<prefix>NNN`` + 1 (1 if none)."""
stem_re = re.compile(rf"^{re.escape(hunt_type_prefix)}(\d+)$")
numbers = [
int(m.group(1))
for f in Path(hunt_dir).glob(f"{hunt_type_prefix}*.md")
if (m := stem_re.match(f.stem))
]
return max(numbers) + 1 if numbers else 1


if __name__ == "__main__":
Expand All @@ -130,22 +130,21 @@ def get_next_hunt_id(hunt_type_prefix, hunt_dir):
hunt_details = parse_issue_body(issue_body)

# 2. Determine hunt type, prefix, and directory
hunt_type = hunt_details.get('hunt_type', 'Flames').lower()
if 'flames' in hunt_type:
prefix, directory = 'H', 'Flames'
elif 'embers' in hunt_type:
prefix, directory = 'B', 'Embers'
elif 'alchemy' in hunt_type:
prefix, directory = 'A', 'Alchemy'
hunt_type = hunt_details.get("hunt_type", "Flames").lower()
if "flames" in hunt_type:
prefix, directory = "H", "Flames"
elif "embers" in hunt_type:
prefix, directory = "B", "Embers"
elif "alchemy" in hunt_type:
prefix, directory = "M", "Alchemy"
else:
prefix, directory = 'H', 'Flames' # Default to Flames
prefix, directory = "H", "Flames" # Default to Flames

Path(directory).mkdir(exist_ok=True)

# 3. Determine next hunt ID
# 3. Determine next hunt ID (HNNN / BNNN / MNNN, continuing the sequence)
next_id = get_next_hunt_id(prefix, directory)
year = datetime.now().year
hunt_id = f"{prefix}-{year}-{next_id:03d}"
hunt_id = f"{prefix}{next_id:03d}"
out_md_path = Path(f"{directory}/{hunt_id}.md")

# 4. Generate the core content
Expand All @@ -161,7 +160,7 @@ def get_next_hunt_id(hunt_type_prefix, hunt_dir):
print(f"✅ Successfully wrote hunt to {out_md_path}")

# 7. Set output for the workflow
if 'GITHUB_OUTPUT' in os.environ:
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
print(f'HUNT_FILE_PATH={out_md_path}', file=f)
print(f'HUNT_ID={hunt_id}', file=f)
if "GITHUB_OUTPUT" in os.environ:
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
print(f"HUNT_FILE_PATH={out_md_path}", file=f)
print(f"HUNT_ID={hunt_id}", file=f)
5 changes: 2 additions & 3 deletions scripts/reassign_hunt_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""Reassign a draft hunt's ID if it collides with an ID already on ``main``.

Run on a checked-out draft branch with ``origin/main`` fetched. For each hunt
file the branch ADDS under ``Flames/`` whose ``H-YYYY-NNN`` number already
file the branch ADDS under ``Flames/`` whose ``HNNN`` number already
exists on main, rename it to the next free number (rewriting the heading and
any populated Hunt# cell), and stage the rename.

Expand Down Expand Up @@ -76,9 +76,8 @@ def main() -> int:
continue
final_id = path.stem
if num in main_nums:
year = int(path.stem.split("-")[1])
new_num = next_free_number(claimed)
new_id = format_hunt_id(year, new_num)
new_id = format_hunt_id(new_num)
new_path = rewrite_hunt_id(path, new_id)
_git("add", "-A", "--", FLAMES)
claimed.add(new_num)
Expand Down
57 changes: 28 additions & 29 deletions scripts/tests/test_hunt_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@


def test_parse_hunt_number():
assert parse_hunt_number("H-2026-007") == 7
assert parse_hunt_number("H-2026-012") == 12
assert parse_hunt_number("H001") is None # old namespace ignored
assert parse_hunt_number("B-2026-001") is None # Embers prefix, not H
assert parse_hunt_number("H200") == 200
assert parse_hunt_number("H001") == 1
assert parse_hunt_number("B001") is None # Embers prefix, not H
assert parse_hunt_number("M001") is None # Alchemy prefix, not H
assert (
parse_hunt_number("H-2026-007") is None
) # legacy year format, no longer minted
assert parse_hunt_number("not-an-id") is None


Expand All @@ -25,8 +28,8 @@ def test_next_free_number():


def test_format_hunt_id():
assert format_hunt_id(2026, 3) == "H-2026-003"
assert format_hunt_id(2026, 42) == "H-2026-042"
assert format_hunt_id(3) == "H003"
assert format_hunt_id(200) == "H200"


def _write_hunt(path: Path, hunt_id: str, hunt_cell: str = "") -> None:
Expand All @@ -42,52 +45,48 @@ def _write_hunt(path: Path, hunt_id: str, hunt_cell: str = "") -> None:


def test_rewrite_hunt_id_renames_and_updates_heading(tmp_path):
src = tmp_path / "H-2026-001.md"
_write_hunt(src, "H-2026-001") # empty Hunt# cell — the real generated format
new_path = rewrite_hunt_id(src, "H-2026-003")
assert new_path.name == "H-2026-003.md"
src = tmp_path / "H200.md"
_write_hunt(src, "H200") # empty Hunt# cell — the real generated format
new_path = rewrite_hunt_id(src, "H202")
assert new_path.name == "H202.md"
assert not src.exists()
text = new_path.read_text()
assert text.splitlines()[0] == "# H-2026-003"
assert "# H-2026-001" not in text
assert text.splitlines()[0] == "# H202"
assert "# H200" not in text
assert "## Why" in text and "## References" in text # body preserved


def test_rewrite_hunt_id_updates_populated_table_cell(tmp_path):
src = tmp_path / "H-2026-002.md"
_write_hunt(src, "H-2026-002", hunt_cell="H-2026-002")
new_path = rewrite_hunt_id(src, "H-2026-004")
src = tmp_path / "H201.md"
_write_hunt(src, "H201", hunt_cell="H201")
new_path = rewrite_hunt_id(src, "H203")
text = new_path.read_text()
assert "| H-2026-004 |" in text
assert "H-2026-002" not in text
assert "| H203 |" in text
assert "H201" not in text


def test_find_id_problems_flags_main_collision():
added = [("H-2026-002", "H-2026-002")]
problems = find_id_problems(
added, main_ids={"H-2026-001", "H-2026-002"}, all_stems=["H-2026-002"]
)
added = [("H201", "H201")]
problems = find_id_problems(added, main_ids={"H200", "H201"}, all_stems=["H201"])
assert any("already exists on main" in p for p in problems)


def test_find_id_problems_clean_add():
added = [("H-2026-003", "H-2026-003")]
added = [("H202", "H202")]
problems = find_id_problems(
added,
main_ids={"H-2026-001", "H-2026-002"},
all_stems=["H-2026-001", "H-2026-002", "H-2026-003"],
main_ids={"H200", "H201"},
all_stems=["H200", "H201", "H202"],
)
assert problems == []


def test_find_id_problems_heading_mismatch():
added = [("H-2026-003", "H-2026-002")]
problems = find_id_problems(added, main_ids=set(), all_stems=["H-2026-003"])
added = [("H202", "H201")]
problems = find_id_problems(added, main_ids=set(), all_stems=["H202"])
assert any("does not match filename" in p for p in problems)


def test_find_id_problems_duplicate_in_tree():
problems = find_id_problems(
[], main_ids=set(), all_stems=["H-2026-001", "H-2026-001"]
)
problems = find_id_problems([], main_ids=set(), all_stems=["H200", "H200"])
assert any("duplicate" in p.lower() for p in problems)
Loading