The same detection on a test VM and on a domain controller are not the same incident, yet a vendor severity tag treats them identically. This guide implements the asset-tier multiplier that injects business context into the score, as one precise technique within dynamic severity scoring and the broader alert correlation rule engines pipeline.
Root-Cause Context
Detection content assigns severity based on the signature, before it knows anything about the asset. A signature-based “medium” for suspicious PowerShell is a medium whether it fires on an ephemeral CI runner or on the tier-0 identity infrastructure that would let an attacker forge tickets for the whole domain. The business impact — the thing triage actually cares about — lives in an asset inventory that the detection engine never consulted.
The failure this produces is subtle but corrosive: analysts learn that “high” does not reliably mean “important,” because a flood of highs on disposable infrastructure trains them to distrust the label. Injecting asset criticality as a multiplier restores the correlation between score and consequence. The multiplier must come from an authoritative, version-controlled tier mapping — not a hard-coded list — so that reclassifying an asset (a test box promoted to production) immediately changes how its alerts are scored, with a full audit trail.
Prerequisites
The implementation targets Python 3.11+ and uses only the standard library plus pydantic. It assumes an authoritative asset inventory keyed by the ECS host.id, normalized upstream by JSON event normalization, and feeds its weighted score to the parent dynamic severity scoring engine.
pip install "pydantic>=2.6,<3.0"
Production-Ready Implementation
The multiplier is looked up from a versioned tier map; an unknown asset defaults to a conservative standard tier and is flagged for inventory reconciliation rather than silently under- or over-scored.
from __future__ import annotations
import logging
from dataclasses import dataclass
from pydantic import BaseModel, Field
logger = logging.getLogger("soc.scoring.asset")
# Versioned tier -> multiplier map (loaded from config in production).
TIER_MULTIPLIERS: dict[str, float] = {
"sandbox": 0.5,
"standard": 1.0,
"sensitive": 1.5,
"crown_jewel": 2.0,
}
DEFAULT_TIER = "standard"
class ScoredAlert(BaseModel):
alert_id: str = Field(min_length=1)
host_id: str = Field(min_length=1)
base_severity: float = Field(ge=0, le=100)
@dataclass(frozen=True)
class AssetRecord:
host_id: str
tier: str
class AssetWeighter:
def __init__(self, inventory: dict[str, AssetRecord], ceiling: float = 100.0) -> None:
self._inv = inventory
self._ceiling = ceiling
def multiplier_for(self, host_id: str) -> tuple[float, str]:
record = self._inv.get(host_id)
if record is None:
logger.warning("ERR_SCORE_051 unknown asset host=%s -> default", host_id)
return TIER_MULTIPLIERS[DEFAULT_TIER], DEFAULT_TIER
mult = TIER_MULTIPLIERS.get(record.tier)
if mult is None:
logger.warning("ERR_SCORE_052 unknown tier=%s host=%s", record.tier, host_id)
return TIER_MULTIPLIERS[DEFAULT_TIER], DEFAULT_TIER
return mult, record.tier
def weight(self, alert: ScoredAlert) -> dict:
mult, tier = self.multiplier_for(alert.host_id)
weighted = min(self._ceiling, alert.base_severity * mult)
return {
"alert_id": alert.alert_id,
"host_id": alert.host_id,
"base_severity": alert.base_severity,
"asset_tier": tier,
"multiplier": mult,
"weighted_severity": round(weighted, 2),
}
if __name__ == "__main__":
inv = {
"DC-1": AssetRecord("DC-1", "crown_jewel"),
"CI-7": AssetRecord("CI-7", "sandbox"),
}
w = AssetWeighter(inv)
dc = w.weight(ScoredAlert(alert_id="a1", host_id="DC-1", base_severity=50))
ci = w.weight(ScoredAlert(alert_id="a2", host_id="CI-7", base_severity=50))
assert dc["weighted_severity"] == 100.0 # 50 * 2.0, clamped at ceiling
assert ci["weighted_severity"] == 25.0 # 50 * 0.5
The clamp at the ceiling keeps a crown-jewel multiplier from producing an out-of-range score, and the default-tier fallback ensures an asset missing from inventory is scored conservatively (never silently at zero) while its absence is surfaced as ERR_SCORE_051 for reconciliation.
Error-Code Reference
| Code | Meaning | Action |
|---|---|---|
ERR_SCORE_051 |
Asset not found in inventory | Apply default tier; emit metric; reconcile the CMDB |
ERR_SCORE_052 |
Asset has an unknown tier value | Apply default tier; fix the tier taxonomy |
ERR_SCORE_053 |
Tier multiplier config failed to load | Fall back to all-1.0 (identity) weighting; alert loudly |
ERR_SCORE_054 |
Weighted score exceeds ceiling before clamp | Clamp to ceiling; expected for high base × high tier |
Operational Notes
- Tiers are config, not code. Store the tier map and the inventory in version control so reclassifying an asset is a reviewed, audited change that immediately affects scoring.
- Default conservatively. An unknown asset should score at the standard tier, never zero — under-scoring an unclassified crown jewel is the dangerous direction.
- Compose, don’t collide. The asset multiplier is one factor; combine it multiplicatively with the ATT&CK and convergence multipliers in the parent engine, then clamp once, so factors reinforce rather than one dominating.
- Reconcile drift. Track the
ERR_SCORE_051rate; a rising unknown-asset rate means inventory coverage is decaying and scores are increasingly defaulted.
Verification Checklist
FAQ
Should asset criticality add to or multiply the score?
Multiply. A multiplier makes asset context compound with the other risk factors the way real risk does — a dangerous technique on a critical asset reinforces — whereas adding a fixed asset bonus lets that term dominate low-severity signals and flattens high-severity ones. Multiplicative weighting, followed by a single clamp to the ceiling, keeps the factors composable and bounded.
What multiplier should a crown-jewel asset get?
Enough to reliably float a medium-severity detection on a tier-0 asset above a high-severity detection on a disposable one, but not so much that every crown-jewel alert pins at the ceiling and loses resolution. A ×2.0 top tier against a ×0.5 bottom tier gives a 4× spread, which is usually sufficient; validate by replaying real alerts and confirming the resulting queue order matches analyst intuition.