This page generates reference material from the implementation. The stable
conceptual API is the event → detection → score → finding flow; individual
classes remain subject to alpha-version changes.
defregister(self,plugin:Plugin)->None:"""Register a plugin by its metadata name."""name=plugin.meta.nameifnameinself._plugins:msg=f"Plugin already registered: {name}"raiseValueError(msg)self._plugins[name]=plugin
defget_plugin(self,name:str)->Plugin:"""Fetch a registered plugin or raise KeyError."""try:returnself._plugins[name]exceptKeyErrorasexc:msg=f"Plugin not found: {name}"raiseKeyError(msg)fromexc
defingest_raw(self,plugin_name:str,records:Sequence[dict[str,Any]],context:PluginContext|None=None,)->list[Event]:"""Normalize raw records via a plugin and store the resulting events."""plugin=self.get_plugin(plugin_name)ctx=contextorPluginContext()events=plugin.normalize(records,ctx)self.ingest(events)returnevents
defanalyze(self,*,plugin_name:str|None=None,events:Sequence[Event]|None=None,context:PluginContext|None=None,)->AnalysisResult:"""Analyze events with one or all plugins."""source_events=list(events)ifeventsisnotNoneelseself.store.list_events()ifnotself._plugins:returnAnalysisResult(events_processed=len(source_events))pipeline=AnalysisPipeline(list(self._plugins.values()))ctx=contextorPluginContext(config={"risk_threshold":self.settings.risk_threshold})result:PipelineResultifplugin_nameisnotNone:result=pipeline.run_events(source_events,ctx,plugin_name=plugin_name)else:result=pipeline.run_all(source_events,ctx)elevated=[fforfinresult.findingsiff.score.value>=self.settings.risk_threshold]returnAnalysisResult(findings=result.findings,events_processed=len(source_events),elevated=elevated,)
defrun_plugin(self,plugin_name:str,context:PluginContext|None=None)->AnalysisResult:"""Run a plugin's full collect→normalize→detect→score→explain pipeline."""plugin=self.get_plugin(plugin_name)findings=plugin.run(context)events=self.store.list_events()elevated=[fforfinfindingsiff.score.value>=self.settings.risk_threshold]returnAnalysisResult(findings=findings,events_processed=len(events),elevated=elevated,)
defcollect(self,context:PluginContext)->Iterable[dict[str,Any]]:"""Collect raw vendor/source records. Default implementation yields nothing; override when the plugin owns its own data sources. """return[]
@abstractmethoddefnormalize(self,records:Sequence[dict[str,Any]],context:PluginContext,)->list[Event]:"""Normalize raw records into canonical :class:`Event` objects."""
@abstractmethoddefdetect(self,events:Sequence[Event],context:PluginContext)->list[Detection]:"""Produce detections from normalized events (no AI required)."""
@abstractmethoddefscore(self,detection:Detection,events:Sequence[Event],context:PluginContext,)->ScoreBreakdown:"""Compute a transparent risk score for a detection."""
defwith_attribute(self,key:str,value:Any)->Event:"""Return a copy with an additional attribute (immutability-friendly)."""merged={**self.attributes,key:value}returnself.model_copy(update={"attributes":merged})
defaggregate_score(factors:Sequence[ScoreFactor],*,confidence:float,reasoning:str,evidence:Sequence[Evidence]|None=None,references:Sequence[KnowledgeReference]|None=None,baseline:float=0.0,)->ScoreBreakdown:"""Aggregate signed factor contributions into an explainable score. Positive contributions increase risk; negative contributions reduce it. The result is always clamped to [0, 1]. """total=baseline+sum(f.contributionforfinfactors)all_evidence:list[Evidence]=list(evidenceor[])forfactorinfactors:all_evidence.extend(factor.evidence)returnScoreBreakdown(value=clamp01(total),confidence=clamp01(confidence),factors=list(factors),evidence=all_evidence,reasoning=reasoning,references=list(referencesor[]),)
defwhy_malicious(detection:Detection,score:ScoreBreakdown)->str:"""Short answer to: Why was this event classified as malicious?"""top=sorted(score.factors,key=lambdaf:abs(f.contribution),reverse=True)[:3]ifnottop:returnscore.reasoningparts=[f"{f.name} ({f.contribution:+.2f})"forfintop]returnf"{detection.title} scored {score.value:.2f} due to: "+", ".join(parts)+"."
Call :meth:fit with known-normal baseline events, then call
:meth:detect on later events. Scaling and threshold calibration use only
the baseline; the held-out tail of that baseline determines the threshold.
TensorFlow is imported only when fit is called.
def__init__(self,*,sequence_length:int=20,validation_fraction:float=0.2,percentile:float=99.0,epochs:int=25,batch_size:int=32,)->None:ifsequence_length<2:raiseValueError("sequence_length must be at least 2")ifepochs<1orbatch_size<1:raiseValueError("epochs and batch_size must be positive")ifnot0.0<validation_fraction<1.0:raiseValueError("validation_fraction must be between 0 and 1")ifnot0.0<percentile<100.0:raiseValueError("percentile must be between 0 and 100")self.sequence_length=sequence_lengthself.validation_fraction=validation_fractionself.percentile=percentileself.epochs=epochsself.batch_size=batch_sizeself._mean:np.ndarray|None=Noneself._scale:np.ndarray|None=Noneself._threshold:float|None=Noneself._model:Any=None
deffit(self,baseline_events:Sequence[Event])->None:"""Fit on known-normal events and calibrate on a held-out tail."""# Split raw buckets within each actor BEFORE creating overlapping windows.# No observation is shared between fitting and threshold calibration.training_parts=[]validation_parts=[]foractor,bucketsinself._buckets(baseline_events).items():vectors=np.array([row[0]forrowinbuckets],dtype=float)split=int(len(vectors)*(1.0-self.validation_fraction))ifmin(split,len(vectors)-split)<self.sequence_length:raiseValueError(f"Actor {actor!r} needs at least {self.sequence_length} buckets ""in BOTH training and validation partitions.")training_parts.append(vectors[:split])validation_parts.append(vectors[split:])ifnottraining_parts:raiseValueError("No supported baseline events")raw_training=np.concatenate(training_parts)mean=raw_training.mean(axis=0)scale=raw_training.std(axis=0)scale[scale==0]=1.0training=(np.concatenate([self._windows(p)forpintraining_parts])-mean)/scalevalidation=(np.concatenate([self._windows(p)forpinvalidation_parts])-mean)/scaletensorflow:Any=_tensorflow()model=tensorflow.keras.Sequential([tensorflow.keras.layers.Input(shape=(self.sequence_length,len(_FEATURES))),tensorflow.keras.layers.LSTM(64,return_sequences=True),tensorflow.keras.layers.Dropout(0.2),tensorflow.keras.layers.LSTM(32),tensorflow.keras.layers.Dense(len(_FEATURES)),])model.compile(optimizer="adam",loss="mse")model.fit(training,training[:,-1,:],epochs=self.epochs,batch_size=self.batch_size,shuffle=False,verbose=0,)predicted=model.predict(validation,verbose=0)errors=np.mean(np.square(predicted-validation[:,-1,:]),axis=1)ifnotnp.all(np.isfinite(errors)):raiseValueError("Model produced non-finite calibration errors")# Publish state only after a successful fit, including on refits.self._mean,self._scale=mean,scaleself._threshold=float(np.percentile(errors,self.percentile))self._model=model
defdetect(self,events:Sequence[Event])->list[Detection]:"""Return a detection for every sequence above the fitted threshold."""ifself._modelisNoneorself._meanisNoneorself._scaleisNone:raiseRuntimeError("Call fit() before detect()")buckets=self._buckets(events)detections:list[Detection]=[]foractor,actor_bucketsinbuckets.items():vectors=np.array([row[0]forrowinactor_buckets],dtype=float)iflen(vectors)<self.sequence_length:continuesequences=self._windows(vectors)scaled=self._transform(sequences)predicted=self._model.predict(scaled,verbose=0)errors=np.mean(np.square(predicted-scaled[:,-1,:]),axis=1)ifnotnp.all(np.isfinite(errors)):raiseValueError("Model produced non-finite inference errors")foroffset,errorinenumerate(errors):iffloat(error)<=self.threshold:continue_,timestamp,event_ids=actor_buckets[offset+self.sequence_length-1]detections.append(Detection(plugin="insider-lstm",rule_id="insider.lstm_reconstruction_error",title=f"LSTM behavioral anomaly for {actor}",description=("The per-actor feature sequence had reconstruction error "f"{float(error):.4f}, above the calibrated threshold "f"{self.threshold:.4f}."),severity=DetectionSeverity.MEDIUM,event_ids=[event.idforeventinevent_ids],evidence=[Evidence(kind=EvidenceKind.BEHAVIORAL,summary="Per-actor LSTM reconstruction anomaly",details={"actor":actor,"timestamp":timestamp.isoformat(),"reconstruction_error":float(error),"threshold":self.threshold,"features":[item.valueforitemin_FEATURES],},weight=0.7,)],tags=["insider","lstm","ml","anomaly"],))returndetections
defanalyze(self,events:Sequence[Event])->list[Finding]:"""Convert ML detections into transparent, reviewable SATARK findings."""findings:list[Finding]=[]fordetectioninself.detect(events):evidence=detection.evidence[0]error=float(evidence.details["reconstruction_error"])factor=ScoreFactor(name="lstm_reconstruction_error",contribution=(0.6ifself.threshold==0elsemin(0.6,0.3*error/self.threshold)),description=(f"Reconstruction error {error:.4f} exceeds the calibrated "f"threshold {self.threshold:.4f}."),evidence=list(detection.evidence),)score=aggregate_score([factor],confidence=0.6,reasoning=("Optional LSTM model flagged an unusual per-actor feature sequence. ""Review the listed telemetry before taking action."),baseline=0.1,)explanation=(f"{detection.title}: reconstruction error {error:.4f} was above "f"the baseline threshold {self.threshold:.4f}.")findings.append(Finding(detection=detection,score=score,explanation=explanation))returnfindings
satark.core.storage provides InMemoryEventStore for experiments and
JsonlEventStore for lightweight persistent event logs. satark.graph offers
EntityGraph, build_timeline, and find_attack_paths. satark.rules
contains regex, Sigma-like, STIX-like, and custom predicate rule engines; YARA
is a placeholder that requires external integration.
satark.knowledge provides static, versioned lookup providers. The included
catalogs are small seeds, not a replacement for a maintained upstream data-sync
process.
For deeper module docs, browse the source under src/satark/.