diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 8723c5a..3bbc9d4 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -185,7 +185,7 @@ HTML = """

Events and Anomalies

Baseline and Stream Health

Operator Guidance

Waiting for monitor data.

Correlation Map

Waiting for correlated entities.

Investigation Incidents

Anomalies

Recommendations

AI Assessment

LLM assessment disabled.
-

Triage Queue

Field Baseline Deviations

Active Feedback Rules

Related Activity Across Sources

Block Candidates

Threat Intelligence

Policy Findings

+

Triage Queue

Field Baseline Deviations

Active Feedback Rules

Related Activity Across Sources

Block Candidates

Threat Intelligence

Policy Findings

Diagnostics

Recommended Stream Profiles

Waiting for observed stream data.

Installed Ollama Models

Loading local Ollama models.

Runtime Configuration

How To Use SignalScope

1. Normal workflow

  1. Settings: connect Graylog MCP and enable streams.
  2. Settings: apply recommended profiles for missing streams.
  3. Diagnostics: confirm raw samples, aggregate counts, and profile readiness.
  4. Overview: use Operator Guidance, incidents, trends, and correlation map.
  5. Findings: review only high-signal deviations first, then mark decisions.

2. Stream health

  • ready: profile exists, events are arriving, and baseline fields are ready.
  • learning: profile exists and events arrive, but baseline age or buckets are still too low.
  • missing_profile: stream is enabled but no profile exists. Apply or edit one.
  • no_events: stream is enabled but the current poll has no raw sample events.
  • partial_fetch: Graylog returned only part of the requested raw sample.

3. Ready fields

0/8 means 8 profile fields are tracked but none are mature enough yet. A field needs at least 12 baseline buckets and the configured Baseline training days before it is ready.

During learning, treat findings as signals to tune profiles, not as final alerts.

4. Profiles

  • Entity fields define who or what behavior is tracked, such as user, host, source IP, or application actor.
  • Baseline fields define the changing behavior to learn, such as action, event ID, service, URL, status, or counters.
  • Relationships learn pairs such as username to srcip or host to process.
  • Detectors add burst checks for auth failures, DNS queries, and deny actions.

5. Findings

  • Start with Triage Queue and incidents, not raw long tables.
  • Open evidence details before confirming a finding.
  • Use Expected for known behavior, False positive for bad signal, Confirmed for real investigation items.
  • Use expiry when a behavior is expected only temporarily.

6. High EPS / MCP

  • Use aggregate or auto fetch mode for high EPS streams.
  • Keep raw samples small enough for context; aggregate counts represent the full window.
  • Sample capped is normal in aggregate mode. Truncated raw mode means you may miss context.
  • If MCP is stale, the UI shows cached status so you can still inspect previous findings.

7. Ollama

  • Dashboard assessment summarizes current evidence.
  • Profile advisor maps unknown/custom fields and suggests relationships.
  • Ollama advice is constrained to fields discovered from Graylog; unknown fields are rejected.

8. Baseline DB size

  • Baseline DB size is the local SQLite behavior baseline.
  • Retention deletes old rows, but SQLite only returns disk space after manual VACUUM.
  • If the DB grows fast, lower bucket retention, value retention, or max values per entity field in Settings.
  • Stop the service before running signalscope baseline-maintenance --vacuum.

9. What to fix first

  1. No streams enabled.
  2. Enabled streams with missing profiles.
  3. Enabled streams with zero raw events.
  4. Profiles stuck at 0 ready fields after the training window.
  5. Too many repeated findings without review feedback.
  6. Baseline DB growing without maintenance.
@@ -218,6 +218,8 @@ const tableSort = {}; const uiCache = {correlations: [], fieldRows: [], correlationHitboxes: [], selectedCorrelationKey: '', entityLabels: {}}; let pendingReview = null; let lastReviewAuthor = ''; +let selectedDeviationKeys = new Set(); +function deviationKey(r) { return `${r.entity}:${r.stream_id}:${r.field}:${r.value || ''}`; } window.availableStreams = []; window.streamSelection = {}; window.configuredStreams = []; @@ -793,8 +795,53 @@ async function refresh() { document.getElementById('findingSummary').textContent = `${fieldRows.length} shown from ${sourceFieldRows.length} raw deviations. Reviewed and low-score findings are hidden by default.`; if (rawFieldRows.length) uiCache.fieldRows = rawFieldRows; document.getElementById('fieldDeviations').innerHTML = `${fieldRowsCached ? '

No current field deviations in this poll; showing cached findings from the last non-empty poll.

' : ''}` + table(fieldRows, [ + {label:'', render:r => { const key = deviationKey(r); return ``; }}, {label:'Entity', key:'entity', render:r => entityCell(r)}, {label:'Stream', key:'stream_title'}, {label:'Detector', key:'detector'}, {label:'Field', key:'field'}, {label:'Score', key:'score'}, {label:'Baseline', render:r => esc(`${r.confidence || '-'}; ${r.baseline_age_days ?? 0}d; ${r.baseline_samples || 0} samples`)}, {label:'Review', render:r => esc(r.feedback || 'unreviewed')}, {label:'Evidence', render:r => { const weighted = r.weight && r.weight !== 1 ? `; weighted ${r.base_score ?? r.score} x ${r.weight}` : ''; const summary=esc(`${r.reason}; current ${r.current ?? '-'} vs baseline ${r.baseline ?? '-'}; scope ${r.baseline_scope ?? '-'}${weighted}; values: ${(r.sample_values || []).join(', ') || '-'}`); const events=(r.sample_events || []).map(item => `${esc(`${item.timestamp} | ${item.source} -> ${item.destination} | ${item.action} ${item.service} | ${item.value} | ${item.message}`)}${item.graylog_query ? `
${esc(item.graylog_query)}${graylogEvidenceLink(item.graylog_query, configuration)}` : ''}`).join('
'); const id=`deviation:${r.entity}:${r.stream_id}:${r.field}:${r.value || ''}`; return events ? `
${summary}

${events}

` : summary; }}, {label:'Review action', render:r => `
`} ], 'field-deviations'); + const bulkReviewBar = document.getElementById('bulkReviewBar'); + const updateBulkReviewBar = () => { + const count = selectedDeviationKeys.size; + bulkReviewBar.hidden = count === 0; + document.getElementById('bulkReviewCount').textContent = `${count} selected`; + }; + document.querySelectorAll('.deviation-select').forEach(box => box.addEventListener('change', () => { + if (box.checked) selectedDeviationKeys.add(box.dataset.key); else selectedDeviationKeys.delete(box.dataset.key); + updateBulkReviewBar(); + })); + const selectAllBox = document.getElementById('selectAllDeviations'); + selectAllBox.checked = fieldRows.length > 0 && fieldRows.every(row => selectedDeviationKeys.has(deviationKey(row))); + selectAllBox.onchange = () => { + fieldRows.forEach(row => { + if (selectAllBox.checked) selectedDeviationKeys.add(deviationKey(row)); else selectedDeviationKeys.delete(deviationKey(row)); + }); + refresh(); + }; + updateBulkReviewBar(); + document.getElementById('bulkReviewClear').onclick = () => { + selectedDeviationKeys.clear(); + refresh(); + }; + document.getElementById('bulkReviewSubmit').onclick = async () => { + const selectedRows = fieldRows.filter(row => selectedDeviationKeys.has(deviationKey(row))); + if (!selectedRows.length) return; + const status = document.getElementById('bulkReviewStatus').value; + const scope = document.getElementById('bulkReviewScope').value; + const note = document.getElementById('bulkReviewNote').value || ''; + const author = document.getElementById('bulkReviewAuthor').value || ''; + const days = Number(document.getElementById('bulkReviewExpiryDays').value || '0'); + const expiresAt = days > 0 ? Math.floor(Date.now()/1000) + days * 86400 : 0; + const items = selectedRows.map(row => ({status, scope, entity: row.entity, stream_id: row.stream_id, field: row.field, value: row.value, note, author, expires_at: expiresAt})); + const response = await fetch('/api/feedback/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({items})}); + const result = await response.json().catch(() => ({})); + if (response.ok) { + const errorCount = (result.errors || []).length; + document.getElementById('feedbackNotice').textContent = errorCount ? `Applied to ${(result.added||[]).length} of ${items.length} selected; ${errorCount} skipped.` : `Applied to ${items.length} selected. Matching patterns will be labeled on the next refresh.`; + selectedDeviationKeys.clear(); + refresh(); + } else { + document.getElementById('feedbackNotice').textContent = `Could not apply bulk review: ${result.error || 'invalid request'}.`; + } + }; const reviewForm = document.getElementById('reviewForm'); const reviewScopeSelect = document.getElementById('reviewScope'); const reviewScopeWarning = document.getElementById('reviewScopeWarning'); @@ -845,8 +892,10 @@ async function refresh() { document.getElementById('feedbackNotice').textContent = `Could not save review: ${error.error || 'invalid request'}.`; } }; - const feedbackRules = (data.feedback || []).slice().sort((left, right) => Number(right.created_at || 0) - Number(left.created_at || 0)); - document.getElementById('feedbackRuleSummary').textContent = `${feedbackRules.length} active rule(s).`; + const allFeedbackRules = (data.feedback || []).slice().sort((left, right) => Number(right.created_at || 0) - Number(left.created_at || 0)); + const ruleStatusFilter = document.getElementById('feedbackRuleStatusFilter')?.value || 'all'; + const feedbackRules = allFeedbackRules.filter(rule => ruleStatusFilter === 'all' || rule.status === ruleStatusFilter); + document.getElementById('feedbackRuleSummary').textContent = ruleStatusFilter === 'all' ? `${feedbackRules.length} active rule(s).` : `${feedbackRules.length} of ${allFeedbackRules.length} rule(s).`; document.getElementById('feedbackRules').innerHTML = table(feedbackRules, [ {label:'Scope', key:'scope'}, {label:'Entity', render:r => esc(r.entity || '-')}, @@ -1232,7 +1281,7 @@ document.querySelectorAll('.tab').forEach(button => button.addEventListener('cli document.querySelectorAll('.tab').forEach(item => item.classList.toggle('active', item === button)); document.querySelectorAll('[data-view]').forEach(view => view.classList.toggle('active', view.dataset.view === button.dataset.tab)); })); -['showReviewedFindings','showLowFindings','showExistingProfiles','showResolvedIncidents'].forEach(id => document.getElementById(id)?.addEventListener('change', refresh)); +['showReviewedFindings','showLowFindings','showExistingProfiles','showResolvedIncidents','feedbackRuleStatusFilter'].forEach(id => document.getElementById(id)?.addEventListener('change', refresh)); document.getElementById('correlationGraph')?.addEventListener('click', event => { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left; @@ -1368,6 +1417,14 @@ def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | except (ValueError, json.JSONDecodeError) as exc: self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8")) return + if self.path == "/api/feedback/bulk" and self._is_loopback_client(): + try: + payload = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", "0")), 65_536)).decode("utf-8")) + items = payload.get("items", []) if isinstance(payload, dict) else [] + self._send(200, "application/json", json.dumps(FeedbackStore().add_many(items if isinstance(items, list) else [])).encode("utf-8")) + except (ValueError, json.JSONDecodeError) as exc: + self._send(400, "application/json", json.dumps({"error": str(exc)}).encode("utf-8")) + return if self.path == "/api/feedback/revoke" and self._is_loopback_client(): try: payload = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", "0")), 16_384)).decode("utf-8")) diff --git a/src/fgai/feedback.py b/src/fgai/feedback.py index 738e201..d48646b 100644 --- a/src/fgai/feedback.py +++ b/src/fgai/feedback.py @@ -105,6 +105,28 @@ class FeedbackStore: self.path.write_text(json.dumps(entries, indent=2), encoding="utf-8") return entry + def add_many(self, items: list[dict[str, object]]) -> dict[str, object]: + """Normalize and save a batch of entries with a single read-modify-write. + + One bad item never blocks the rest — each is validated independently and + failures are reported by index instead of aborting the whole batch. + """ + added_by_id: dict[str, dict[str, object]] = {} + errors: list[dict[str, object]] = [] + for index, item in enumerate(items): + try: + entry = _normalize(item) + added_by_id[entry["id"]] = entry # later duplicates in the same batch win, like sequential add() calls would + except ValueError as exc: + errors.append({"index": index, "error": str(exc)}) + added = list(added_by_id.values()) + entries = self.entries() + entries = [value for value in entries if value.get("id") not in added_by_id] + entries.extend(added) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(entries, indent=2), encoding="utf-8") + return {"added": added, "errors": errors, "remaining": len(entries)} + def revoke(self, entry_id: str) -> dict[str, object]: entries = self.entries() before = len(entries) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b6b9471..dc6f796 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -81,11 +81,35 @@ class DashboardTests(unittest.TestCase): self.assertIn("/api/feedback/revoke", source) self.assertIn(".revoke(", source) + def test_dashboard_exposes_feedback_bulk_endpoint(self): + source = inspect.getsource(dashboard) + self.assertIn("/api/feedback/bulk", source) + self.assertIn(".add_many(", source) + + def test_dashboard_has_bulk_review_bar(self): + self.assertIn('id="bulkReviewBar"', HTML) + self.assertIn('id="selectAllDeviations"', HTML) + self.assertIn("deviation-select", HTML) + self.assertIn('id="bulkReviewSubmit"', HTML) + + def test_bulk_review_scope_never_offers_broad_scopes(self): + start = HTML.index('id="bulkReviewScope"') + end = HTML.index('', start) + bulk_scope_html = HTML[start:end] + self.assertIn('value="value"', bulk_scope_html) + self.assertIn('value="field"', bulk_scope_html) + self.assertNotIn('value="entity"', bulk_scope_html) + self.assertNotIn('value="stream"', bulk_scope_html) + def test_dashboard_has_active_feedback_rules_panel(self): self.assertIn('id="feedbackRules"', HTML) self.assertIn("revoke-feedback-rule", HTML) self.assertIn('id="feedbackRuleSummary"', HTML) + def test_dashboard_has_feedback_rule_status_filter(self): + self.assertIn('id="feedbackRuleStatusFilter"', HTML) + self.assertIn('value="false_positive">False positive', HTML) + def test_dashboard_has_scoped_review_form(self): self.assertIn('id="reviewScope"', HTML) self.assertIn('value="entity"', HTML) diff --git a/tests/test_feedback.py b/tests/test_feedback.py index fbeafb8..ab2d8bf 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -72,6 +72,37 @@ class FeedbackTests(unittest.TestCase): missing = store.revoke("does-not-exist") self.assertEqual(missing, {"removed": 0, "remaining": 1, "id": "does-not-exist"}) + def test_add_many_writes_once_and_dedupes_by_id(self): + with tempfile.TemporaryDirectory() as directory: + store = FeedbackStore(str(Path(directory) / "feedback.json")) + result = store.add_many([ + {"status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "bob"}, + {"status": "confirmed", "entity": "carol", "stream_id": "vpn", "field": "username", "value": "dave"}, + # Same (stream_id, entity, field, value) as the first item -> same id -> collapses to one. + {"status": "expected", "entity": "alice", "stream_id": "windows", "field": "username", "value": "bob"}, + ]) + + self.assertEqual(len(result["added"]), 2) + self.assertEqual(result["errors"], []) + self.assertEqual(result["remaining"], 2) + entries = store.entries() + self.assertEqual(len(entries), 2) + collapsed = next(item for item in entries if item["entity"] == "alice") + self.assertEqual(collapsed["status"], "expected") + + def test_add_many_reports_errors_without_aborting_the_batch(self): + with tempfile.TemporaryDirectory() as directory: + store = FeedbackStore(str(Path(directory) / "feedback.json")) + result = store.add_many([ + {"status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "bob"}, + {"status": "expected", "scope": "entity", "entity": "carol"}, # broad scope, no expiry -> invalid + ]) + + self.assertEqual(len(result["added"]), 1) + self.assertEqual(len(result["errors"]), 1) + self.assertEqual(result["errors"][0]["index"], 1) + self.assertEqual(len(store.entries()), 1) + def test_feedback_matches_respects_scope_and_precedence(self): value_rule = {"scope": "value", "entity": "alice", "stream_id": "windows", "field": "username", "value": "10.0.0.1", "status": "confirmed"} field_rule = {"scope": "field", "entity": "alice", "stream_id": "windows", "field": "username", "status": "expected"}