diff --git a/backend/routers/coverage.py b/backend/routers/coverage.py index ddf104a..de4a93d 100644 --- a/backend/routers/coverage.py +++ b/backend/routers/coverage.py @@ -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, }) diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 8d78542..4304398 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -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} diff --git a/frontend/index.html b/frontend/index.html index 84d0d7c..6a4723e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -280,6 +280,18 @@ function cvSetFilter(f) { return true }) + function missingFieldsCell(s) { + if (!s.missing_fields?.length) { + return s.rule_count + ? `✓ All fields covered` + : `` + } + const chips = s.missing_fields.map(f => + `${esc(f)}` + ).join(' ') + return `
${chips}
` + } + function parserCell(s) { if (s.status === 'covered') { if (s.parser === 'detected in data') { @@ -302,7 +314,8 @@ function cvSetFilter(f) { Events (7d) Status Parser - STAR Rules + STAR Rules + Detection Fields Missing ${sources.map(s => ` @@ -310,7 +323,8 @@ function cvSetFilter(f) { ${(s.event_count||0).toLocaleString()} ${LABELS[s.status]||s.status} ${parserCell(s)} - ${s.rules?.length ? s.rules.map(r=>esc(r.rule)).join(', ') : '—'} + ${s.rules?.length ? s.rules.map(r=>esc(r.rule)).join(', ') : '—'} + ${missingFieldsCell(s)} `).join('')} ` } @@ -577,6 +591,11 @@ function renderSettingsForm(fields, envExists, envPath) {
+ ${f.type === 'select' ? ` + ` : ` - ${f.secret ? `` : ''} + ${f.secret ? `` : ''}`}
- ${f.set ? `

Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}

` : `

Not configured

`} + ${f.hint ? `

${esc(f.hint)}

` : ''} + ${f.type !== 'select' ? (f.set ? `

Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}

` : `

Not configured

`) : ''}
`).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 = 'Nothing to save — fill in at least one field.'