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 `
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
`) : ''}