refactor(realism): single source of truth for persona→login
decnet/realism/naming._home and decnet/canary/cultivator._persona_login both normalised "John Smith"→"johnsmith" with identical logic. Lift to decnet.realism.personas.login_for(persona) and have both consumers import it. Drift between the two would have left canary placement and realism path naming using different login derivations.
This commit is contained in:
@@ -29,6 +29,7 @@ from typing import Any, Optional
|
|||||||
from decnet.canary.base import CanaryArtifact, CanaryContext
|
from decnet.canary.base import CanaryArtifact, CanaryContext
|
||||||
from decnet.canary.factory import get_generator
|
from decnet.canary.factory import get_generator
|
||||||
from decnet.logging import get_logger
|
from decnet.logging import get_logger
|
||||||
|
from decnet.realism.personas import login_for
|
||||||
from decnet.realism.taxonomy import ContentClass, Plan
|
from decnet.realism.taxonomy import ContentClass, Plan
|
||||||
|
|
||||||
log = get_logger("canary.cultivator")
|
log = get_logger("canary.cultivator")
|
||||||
@@ -64,14 +65,6 @@ _DEFAULT_PATH: dict[ContentClass, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _persona_login(persona: str) -> str:
|
|
||||||
"""Mirror :func:`decnet.realism.naming._home`'s username conventions."""
|
|
||||||
candidate = persona.lower().replace(" ", "")
|
|
||||||
if candidate.isalnum() and candidate.isascii() and candidate:
|
|
||||||
return candidate
|
|
||||||
return "user"
|
|
||||||
|
|
||||||
|
|
||||||
def _path_for(plan: Plan) -> str:
|
def _path_for(plan: Plan) -> str:
|
||||||
"""Produce the canary placement path for *plan*.
|
"""Produce the canary placement path for *plan*.
|
||||||
|
|
||||||
@@ -84,7 +77,7 @@ def _path_for(plan: Plan) -> str:
|
|||||||
template = _DEFAULT_PATH.get(plan.content_class)
|
template = _DEFAULT_PATH.get(plan.content_class)
|
||||||
if template is None:
|
if template is None:
|
||||||
return plan.target_path
|
return plan.target_path
|
||||||
return template.format(persona=_persona_login(plan.persona))
|
return template.format(persona=login_for(plan.persona))
|
||||||
|
|
||||||
|
|
||||||
def _new_callback_token() -> str:
|
def _new_callback_token() -> str:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import secrets
|
|||||||
import string
|
import string
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from decnet.realism.personas import login_for
|
||||||
from decnet.realism.taxonomy import ContentClass
|
from decnet.realism.taxonomy import ContentClass
|
||||||
|
|
||||||
|
|
||||||
@@ -32,17 +33,8 @@ from decnet.realism.taxonomy import ContentClass
|
|||||||
# paths (out of scope until per-OS personas land). For now everything
|
# paths (out of scope until per-OS personas land). For now everything
|
||||||
# is POSIX.
|
# is POSIX.
|
||||||
def _home(persona: str) -> str:
|
def _home(persona: str) -> str:
|
||||||
"""Return the canonical home directory for *persona*.
|
"""Return the canonical home directory for *persona*."""
|
||||||
|
return f"/home/{login_for(persona)}"
|
||||||
The persona's ``name`` is used as the linux username when it's a
|
|
||||||
plausible login (lowercase, no spaces); otherwise we fall back to
|
|
||||||
a generic ``user`` so the path doesn't reveal a persona display
|
|
||||||
name on the decky filesystem.
|
|
||||||
"""
|
|
||||||
candidate = persona.lower().replace(" ", "")
|
|
||||||
if candidate.isalnum() and candidate.isascii() and candidate:
|
|
||||||
return f"/home/{candidate}"
|
|
||||||
return "/home/user"
|
|
||||||
|
|
||||||
|
|
||||||
def _random_token(rng: secrets.SystemRandom, length: int = 6) -> str:
|
def _random_token(rng: secrets.SystemRandom, length: int = 6) -> str:
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ def parse_personas(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def login_for(persona: str) -> str:
|
||||||
|
"""Return the linux login derived from a persona's display name.
|
||||||
|
|
||||||
|
Lowercase, strip spaces; if the result isn't a plausible POSIX
|
||||||
|
login (alnum ASCII), fall back to ``user`` so the path doesn't
|
||||||
|
leak the persona's display name onto the decky filesystem.
|
||||||
|
Shared by realism path naming (``decnet/realism/naming.py``) and
|
||||||
|
canary cultivation (``decnet/canary/cultivator.py``).
|
||||||
|
"""
|
||||||
|
candidate = persona.lower().replace(" ", "")
|
||||||
|
if candidate.isalnum() and candidate.isascii() and candidate:
|
||||||
|
return candidate
|
||||||
|
return "user"
|
||||||
|
|
||||||
|
|
||||||
def in_active_hours(persona: EmailPersona, now_hour: int) -> bool:
|
def in_active_hours(persona: EmailPersona, now_hour: int) -> bool:
|
||||||
"""Return True if *now_hour* (0–23) falls in the persona's window.
|
"""Return True if *now_hour* (0–23) falls in the persona's window.
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
- [x] **Real-time alerting via webhooks** — Admin-configurable outbound webhooks (SIEM/SOAR integration: Wazuh/Shuffle/TheHive/n8n) with HMAC-SHA256 signing, topic-pattern filtering, and bounded retry. Slack/Telegram-specific senders remain as per-destination work (they accept generic webhook payloads already).
|
- [x] **Real-time alerting via webhooks** — Admin-configurable outbound webhooks (SIEM/SOAR integration: Wazuh/Shuffle/TheHive/n8n) with HMAC-SHA256 signing, topic-pattern filtering, and bounded retry. Slack/Telegram-specific senders remain as per-destination work (they accept generic webhook payloads already).
|
||||||
- [x] **Threat intel enrichment** — Auto-lookup IPs against AbuseIPDB, Shodan, and GreyNoise. -> Out-of-band `decnet enrich` worker, woken on `attacker.scored`/`attacker.observed`. v1 ships GreyNoise Community + AbuseIPDB + abuse.ch (Feodo Tracker bulk feed and ThreatFox per-IP). Shodan / Censys / OTX backlogged in DEVELOPMENT_V2.md.
|
- [x] **Threat intel enrichment** — Auto-lookup IPs against AbuseIPDB, Shodan, and GreyNoise. -> Out-of-band `decnet enrich` worker, woken on `attacker.scored`/`attacker.observed`. v1 ships GreyNoise Community + AbuseIPDB + abuse.ch (Feodo Tracker bulk feed and ThreatFox per-IP). Shodan / Censys / OTX backlogged in DEVELOPMENT_V2.md.
|
||||||
- [ ] **Attack campaign clustering** — Group sessions by signatures and timing patterns.
|
- [x] **Attack campaign clustering** — Group sessions by signatures and timing patterns.
|
||||||
- [x] **GeoIP mapping** — Visualize attacker origin and ASN data on a map.
|
- [x] **GeoIP mapping** — Visualize attacker origin and ASN data on a map.
|
||||||
- [ ] **TTPs tagging** — Map observed behaviors to MITRE ATT&CK techniques.
|
- [ ] **TTPs tagging** — Map observed behaviors to MITRE ATT&CK techniques.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ dependencies = [
|
|||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"requests>=2.33.1",
|
"requests>=2.33.1",
|
||||||
"slowapi>=0.1.9",
|
"slowapi>=0.1.9",
|
||||||
"sqlite_vec>=0.1.9"
|
"sqlite_vec>=0.1.9",
|
||||||
|
"Pillow>=12.2.0",
|
||||||
|
"lxml>=6.1.0",
|
||||||
|
"pikepdf>=10.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
from decnet.realism.personas import (
|
from decnet.realism.personas import (
|
||||||
EmailPersona,
|
EmailPersona,
|
||||||
in_active_hours,
|
in_active_hours,
|
||||||
|
login_for,
|
||||||
parse_personas,
|
parse_personas,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,3 +100,28 @@ def test_active_hours_malformed_treats_as_always_on():
|
|||||||
def test_active_hours_equal_window_treated_as_always_on():
|
def test_active_hours_equal_window_treated_as_always_on():
|
||||||
p = EmailPersona(**_persona(active_hours="10:00-10:00"))
|
p = EmailPersona(**_persona(active_hours="10:00-10:00"))
|
||||||
assert in_active_hours(p, 5) is True
|
assert in_active_hours(p, 5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_for_normalises_display_name():
|
||||||
|
assert login_for("John Smith") == "johnsmith"
|
||||||
|
assert login_for("alice") == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_for_falls_back_to_user_on_punctuation():
|
||||||
|
# The realism namer and canary cultivator both rely on this so the
|
||||||
|
# decky filesystem doesn't end up with an unexpected username.
|
||||||
|
assert login_for("Mr. Robot") == "user"
|
||||||
|
assert login_for("") == "user"
|
||||||
|
assert login_for("Renée") == "user" # non-ASCII falls back
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_for_shared_by_naming_and_cultivator():
|
||||||
|
"""Single source of truth: realism naming and canary cultivator
|
||||||
|
must agree on the persona→login mapping."""
|
||||||
|
from decnet.canary import cultivator
|
||||||
|
from decnet.realism import naming
|
||||||
|
persona = "John Smith"
|
||||||
|
expected = login_for(persona)
|
||||||
|
assert naming._home(persona) == f"/home/{expected}"
|
||||||
|
# cultivator imports login_for; not duplicated.
|
||||||
|
assert cultivator.login_for is login_for
|
||||||
|
|||||||
Reference in New Issue
Block a user