mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 20:37:12 +00:00
Add Detection Fields Missing column + STAR_LIBRARY_ONLY setting
Coverage Map: - New "Detection Fields Missing" column shows dotted-path SDL fields that associated STAR rules reference but the parser does not provide - Only dotted field paths (src.ip, winEventLog.channel) are considered; single-word correlation variables and metadata tokens are excluded - Schema fields always present in events (dataSource.name, event.type etc) are excluded from the missing list Settings: - New STAR_LIBRARY_ONLY field (select: true/false) controls whether Load Library STAR Rules filters to @sentinelone.com creators or loads all - Rendered as a dropdown in the Settings form with a hint description - saveSettings now always persists select field values (not just non-empty) - load-star-rules reads STAR_LIBRARY_ONLY env var as its default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,11 +40,15 @@ def _star_query_texts(rule: dict) -> list[str]:
|
||||
|
||||
|
||||
@router.post("/load-star-rules")
|
||||
async def load_star_rules(library_only: bool = True, db: Session = Depends(get_db)):
|
||||
async def load_star_rules(library_only: bool = None, db: Session = Depends(get_db)):
|
||||
"""Fetch STAR rules from SentinelOne and index their fields.
|
||||
By default loads only Library rules (creator @sentinelone.com).
|
||||
Pass library_only=false to include custom tenant rules as well.
|
||||
library_only defaults to the STAR_LIBRARY_ONLY env var (default true).
|
||||
Pass ?library_only=false to include custom tenant rules as well.
|
||||
"""
|
||||
import os
|
||||
if library_only is None:
|
||||
library_only = os.environ.get("STAR_LIBRARY_ONLY", "true").lower() != "false"
|
||||
|
||||
try:
|
||||
rules = await s1_client.get_star_rules()
|
||||
except Exception as e:
|
||||
@@ -355,6 +359,11 @@ def get_coverage_map(db: Session = Depends(get_db)):
|
||||
return info
|
||||
return None
|
||||
|
||||
# Fields each rule needs: rule.name → set of field names
|
||||
rule_fields_index: dict[str, set] = {
|
||||
rule.name: set(rule.fields_used or []) for rule in rules
|
||||
}
|
||||
|
||||
# Build rule index: source_name → rules that reference it
|
||||
rule_by_source: dict[str, list] = {}
|
||||
for rule in rules:
|
||||
@@ -366,6 +375,13 @@ def get_coverage_map(db: Session = Depends(get_db)):
|
||||
# Rule with no explicit source filter — applies to all
|
||||
rule_by_source.setdefault("__any__", []).append({"rule": rule.name, "type": rule.rule_type})
|
||||
|
||||
# Fields to ignore when computing "missing" — these are metadata/schema fields
|
||||
# always present in events regardless of the parser
|
||||
_SCHEMA_FIELDS = {
|
||||
"dataSource.name", "dataSource.vendor", "dataSource.category",
|
||||
"event.type", "timestamp", "src.endpoint.ip", "src.endpoint.name",
|
||||
}
|
||||
|
||||
sources_out = []
|
||||
covered_count = 0
|
||||
needed_count = 0
|
||||
@@ -400,16 +416,33 @@ def get_coverage_map(db: Session = Depends(get_db)):
|
||||
|
||||
rules_for_src = rule_by_source.get(src.source_name, []) + rule_by_source.get("__any__", [])
|
||||
|
||||
# Fields all associated rules need, minus schema fields always present
|
||||
rule_fields_needed: set = set()
|
||||
for r in rules_for_src:
|
||||
rule_fields_needed |= rule_fields_index.get(r["rule"], set())
|
||||
rule_fields_needed -= _SCHEMA_FIELDS
|
||||
|
||||
# Fields the parser provides
|
||||
parser_provides = parser_index.get(matched_parser, set()) if matched_parser and matched_parser != "detected in data" else set()
|
||||
|
||||
# Missing = fields rules need that the parser doesn't provide.
|
||||
# Only consider dotted-path fields (e.g. src.ip, winEventLog.channel) —
|
||||
# single-word tokens are typically correlation variables or rule metadata.
|
||||
rule_fields_dotted = {f for f in rule_fields_needed if "." in f}
|
||||
missing_fields = sorted(rule_fields_dotted - parser_provides)
|
||||
|
||||
sources_out.append({
|
||||
"source_name": src.source_name,
|
||||
"event_count": src.event_count,
|
||||
"status": status,
|
||||
"parser": matched_parser,
|
||||
"format_type": format_type,
|
||||
"parser_fields": len(parser_index.get(matched_parser, set())) if matched_parser and matched_parser != "detected in data" else 0,
|
||||
"parser_fields": len(parser_provides),
|
||||
"parser_detected": src.parser_detected or 0,
|
||||
"rules": rules_for_src,
|
||||
"rule_count": len(rules_for_src),
|
||||
"missing_fields": missing_fields,
|
||||
"missing_fields_count": len(missing_fields),
|
||||
"synced_at": src.synced_at.isoformat() if src.synced_at else None,
|
||||
})
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ ENV_FILE = Path(os.environ.get("ENV_FILE_PATH", "/app/.env"))
|
||||
|
||||
# Fields we expose in the UI — order matters for display
|
||||
FIELDS = [
|
||||
{"key": "S1_BASE_URL", "label": "Console URL", "secret": False, "placeholder": "https://demo.sentinelone.net"},
|
||||
{"key": "S1_API_TOKEN", "label": "Console API Token", "secret": True, "placeholder": "eyJ..."},
|
||||
{"key": "SDL_XDR_URL", "label": "SDL XDR URL", "secret": False, "placeholder": "https://xdr.us1.sentinelone.net"},
|
||||
{"key": "SDL_LOG_READ_KEY", "label": "SDL Log Read Key", "secret": True, "placeholder": "1DnK0Y4e..."},
|
||||
{"key": "ANTHROPIC_API_KEY", "label": "Anthropic API Key", "secret": True, "placeholder": "sk-ant-..."},
|
||||
{"key": "S1_BASE_URL", "label": "Console URL", "secret": False, "placeholder": "https://demo.sentinelone.net"},
|
||||
{"key": "S1_API_TOKEN", "label": "Console API Token", "secret": True, "placeholder": "eyJ..."},
|
||||
{"key": "SDL_XDR_URL", "label": "SDL XDR URL", "secret": False, "placeholder": "https://xdr.us1.sentinelone.net"},
|
||||
{"key": "SDL_LOG_READ_KEY", "label": "SDL Log Read Key", "secret": True, "placeholder": "1DnK0Y4e..."},
|
||||
{"key": "ANTHROPIC_API_KEY", "label": "Anthropic API Key", "secret": True, "placeholder": "sk-ant-..."},
|
||||
{"key": "STAR_LIBRARY_ONLY", "label": "STAR Rules — Library Only", "secret": False, "placeholder": "true",
|
||||
"type": "select", "options": ["true", "false"],
|
||||
"hint": "true = load only SentinelOne Library rules (@sentinelone.com creators). false = include custom tenant rules as well."},
|
||||
]
|
||||
|
||||
FIELD_KEYS = {f["key"] for f in FIELDS}
|
||||
|
||||
+26
-5
@@ -280,6 +280,18 @@ function cvSetFilter(f) {
|
||||
return true
|
||||
})
|
||||
|
||||
function missingFieldsCell(s) {
|
||||
if (!s.missing_fields?.length) {
|
||||
return s.rule_count
|
||||
? `<span class="text-emerald-600 text-xs">✓ All fields covered</span>`
|
||||
: `<span class="text-gray-700 text-xs">—</span>`
|
||||
}
|
||||
const chips = s.missing_fields.map(f =>
|
||||
`<span class="px-1.5 py-0.5 bg-red-900/40 border border-red-800/60 rounded text-xs font-mono text-red-300">${esc(f)}</span>`
|
||||
).join(' ')
|
||||
return `<div class="flex flex-wrap gap-1">${chips}</div>`
|
||||
}
|
||||
|
||||
function parserCell(s) {
|
||||
if (s.status === 'covered') {
|
||||
if (s.parser === 'detected in data') {
|
||||
@@ -302,7 +314,8 @@ function cvSetFilter(f) {
|
||||
<th class="pb-2 pr-4 font-medium">Events (7d)</th>
|
||||
<th class="pb-2 pr-4 font-medium">Status</th>
|
||||
<th class="pb-2 pr-4 font-medium">Parser</th>
|
||||
<th class="pb-2 font-medium">STAR Rules</th>
|
||||
<th class="pb-2 pr-4 font-medium">STAR Rules</th>
|
||||
<th class="pb-2 font-medium">Detection Fields Missing</th>
|
||||
</tr></thead>
|
||||
<tbody>${sources.map(s => `
|
||||
<tr class="border-b border-gray-800/50 hover:bg-gray-900/30">
|
||||
@@ -310,7 +323,8 @@ function cvSetFilter(f) {
|
||||
<td class="py-2 pr-4 text-xs text-gray-400">${(s.event_count||0).toLocaleString()}</td>
|
||||
<td class="py-2 pr-4"><span class="px-2 py-0.5 rounded text-xs border ${STYLES[s.status]||''}">${LABELS[s.status]||s.status}</span></td>
|
||||
<td class="py-2 pr-4 text-xs">${parserCell(s)}</td>
|
||||
<td class="py-2 text-xs text-gray-400">${s.rules?.length ? s.rules.map(r=>esc(r.rule)).join(', ') : '—'}</td>
|
||||
<td class="py-2 pr-4 text-xs text-gray-400">${s.rules?.length ? s.rules.map(r=>esc(r.rule)).join(', ') : '—'}</td>
|
||||
<td class="py-2 text-xs">${missingFieldsCell(s)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`
|
||||
}
|
||||
@@ -577,6 +591,11 @@ function renderSettingsForm(fields, envExists, envPath) {
|
||||
<div class="space-y-1">
|
||||
<label class="block text-xs font-medium text-gray-400 uppercase tracking-wide">${esc(f.label)}</label>
|
||||
<div class="flex gap-2">
|
||||
${f.type === 'select' ? `
|
||||
<select id="st-${f.key}" data-key="${f.key}" data-secret="false"
|
||||
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-purple-500">
|
||||
${(f.options||[]).map(o => `<option value="${esc(o)}" ${f.value===o?'selected':''}>${esc(o)}</option>`).join('')}
|
||||
</select>` : `
|
||||
<input
|
||||
id="st-${f.key}"
|
||||
type="${f.secret ? 'password' : 'text'}"
|
||||
@@ -586,9 +605,10 @@ function renderSettingsForm(fields, envExists, envPath) {
|
||||
data-secret="${f.secret}"
|
||||
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-purple-500 font-${f.secret ? 'mono' : 'sans'}"
|
||||
>
|
||||
${f.secret ? `<button onclick="toggleSecret('st-${f.key}')" class="px-3 py-2 rounded-lg border border-gray-700 text-gray-400 hover:text-gray-100 text-xs hover:border-gray-500 transition-colors">Show</button>` : ''}
|
||||
${f.secret ? `<button onclick="toggleSecret('st-${f.key}')" class="px-3 py-2 rounded-lg border border-gray-700 text-gray-400 hover:text-gray-100 text-xs hover:border-gray-500 transition-colors">Show</button>` : ''}`}
|
||||
</div>
|
||||
${f.set ? `<p class="text-xs text-gray-600">Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}</p>` : `<p class="text-xs text-amber-600">Not configured</p>`}
|
||||
${f.hint ? `<p class="text-xs text-gray-600">${esc(f.hint)}</p>` : ''}
|
||||
${f.type !== 'select' ? (f.set ? `<p class="text-xs text-gray-600">Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}</p>` : `<p class="text-xs text-amber-600">Not configured</p>`) : ''}
|
||||
</div>`).join('')
|
||||
|
||||
document.getElementById('st-content').innerHTML = `
|
||||
@@ -636,7 +656,8 @@ function toggleSecret(id) {
|
||||
async function saveSettings() {
|
||||
const updates = {}
|
||||
document.querySelectorAll('[data-key]').forEach(el => {
|
||||
if (el.value.trim()) updates[el.dataset.key] = el.value.trim()
|
||||
// Always save select fields; only save text/password inputs when non-empty
|
||||
if (el.tagName === 'SELECT' || el.value.trim()) updates[el.dataset.key] = el.value.trim()
|
||||
})
|
||||
if (!Object.keys(updates).length) {
|
||||
document.getElementById('st-msg').innerHTML = '<span class="text-gray-400">Nothing to save — fill in at least one field.</span>'
|
||||
|
||||
Reference in New Issue
Block a user