diff --git a/.gitignore b/.gitignore index a0ed7703..fe322a54 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,4 @@ package-lock.json # payloads — operator-only artifact. The synthetic ``seed_*.jsonl`` # files alongside ARE committed and exercise the harness in CI. tests/ttp/rule_precision/corpus/*.jsonl -!tests/ttp/rule_precision/corpus/seed_*.jsonl +tests/ttp/rule_precision/corpus/seed_*.jsonl diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index fa0f4f73..537078b6 100644 --- a/decnet/profiler/behave_shell/_features/__init__.py +++ b/decnet/profiler/behave_shell/_features/__init__.py @@ -33,6 +33,7 @@ from decnet.profiler.behave_shell._features.environmental import ( ) from decnet.profiler.behave_shell._features.operational import ( objective, + opsec_discipline, ) from decnet.profiler.behave_shell._features.temporal import ( escalation_pattern, @@ -85,4 +86,5 @@ FEATURES: tuple[FeatureFn, ...] = ( keyboard_layout, numpad_usage, objective, + opsec_discipline, ) diff --git a/decnet/profiler/behave_shell/_features/operational.py b/decnet/profiler/behave_shell/_features/operational.py index 17c89f19..7093276d 100644 --- a/decnet/profiler/behave_shell/_features/operational.py +++ b/decnet/profiler/behave_shell/_features/operational.py @@ -14,10 +14,18 @@ from decnet_behave_core.spec.envelope import Observation from decnet.profiler.behave_shell._ctx import SessionContext from decnet.profiler.behave_shell._features._emit import make_observation -from decnet.profiler.behave_shell._intent import classify_intent +from decnet.profiler.behave_shell._features.temporal import ( + _CLEANUP_TOKEN_HASHES, +) +from decnet.profiler.behave_shell._intent import ( + OPSEC_HISTORY_TOKENS, + classify_intent, +) from decnet.profiler.behave_shell._thresholds import ( + EXIT_BEHAVIOR_LOOKBACK_K, INTENT_FULL_CONFIDENCE_MIN, INTENT_MIN_COMMANDS, + MIN_COMMANDS_FOR_FULL_CONFIDENCE, ) @@ -60,3 +68,44 @@ def objective(ctx: SessionContext) -> Iterator[Observation]: value=value, confidence=confidence, ) + + +def opsec_discipline(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``operational.opsec_discipline`` ∈ {careful, careless, learning}. + + * ``careful`` — operator hits ``OPSEC_HISTORY_TOKENS`` AND the + tail-K (=``EXIT_BEHAVIOR_LOOKBACK_K``) commands include cleanup + vocabulary (locally re-derived; we do **not** read prior + observations). + * ``learning`` — operator hits ``OPSEC_HISTORY_TOKENS`` but does + NOT close with cleanup tokens. Half-discipline. + * ``careless`` — no ``OPSEC_HISTORY_TOKENS`` hits at all. + + Skip emission when no commands. Confidence 0.45 (small lexicon, + soft); 0.30 below ``MIN_COMMANDS_FOR_FULL_CONFIDENCE`` (=5). + """ + if not ctx.commands: + return + has_history = any( + c.first_token_hash in OPSEC_HISTORY_TOKENS for c in ctx.commands + ) + tail = ctx.commands[-EXIT_BEHAVIOR_LOOKBACK_K:] + has_cleanup_tail = any( + c.first_token_hash in _CLEANUP_TOKEN_HASHES for c in tail + ) + if not has_history: + value = "careless" + elif has_cleanup_tail: + value = "careful" + else: + value = "learning" + if len(ctx.commands) < MIN_COMMANDS_FOR_FULL_CONFIDENCE: + confidence = 0.30 + else: + confidence = 0.45 + yield make_observation( + ctx, + primitive="operational.opsec_discipline", + value=value, + confidence=confidence, + ) diff --git a/decnet/web/db/models/topology.py b/decnet/web/db/models/topology.py index 59825f74..ed926d1f 100644 --- a/decnet/web/db/models/topology.py +++ b/decnet/web/db/models/topology.py @@ -31,7 +31,7 @@ class Topology(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) name: str = Field(index=True, unique=True) mode: Literal["unihost", "agent"] = Field( - default="unihost", sa_column=Column("mode", String, nullable=False, default="unihost") + default="unihost", sa_column=Column("mode", String(16), nullable=False, default="unihost") ) # When ``mode == "agent"``, pins this topology to a specific enrolled # worker. ``None`` for unihost topologies (master-local deploy). @@ -46,7 +46,7 @@ class Topology(SQLModel, table=True): "pending", "deploying", "active", "degraded", "failed", "tearing_down", "torn_down" ] = Field( default="pending", - sa_column=Column("status", String, nullable=False, default="pending", index=True), + sa_column=Column("status", String(32), nullable=False, default="pending", index=True), ) status_changed_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc) @@ -121,7 +121,7 @@ class TopologyDecky(SQLModel, table=True): "pending", "running", "failed", "torn_down", "degraded", "tearing_down", "teardown_failed" ] = Field( default="pending", - sa_column=Column("state", String, nullable=False, default="pending", index=True), + sa_column=Column("state", String(32), nullable=False, default="pending", index=True), ) last_error: Optional[str] = Field( default=None, sa_column=Column("last_error", Text, nullable=True) @@ -186,13 +186,13 @@ class TopologyMutation(SQLModel, table=True): ) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) topology_id: str = Field(foreign_key="topologies.id", index=True) - op: _MUTATION_OPS = Field(sa_column=Column("op", String, nullable=False, index=True)) + op: _MUTATION_OPS = Field(sa_column=Column("op", String(32), nullable=False, index=True)) payload: str = Field( sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}") ) state: Literal["pending", "applying", "applied", "failed"] = Field( default="pending", - sa_column=Column("state", String, nullable=False, default="pending", index=True), + sa_column=Column("state", String(32), nullable=False, default="pending", index=True), ) requested_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), index=True diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index fa194020..0f3415b2 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -24,7 +24,7 @@ interface ConfigData { const Config: React.FC = () => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals' | 'appearance' | 'workers'>('limits'); + const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals' | 'appearance' | 'workers' | 'ttp'>('limits'); const [accent, setAccent] = useState<'matrix' | 'violet'>(() => { try { const raw = localStorage.getItem('decnet_tweaks'); diff --git a/decnet_web/src/components/TTPInspector.css b/decnet_web/src/components/TTPInspector.css index 8a508829..d1414d7d 100644 --- a/decnet_web/src/components/TTPInspector.css +++ b/decnet_web/src/components/TTPInspector.css @@ -139,8 +139,8 @@ font-family: var(--mono, ui-monospace, monospace); font-size: 0.74rem; display: grid; - grid-template-columns: 60px 1fr; - column-gap: 12px; + grid-template-columns: max-content 1fr; + column-gap: 14px; row-gap: 3px; max-height: 320px; overflow-y: auto; @@ -153,12 +153,14 @@ font-size: 0.66rem; align-self: baseline; padding-top: 2px; + white-space: nowrap; } .ttp-evidence-v { color: var(--matrix); word-break: break-all; white-space: pre-wrap; + min-width: 0; } .ttp-empty { diff --git a/tests/profiler/behave_shell/test_operational_opsec_discipline.py b/tests/profiler/behave_shell/test_operational_opsec_discipline.py new file mode 100644 index 00000000..ee1e2ab6 --- /dev/null +++ b/tests/profiler/behave_shell/test_operational_opsec_discipline.py @@ -0,0 +1,73 @@ +"""Step G.2: ``operational.opsec_discipline`` ∈ {careful, careless, learning}.""" +from __future__ import annotations + +from decnet.profiler.behave_shell import extract_session +from decnet.profiler.behave_shell._parse import AsciinemaEvent + + +PRIMITIVE = "operational.opsec_discipline" + + +def _of(observations: list, primitive: str): + obs = [o for o in observations if o.primitive == primitive] + assert len(obs) == 1, f"expected exactly one {primitive}, got {len(obs)}" + return obs[0] + + +def _typed(text: str, t0: float = 0.0, dt: float = 0.05) -> list[AsciinemaEvent]: + return [(t0 + i * dt, "i", c) for i, c in enumerate(text)] + + +def _cmd(token: str, t0: float, *, with_prompt: bool = True) -> list[AsciinemaEvent]: + events = _typed(f"{token}\r", t0=t0) + cmd_end = t0 + len(token) * 0.05 + if with_prompt: + events.append((cmd_end + 0.10, "o", "out\nanti@host:~$ ")) + else: + events.append((cmd_end + 0.10, "o", "out\n")) + return events + + +def test_no_commands_no_emission() -> None: + out = list(extract_session([(0.0, "i", "x")], sid="g2-empty")) + assert [o for o in out if o.primitive == PRIMITIVE] == [] + + +def test_careless_no_history_tokens() -> None: + events = _cmd("ls", t0=0.0) + _cmd("pwd", t0=1.0) + _cmd("cat", t0=2.0) + obs = _of(list(extract_session(events, sid="g2-careless")), PRIMITIVE) + assert obs.value == "careless" + + +def test_careful_history_then_cleanup_tail() -> None: + """``history`` early + ``rm`` / ``shred`` in tail-3 → careful.""" + events = ( + _cmd("history", t0=0.0) + + _cmd("ls", t0=1.0) + + _cmd("pwd", t0=2.0) + + _cmd("rm", t0=3.0) + + _cmd("shred", t0=4.0) + + _cmd("clear", t0=5.0) + ) + obs = _of(list(extract_session(events, sid="g2-careful")), PRIMITIVE) + assert obs.value == "careful" + + +def test_learning_history_no_cleanup_tail() -> None: + """``history`` hit but tail is recon — knows the trick, doesn't apply.""" + events = ( + _cmd("history", t0=0.0) + + _cmd("ls", t0=1.0) + + _cmd("pwd", t0=2.0) + + _cmd("cat", t0=3.0) + + _cmd("find", t0=4.0) + ) + obs = _of(list(extract_session(events, sid="g2-learning")), PRIMITIVE) + assert obs.value == "learning" + + +def test_low_command_count_drops_confidence() -> None: + events = _cmd("ls", t0=0.0) + _cmd("pwd", t0=1.0) + obs = _of(list(extract_session(events, sid="g2-thin")), PRIMITIVE) + assert obs.confidence == 0.30 + assert obs.value == "careless" diff --git a/threatfox-api.json b/threatfox-api.json new file mode 100644 index 00000000..bacdfcdc --- /dev/null +++ b/threatfox-api.json @@ -0,0 +1,70 @@ +{ + "query_status": "ok", + "data": { + "1": { + "ioc_type": "url", + "fk_threat_type": "payload_delivery", + "description": "URL that delivers a malware payload" + }, + "2": { + "ioc_type": "domain", + "fk_threat_type": "payload_delivery", + "description": "Domain name that delivers a malware payload" + }, + "3": { + "ioc_type": "ip:port", + "fk_threat_type": "payload_delivery", + "description": "ip:port combination that delivery a malware payload" + }, + "4": { + "ioc_type": "url", + "fk_threat_type": "botnet_cc", + "description": "URL that is used for botnet Command&control (C&C)" + }, + "5": { + "ioc_type": "domain", + "fk_threat_type": "botnet_cc", + "description": "Domain that is used for botnet Command&control (C&C)" + }, + "6": { + "ioc_type": "ip:port", + "fk_threat_type": "botnet_cc", + "description": "ip:port combination that is used for botnet Command&control (C&C)" + }, + "7": { + "ioc_type": "envelope_from", + "fk_threat_type": "payload_delivery", + "description": "Sender email address (envelope from) that is used for payload delivery" + }, + "8": { + "ioc_type": "body_from", + "fk_threat_type": "payload_delivery", + "description": "Sender email address (body from) that is used for payload delivery" + }, + "9": { + "ioc_type": "md5_hash", + "fk_threat_type": "payload", + "description": "MD5 hash of a malware sample (payload)" + }, + "10": { + "ioc_type": "sha256_hash", + "fk_threat_type": "payload", + "description": "SHA256 hash of a malware sample (payload)" + }, + "11": { + "ioc_type": "sha3_384_hash", + "fk_threat_type": "payload", + "description": "SHA3-384 hash of a malware sample (payload)" + }, + "12": { + "ioc_type": "sha1_hash", + "fk_threat_type": "payload", + "description": "SHA1 hash of a malware sample (payload)" + }, + "13": { + "ioc_type": "domain", + "fk_threat_type": "cc_skimming", + "description": "Domain used for credit card skimming (usually related to Magecart attacks)" + } + } +} \ No newline at end of file