/syscall sampler (default)
+ # socket — L2 Stage 6 collector (docs/ML_STAGE6_L2_COLLECTOR.md)
+ # off — disable sequence path entirely
self.seq_sampler = None
+ self.seq_socket_source = None
self.seq_tracker = None
self.seq_model = None
self._seq_model_mtime: float | None = None
self._last_seq_emit = 0.0
self._last_seq_flush = 0.0
- if self.cfg.enable_stage4:
+ self._seq_source = (self.cfg.seq_source or "procfs").strip().lower()
+ if self.cfg.enable_stage4 and self._seq_source != "off":
from kernel_ai.ml.sequence import NgramTracker, SyscallSampler
- self.seq_sampler = SyscallSampler(max_pids=self.cfg.seq_max_pids)
self.seq_tracker = NgramTracker(n=self.cfg.seq_n, window=self.cfg.seq_window)
+ if self._seq_source == "socket":
+ from kernel_ai.ml.collectors.socket_source import SocketSyscallSource
+
+ self.seq_socket_source = SocketSyscallSource(
+ self.cfg.seq_socket,
+ max_events=self.cfg.seq_socket_max_events,
+ )
+ logger.info("Stage 4 source=socket (%s)", self.cfg.seq_socket)
+ else:
+ self.seq_sampler = SyscallSampler(max_pids=self.cfg.seq_max_pids)
+ logger.info("Stage 4 source=procfs")
self._maybe_load_seq_model()
+ # Stage 5 — per-process L1 features + lineage (off by default).
+ self.proc_extractor = None
+ self.proc_detector = None
+ self._last_proc_flush = 0.0
+ if self.cfg.enable_stage5:
+ from kernel_ai.ml.proc_baseline import ProcBaselineDetector
+ from kernel_ai.ml.proc_features import ProcFeatureExtractor
+
+ self.proc_extractor = ProcFeatureExtractor(max_pids=self.cfg.proc_max_pids)
+ self.proc_detector = ProcBaselineDetector(
+ alpha=self.cfg.alpha,
+ warmup_samples=self.cfg.warmup_samples,
+ z_warn=self.cfg.z_warn,
+ z_crit=self.cfg.z_crit,
+ lineage_min_count=self.cfg.proc_lineage_min_count,
+ cooldown_sec=self.cfg.proc_cooldown_sec,
+ max_emit_per_tick=self.cfg.proc_max_emit,
+ )
+ try:
+ self.proc_detector.lineage.load_counts(self.store.load_lineage_counts())
+ except Exception as exc: # noqa: BLE001
+ logger.warning("failed to load lineage whitelist: %s", exc)
+ logger.info(
+ "Stage 5 enabled: max_pids=%d lineage_min=%d",
+ self.cfg.proc_max_pids,
+ self.cfg.proc_lineage_min_count,
+ )
+
+ # Stage 8 — deep sequence stub (Markov/LSTM). No-op without artifact.
+ self.deep_scorer = None
+ self._last_stage8_emit = 0.0
+ if self.cfg.enable_stage8:
+ from kernel_ai.ml.sequence_deep import DeepSequenceScorer
+
+ self.deep_scorer = DeepSequenceScorer(self.cfg)
+ logger.info(
+ "Stage 8 enabled (backend=%s, ready=%s)",
+ self.cfg.stage8_backend,
+ self.deep_scorer.ready,
+ )
+
def _maybe_load_model(self) -> None:
"""Load / hot-reload the IsolationForest artifact if present and changed.
@@ -196,19 +250,27 @@ def _maybe_load_seq_model(self) -> None:
logger.warning("failed to load STIDE profile: %s", exc)
def _tick_sequence(self) -> dict | None:
- """Sample syscalls, grow the n-gram vocabulary, and score the window."""
- if self.seq_sampler is None or self.seq_tracker is None:
+ """Ingest syscalls, grow the n-gram vocabulary, and score the window."""
+ if self.seq_tracker is None:
+ return None
+
+ if self.seq_socket_source is not None:
+ events = self.seq_socket_source.drain()
+ if events:
+ self.seq_tracker.update_stream(events)
+ elif self.seq_sampler is not None:
+ # Burst of rapid sub-samples: parked daemons still yield X,X,X (normal),
+ # while actively-working processes reveal real syscall transitions.
+ bursts = max(1, self.cfg.seq_subsamples)
+ gap = max(0.0, self.cfg.seq_subsample_gap_ms / 1000.0)
+ for i in range(bursts):
+ samples = self.seq_sampler.sample()
+ if samples:
+ self.seq_tracker.update(samples)
+ if i < bursts - 1 and gap:
+ time.sleep(gap)
+ else:
return None
- # Burst of rapid sub-samples: parked daemons still yield X,X,X (normal),
- # while actively-working processes reveal real syscall transitions.
- bursts = max(1, self.cfg.seq_subsamples)
- gap = max(0.0, self.cfg.seq_subsample_gap_ms / 1000.0)
- for i in range(bursts):
- samples = self.seq_sampler.sample()
- if samples:
- self.seq_tracker.update(samples)
- if i < bursts - 1 and gap:
- time.sleep(gap)
# Periodically persist newly observed n-grams so the profile can grow.
now = time.time()
@@ -232,6 +294,67 @@ def _tick_sequence(self) -> dict | None:
top = self.seq_model.top_unseen(window, limit=3)
return _build_sequence_anomaly(mismatch, misses, len(window), top, self.cfg)
+ def _tick_stage8(self) -> dict | None:
+ """Stage 8 stub: score the Stage 4 rolling window if a model is ready."""
+ if self.deep_scorer is None or self.seq_tracker is None:
+ return None
+ self.deep_scorer.maybe_reload()
+ if not self.deep_scorer.ready:
+ return None
+ window = self.seq_tracker.recent()
+ if len(window) < max(8, self.cfg.stage8_window // 4):
+ return None
+ tokens = window[-self.cfg.stage8_window :]
+ score = self.deep_scorer.score_tokens(tokens)
+ if not score:
+ return None
+ neg = float(score.get("neg_avg_logprob") or score.get("perplexity") or 0.0)
+ if neg < self.cfg.stage8_score_warn:
+ return None
+ now = time.time()
+ if (now - self._last_stage8_emit) < self.cfg.stage8_cooldown_sec:
+ return None
+ self._last_stage8_emit = now
+ return self.deep_scorer.build_anomaly(score, self.cfg)
+
+ def _tick_process(self) -> list[dict]:
+ """Stage 5: sample processes, score lineage/baselines, persist whitelist."""
+ if self.proc_extractor is None or self.proc_detector is None:
+ return []
+ samples = self.proc_extractor.collect()
+ now = time.time()
+ anomalies = self.proc_detector.score(samples, now=now)
+
+ if self.cfg.proc_store_snapshots and samples and (now - self._last_proc_flush) >= self.cfg.proc_flush_sec:
+ # Persist a compact interesting subset (already interest-ranked).
+ rows = [
+ {
+ "pid": s.pid,
+ "ppid": s.ppid,
+ "comm": s.comm,
+ "features": {
+ **s.features,
+ "parent_comm": s.parent_comm,
+ "age_sec": round(s.age_sec, 2),
+ "ruid": s.ruid,
+ "euid": s.euid,
+ },
+ }
+ for s in samples[:16]
+ ]
+ try:
+ self.store.insert_proc_snapshots(rows)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("proc snapshot insert failed: %s", exc)
+ pending = self.proc_detector.lineage.drain_pending()
+ if pending:
+ try:
+ self.store.upsert_lineage_counts(pending)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("lineage upsert failed: %s", exc)
+ self._last_proc_flush = now
+ return anomalies
+
def stop(self, *_args) -> None:
self._running = False
@@ -265,6 +388,34 @@ def _tick(self) -> int:
except Exception as exc: # noqa: BLE001 - never let Stage 4 kill the tick
logger.warning("sequence scoring failed: %s", exc)
+ # Stage 5: which *process* looks odd (lineage / per-comm baselines).
+ if self.cfg.enable_stage5:
+ try:
+ anomalies.extend(self._tick_process())
+ except Exception as exc: # noqa: BLE001 - never let Stage 5 kill the tick
+ logger.warning("process scoring failed: %s", exc)
+
+ # Stage 8: deep sequence (Markov/LSTM) — stub no-op without artifact.
+ if self.cfg.enable_stage8:
+ try:
+ deep_anom = self._tick_stage8()
+ if deep_anom is not None:
+ anomalies.append(deep_anom)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("stage8 scoring failed: %s", exc)
+
+ # Stage 7: ATT&CK / Sigma-lite labels on whatever Stages 1–5 emitted.
+ if self.cfg.enable_stage7 and anomalies:
+ try:
+ from kernel_ai.ml.attribution import enrich_anomalies
+
+ anomalies = enrich_anomalies(
+ anomalies,
+ min_confidence=self.cfg.attack_min_confidence,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("attribution enrich failed: %s", exc)
+
if self.cfg.store_features:
self.store.insert_feature_snapshot(features)
if anomalies:
@@ -300,6 +451,8 @@ def run(self) -> None:
# Pick up a freshly retrained model without a restart.
self._maybe_load_model()
self._maybe_load_seq_model()
+ if self.deep_scorer is not None:
+ self.deep_scorer.maybe_reload()
except Exception as exc: # noqa: BLE001 - keep the loop alive
logger.exception("tick failed: %s", exc)
# Reconnect on DB hiccups rather than dying.
diff --git a/kernel_ai/services/siem.py b/kernel_ai/services/siem.py
index e7407c8..b141690 100644
--- a/kernel_ai/services/siem.py
+++ b/kernel_ai/services/siem.py
@@ -24,7 +24,9 @@
import urllib.request
from datetime import datetime, timezone
-_ENV_PATH = "/etc/kernel-ai/elastic.env"
+# PROD default; local/dev can override with KERNEL_AI_ES_ENV_FILE or export
+# KERNEL_AI_ES_URL / KERNEL_AI_ES_API_KEY directly (no file required).
+_ENV_PATH = os.environ.get("KERNEL_AI_ES_ENV_FILE", "/etc/kernel-ai/elastic.env")
_CACHE_TTL_SEC = 45.0
_cache_lock = threading.Lock()
diff --git a/kernel_ai/services/telemetry_orchestration.py b/kernel_ai/services/telemetry_orchestration.py
index 801c233..0261e76 100644
--- a/kernel_ai/services/telemetry_orchestration.py
+++ b/kernel_ai/services/telemetry_orchestration.py
@@ -89,18 +89,22 @@ def _ml_anomalies_to_mutations(rows):
if feature in seen:
continue
seen.add(feature)
- mutations.append(
- {
- "type": feature or row.get("type") or "ml_anomaly",
- "severity": row.get("severity", "medium"),
- "message": row.get("message", ""),
- "description": row.get("message", ""),
- "position": row.get("position", 0.5),
- "source": "ml",
- "subsystem": row.get("subsystem"),
- "score": row.get("score"),
- }
- )
+ attack = row.get("attack")
+ if not attack and isinstance(row.get("meta"), dict):
+ attack = row["meta"].get("attack")
+ mut = {
+ "type": feature or row.get("type") or "ml_anomaly",
+ "severity": row.get("severity", "medium"),
+ "message": row.get("message", ""),
+ "description": row.get("message", ""),
+ "position": row.get("position", 0.5),
+ "source": "ml",
+ "subsystem": row.get("subsystem"),
+ "score": row.get("score"),
+ }
+ if attack:
+ mut["attack"] = attack
+ mutations.append(mut)
if len(mutations) >= KERNEL_DNA_ML_MAX:
break
return mutations
diff --git a/static/js/devices-belt.js b/static/js/devices-belt.js
index e99755d..a41d150 100644
--- a/static/js/devices-belt.js
+++ b/static/js/devices-belt.js
@@ -1,7 +1,7 @@
// Device Control Surface Visualization
-// Version: 24
+// Version: 25
-debugLog('🧲 devices-belt.js v24: Script loading...');
+debugLog('🧲 devices-belt.js v25: Script loading...');
class DevicesBeltVisualization {
constructor() {
@@ -33,6 +33,7 @@ class DevicesBeltVisualization {
this.deviceNodes = [];
this.links = [];
this.pulses = [];
+ this.gridCells = [];
this.subsystemOrder = ['block', 'net', 'char', 'input', 'usb'];
this.busOrder = ['pcie', 'usb', 'virtual', 'net'];
@@ -506,22 +507,64 @@ class DevicesBeltVisualization {
return line;
}
- createTinyGrid(origin, cols, rows, cellSize, gap, color, activeCount, target = this.deviceNodes) {
+ createTinyGrid(origin, cols, rows, cellSize, gap, color, activeCount, target = this.deviceNodes, options = {}) {
const total = cols * rows;
+ const occupied = Math.max(0, Math.min(total, Number(activeCount) || 0));
+ const hotCount = Math.max(0, Math.min(occupied, Number(options.hotCount) || 0));
for (let i = 0; i < total; i += 1) {
const col = i % cols;
const row = Math.floor(i / cols);
- const active = i < activeCount || (i + row) % 5 === 0;
- this.createBoxNode('', new THREE.Vector3(origin.x + col * (cellSize + gap), origin.y - row * (cellSize + gap), origin.z), new THREE.Vector3(cellSize, cellSize, 0.035), color, {
- target,
- fillOpacity: active ? 0.34 : 0.035,
- opacity: active ? 0.78 : 0.18,
- scaleX: 0.05,
- scaleY: 0.05
- });
+ const isOccupied = i < occupied;
+ const isHot = isOccupied && i < hotCount;
+ // Empty cells stay almost hollow; occupied read as lit slots; hot = live activity.
+ const baseFill = isOccupied ? (isHot ? 0.52 : 0.30) : 0.018;
+ const baseEdge = isOccupied ? (isHot ? 0.95 : 0.68) : 0.11;
+ const node = this.createBoxNode(
+ '',
+ new THREE.Vector3(origin.x + col * (cellSize + gap), origin.y - row * (cellSize + gap), origin.z),
+ new THREE.Vector3(cellSize, cellSize, 0.035),
+ color,
+ {
+ target,
+ fillColor: isOccupied ? (isHot ? 0x123a48 : 0x0a222c) : 0x05080c,
+ fillOpacity: baseFill,
+ opacity: baseEdge,
+ scaleX: 0.05,
+ scaleY: 0.05
+ }
+ );
+ const cellMeta = {
+ occupied: isOccupied,
+ hot: isHot,
+ baseFill,
+ baseEdge,
+ phase: Math.random() * Math.PI * 2,
+ speed: isHot ? 2.4 + Math.random() * 1.6 : 1.1 + Math.random() * 0.8
+ };
+ node.fill.userData.gridCell = cellMeta;
+ node.edge.userData.gridCell = cellMeta;
+ if (isOccupied) {
+ this.gridCells.push({ fill: node.fill, edge: node.edge });
+ }
}
}
+ updateGridFlicker(nowMs) {
+ if (!this.gridCells.length) return;
+ const t = nowMs * 0.001;
+ this.gridCells.forEach(({ fill, edge }) => {
+ const g = fill.userData.gridCell;
+ if (!g || !g.occupied) return;
+ const wave = 0.5 + 0.5 * Math.sin(t * g.speed + g.phase);
+ const fillAmp = g.hot ? 0.20 : 0.07;
+ const edgeAmp = g.hot ? 0.10 : 0.04;
+ fill.material.opacity = Math.min(0.85, g.baseFill + wave * fillAmp);
+ edge.material.opacity = Math.min(1, g.baseEdge + wave * edgeAmp);
+ fill.material.needsUpdate = true;
+ edge.material.needsUpdate = true;
+ });
+ }
+
lifecycleValue(stage, device, bus, category) {
const load = Number(device.load_norm || 0);
const irq = Number(device.irq_per_sec || 0);
@@ -558,6 +601,7 @@ class DevicesBeltVisualization {
this.deviceNodes = [];
this.links = [];
this.pulses = [];
+ this.gridCells = [];
this.interactiveDeviceNodes = [];
this.deviceLookup = {};
}
@@ -915,7 +959,23 @@ class DevicesBeltVisualization {
labelColor: '#061014'
});
});
- this.createTinyGrid(new THREE.Vector3(-5.92, 1.12, 0.16), 5, 4, 0.24, 0.07, 0x54d8e8, Math.min(20, 5 + list.length), this.subsystemNodes);
+ const hotLive = list.filter((d) => (
+ Number(d.load_norm || 0) > 0.12
+ || Number(d.irq_per_sec || 0) > 0.8
+ || Number(d.throughput_mb_s || 0) > 0.05
+ )).length;
+ // Utility kit: one slot per live device (no random filler cells).
+ this.createTinyGrid(
+ new THREE.Vector3(-5.92, 1.12, 0.16),
+ 5,
+ 4,
+ 0.24,
+ 0.07,
+ 0x54d8e8,
+ Math.min(20, list.length),
+ this.subsystemNodes,
+ { hotCount: Math.min(20, hotLive) }
+ );
const rightPanel = new THREE.Vector3(3.76, 1.62, 0.14);
this.createBoxNode('', rightPanel, new THREE.Vector3(4.15, 2.42, 0.08), 0x54d8e8, {
@@ -954,7 +1014,18 @@ class DevicesBeltVisualization {
kernelLabel.position.set(-1.25, 0.82, 0.16);
this.scene.add(kernelLabel);
this.subsystemNodes.push(kernelLabel);
- this.createTinyGrid(new THREE.Vector3(-3.75, 0.38, 0.14), 22, 4, 0.17, 0.08, 0x54d8e8, Math.min(88, 16 + list.length * 2), this.subsystemNodes);
+ // Contact grid: ~3 contact cells per device; hot cells track live load/irq.
+ this.createTinyGrid(
+ new THREE.Vector3(-3.75, 0.38, 0.14),
+ 22,
+ 4,
+ 0.17,
+ 0.08,
+ 0x54d8e8,
+ Math.min(88, Math.max(list.length * 3, list.length ? 6 : 0)),
+ this.subsystemNodes,
+ { hotCount: Math.min(88, Math.max(hotLive * 3, hotLive ? 2 : 0)) }
+ );
const kernelParts = [
['BUS', -3.75], ['DRV', -2.55], ['PROBE', -1.35], ['IRQ', -0.15], ['DMA', 1.05], ['UDEV', 2.25], ['VFS', 3.45]
];
@@ -1161,6 +1232,7 @@ class DevicesBeltVisualization {
this.lastFrameTime = now;
this.updateLinksAndPulses(dt);
+ this.updateGridFlicker(now);
this.camera.position.x = 0;
this.camera.position.z = 13.2;
diff --git a/static/js/kernel-dna.js b/static/js/kernel-dna.js
index a12f820..613d1e0 100755
--- a/static/js/kernel-dna.js
+++ b/static/js/kernel-dna.js
@@ -436,10 +436,16 @@ class KernelDNAVisualization {
// Severity drives the assessment marker colour (high = red, else yellow).
const isML = mutationData.source === 'ml';
const isHigh = String(mutationData.severity || '').toLowerCase() === 'high';
- const wireColor = isML ? 0x67C8E0 : this.colors.mutedText;
+ const attack = mutationData.attack || null;
+ const attackColorCss = (attack && attack.color) ? String(attack.color) : null;
+ const wireColor = isML
+ ? (attackColorCss ? parseInt(attackColorCss.replace('#', ''), 16) : 0x67C8E0)
+ : this.colors.mutedText;
const markerColor = isHigh ? 0xE0564E : this.colors.signalYellow;
- const accentCss = isML ? 'rgba(103, 200, 224, 0.9)' : 'rgba(230, 193, 90, 0.7)';
- const accentText = isML ? '#67C8E0' : '#E6C15A';
+ const accentCss = isML
+ ? (attackColorCss ? attackColorCss : 'rgba(103, 200, 224, 0.9)')
+ : 'rgba(230, 193, 90, 0.7)';
+ const accentText = isML ? (attackColorCss || '#67C8E0') : '#E6C15A';
// Mutation: "broken" appearance - distorted/irregular shape.
const geometry = new THREE.OctahedronGeometry(0.25, 0); // Irregular shape
@@ -467,8 +473,12 @@ class KernelDNAVisualization {
yellowMarker.position.copy(point);
yellowMarker.position.y += 0.3; // Position above mutation
- const baseLabel = String(mutationData.type || 'anomaly').replace(/_/g, ' ').toUpperCase().slice(0, 22);
- const tag = isML ? 'ML' : 'RULE';
+ const mitreTag = attack && attack.mitre ? String(attack.mitre) : '';
+ const baseLabel = (mitreTag
+ ? mitreTag
+ : String(mutationData.type || 'anomaly').replace(/_/g, ' ')
+ ).toUpperCase().slice(0, 22);
+ const tag = isML ? (mitreTag ? 'ATT' : 'ML') : 'RULE';
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 48;
@@ -504,6 +514,7 @@ class KernelDNAVisualization {
mutation.userData.mutationSeverity = mutationData.severity || 'medium';
mutation.userData.mutationScore = mutationData.score;
mutation.userData.description = mutationData.description || mutationData.message || 'Anomaly detected';
+ mutation.userData.mutationAttack = attack;
group.add(mutation);
group.add(yellowMarker);
@@ -2941,17 +2952,24 @@ class KernelDNAVisualization {
} else if (userData.mutationType) {
// Mutation - source-coloured header (ML cyan, rule yellow).
const isML = userData.mutationSource === 'ml';
- const headColor = isML ? '#67C8E0' : '#E6C15A';
- const sourceLabel = isML ? 'ML baseline detector' : 'Threshold rule';
+ const atk = userData.mutationAttack || null;
+ const headColor = (atk && atk.color) ? atk.color : (isML ? '#67C8E0' : '#E6C15A');
+ const sourceLabel = isML ? 'ML detector' : 'Threshold rule';
const sev = String(userData.mutationSeverity || 'medium').toUpperCase();
const scoreLine = (userData.mutationScore != null)
- ? `z-score: ${userData.mutationScore}
`
+ ? `score: ${userData.mutationScore}
`
+ : '';
+ const attackLine = atk
+ ? `ATT&CK: ${atk.mitre || '?'}${atk.family ? ' · ' + atk.family : ''}${atk.label_confidence != null ? ' · conf ' + atk.label_confidence : ''}
+ ${atk.why || atk.name || ''}${atk.source ? ' [' + atk.source + ']' : ''}
+ ${(atk.cve && atk.cve.length) ? `CVE: ${atk.cve.join(', ')}
` : ''}`
: '';
tooltipContent = `
MUTATION: ${userData.mutationType}
${userData.description || 'Anomaly detected'}
+ ${attackLine}
Source: ${sourceLabel}
Severity: ${sev}
${scoreLine}
diff --git a/templates/kernel-dna.html b/templates/kernel-dna.html
index 680ed77..f12aa9d 100644
--- a/templates/kernel-dna.html
+++ b/templates/kernel-dna.html
@@ -100,7 +100,7 @@ Why Visualize Kernel Behavior
-
+
-
+