add rule generalization, sized LLM defaults, ops docs, and UI polish

Feedback rules can now be suggested for merging when a human has already
reviewed the same pattern multiple times (never guesses about new values).
LLM defaults sized for constrained VRAM instead of llama3.3/llama3.1.
Adds systemd units, a state backup script, and reverse-proxy auth guidance
for production deployment. Findings view is collapsible and sorts
suggested-review rows to the top for a quicker overview.
This commit is contained in:
larssand
2026-09-23 13:04:53 +02:00
parent c3e482f9ab
commit bf1109be81
14 changed files with 446 additions and 14 deletions

View File

@@ -186,7 +186,8 @@ stored under `graylog_stream_profiles` in `state/fgai-config.json`.
The Settings page also shows recommended stream profiles built from observed The Settings page also shows recommended stream profiles built from observed
field coverage and cardinality. These recommendations use deterministic field coverage and cardinality. These recommendations use deterministic
discovery first, then can optionally be refined by a local Ollama profile advisor discovery first, then can optionally be refined by a local Ollama profile advisor
model such as `qwen3:8b` or `qwen3:14b`. Enable `Ollama profile advisor` and set model such as `qwen2.5:3b-instruct` (default) or a larger model on more capable
hardware. Enable `Ollama profile advisor` and set
`Profile advisor model` in Settings. Advisor output must be valid JSON and is `Profile advisor model` in Settings. Advisor output must be valid JSON and is
validated against fields actually seen in the stream before it can be applied. validated against fields actually seen in the stream before it can be applied.
Unknown fields, raw message fields, internal `fgai_*` fields, and unknown Unknown fields, raw message fields, internal `fgai_*` fields, and unknown
@@ -671,16 +672,28 @@ fgai suggest-blocks --logs logs/fg_syslog.jsonl --execute --expiry-minutes 60
Optional local LLM summary through Ollama: Optional local LLM summary through Ollama:
```bash ```bash
ollama pull llama3.3 ollama pull qwen2.5:7b-instruct
fgai analyze-logs --logs logs/fg_syslog.jsonl --llm --llm-timeout 300 fgai analyze-logs --logs logs/fg_syslog.jsonl --llm --llm-timeout 300
``` ```
For slower machines or large models: For slower machines, pick a smaller model instead of raising the timeout:
```bash ```bash
OLLAMA_MODEL=llama3.1 OLLAMA_TIMEOUT=300 fgai analyze-logs --logs logs/fg_syslog.jsonl --llm OLLAMA_MODEL=qwen2.5:3b-instruct OLLAMA_TIMEOUT=300 fgai analyze-logs --logs logs/fg_syslog.jsonl --llm
``` ```
**Sizing the model to your GPU**: the defaults (`qwen2.5:7b-instruct` for the
dashboard assessment, `qwen2.5:3b-instruct` for the profile advisor) target a
6-8 GB VRAM card at Q4 quantization with some headroom for context. On a
memory-constrained card (6 GB or less, e.g. a mobile/low-power GPU), drop both
to `qwen2.5:3b-instruct` or smaller — tight VRAM causes Ollama to spill onto
CPU, which shows up as slow or timed-out assessments rather than an obvious
out-of-memory error. On a workstation-class GPU with 16 GB+ VRAM, a larger
model such as `qwen3:14b` for the dashboard assessment will give noticeably
better analysis quality. The profile advisor only ever classifies a handful
of ambiguous fields per call (see the Graylog MCP section below), so it stays
small and fast even on constrained hardware without much quality loss.
## Optional FortiGate Input ## Optional FortiGate Input
## Synthetic Windows Test Input ## Synthetic Windows Test Input
@@ -723,7 +736,7 @@ end
- `FORTIGATE_VERIFY_TLS`: `true` or `false`, defaults to `true`. - `FORTIGATE_VERIFY_TLS`: `true` or `false`, defaults to `true`.
- `FGAI_ALLOWLIST`: comma-separated IPs/CIDRs never to block. - `FGAI_ALLOWLIST`: comma-separated IPs/CIDRs never to block.
- `OLLAMA_HOST`: defaults to `http://127.0.0.1:11434`. - `OLLAMA_HOST`: defaults to `http://127.0.0.1:11434`.
- `OLLAMA_MODEL`: defaults to `llama3.1`. - `OLLAMA_MODEL`: defaults to `qwen2.5:7b-instruct`. Size this to your GPU's VRAM — see the sizing note above.
- `OLLAMA_TIMEOUT`: Ollama request timeout in seconds, defaults to `180`. - `OLLAMA_TIMEOUT`: Ollama request timeout in seconds, defaults to `180`.
- `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes. - `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes.
- `FGAI_LLM_INTERVAL`: seconds between dashboard LLM notes, defaults to `300`. - `FGAI_LLM_INTERVAL`: seconds between dashboard LLM notes, defaults to `300`.
@@ -743,3 +756,70 @@ The agent separates detection from enforcement:
- Source IPs must be globally routable and outside the allowlist. - Source IPs must be globally routable and outside the allowlist.
- Blocking requires `--execute`. - Blocking requires `--execute`.
- The FortiGate API call is limited to the quarantine/banned user monitor endpoint. - The FortiGate API call is limited to the quarantine/banned user monitor endpoint.
## Production Deployment
`start.sh` is convenient for a single machine you're logged into, but has no
supervision (a crashed process just stays dead) and no boot-time start.
### Running under systemd
Unit files for the monitor, dashboard, and optional local syslog listener are
in `systemd/`, with full setup and update instructions in
[`systemd/README.md`](systemd/README.md). They mirror `start.sh`'s exact
process arguments, add `Restart=on-failure`, and run under a dedicated
`signalscope` system user with `ProtectSystem=strict`/`ProtectHome=true`
sandboxing rather than as root.
### Backup and Restore
Everything SignalScope needs to restore — runtime configuration, feedback
rules, behavior baselines, incident state, and caches — lives under `state/`.
Nothing else needs backing up; logs and exports are reproducible/disposable.
```bash
scripts/backup_state.sh # writes to ./backups/signalscope-state-<timestamp>.tar.gz
scripts/backup_state.sh /path/to/backups # or a custom destination
```
SQLite databases (`fgai-baseline.sqlite3`, `signalscope-history.sqlite3`,
`signalscope-status-cache.sqlite3`) are backed up with `sqlite3 .backup`,
which takes a consistent snapshot even while the monitor is actively writing
to them, rather than a plain file copy that can capture a torn/inconsistent
write mid-transaction. JSON files (`fgai-config.json`,
`signalscope-feedback.json`, `signalscope-incidents.json`,
`threat-intel-cache.json`) are already written atomically by SignalScope
(temp file + rename) and are copied directly.
To restore: stop the monitor and dashboard, extract the archive's contents
over `state/`, then restart.
### Exposing the Dashboard Beyond Loopback
The dashboard has **no built-in authentication** — its entire access-control
model is "only accept connections from `127.0.0.1`" (`start.sh`'s and the
systemd unit's default `--host 127.0.0.1`). API routes that change state
(`POST /api/config`, `/api/feedback`, `/api/feedback/bulk`, incident actions,
and more) additionally check that the client's socket address is loopback,
regardless of what `Host` header a request carries.
If you need to reach the dashboard from another machine, do not change
`--host` to `0.0.0.0` directly — put an authenticating reverse proxy in front
of it instead and keep SignalScope itself bound to loopback. For example,
with Caddy:
```
signalscope.internal.example.com {
basicauth {
analyst $2a$14$... # generate with: caddy hash-password
}
reverse_proxy 127.0.0.1:8088
}
```
Or nginx with `auth_basic` / an `auth_request` subrequest to whatever SSO the
rest of your infrastructure already uses. Either way, terminate TLS at the
proxy and keep the token/credential prompts outside SignalScope's own process
— since state-changing routes only trust the *proxy's* loopback connection,
not the original client, the proxy is the only thing standing between a
public request and dashboard write access once `--host` is opened up.

46
scripts/backup_state.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Backs up everything SignalScope needs to restore its configuration,
# feedback rules, baselines, and incident history: the whole state/
# directory. SQLite databases are backed up live via `sqlite3 .backup`
# (a consistent snapshot even while the monitor is writing to them)
# instead of a plain file copy, which can capture a torn/inconsistent
# write. JSON files (config, feedback, incidents, caches) are already
# written atomically by SignalScope (temp file + rename), so a plain
# copy of those is safe.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STATE_DIR="${FGAI_STATE_DIR:-$ROOT_DIR/state}"
BACKUP_ROOT="${1:-$ROOT_DIR/backups}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST_DIR="$BACKUP_ROOT/signalscope-state-$STAMP"
if [ ! -d "$STATE_DIR" ]; then
printf 'State directory not found: %s\n' "$STATE_DIR" >&2
exit 1
fi
mkdir -p "$DEST_DIR"
shopt -s nullglob
for db in "$STATE_DIR"/*.sqlite3; do
name="$(basename "$db")"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$db" ".backup '$DEST_DIR/$name'"
else
printf 'Warning: sqlite3 CLI not found, falling back to plain copy for %s (may be inconsistent if the monitor is running)\n' "$name" >&2
cp "$db" "$DEST_DIR/$name"
fi
done
for json_file in "$STATE_DIR"/*.json; do
cp "$json_file" "$DEST_DIR/$(basename "$json_file")"
done
shopt -u nullglob
ARCHIVE="$BACKUP_ROOT/signalscope-state-$STAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$BACKUP_ROOT" "signalscope-state-$STAMP"
rm -rf "$DEST_DIR"
printf 'Backed up %s to %s\n' "$STATE_DIR" "$ARCHIVE"

View File

@@ -28,11 +28,12 @@ DEFAULT_CONFIG: dict[str, object] = {
"llm_model": "", "llm_model": "",
"llm_timeout": 180, "llm_timeout": 180,
"profile_advisor_enabled": False, "profile_advisor_enabled": False,
"profile_advisor_model": "qwen3:8b", "profile_advisor_model": "qwen2.5:3b-instruct",
"profile_advisor_timeout": 240, "profile_advisor_timeout": 240,
"profile_advisor_interval_seconds": 300, "profile_advisor_interval_seconds": 300,
"field_confidence_threshold": 60, "field_confidence_threshold": 60,
"field_confidence_retention_days": 90, "field_confidence_retention_days": 90,
"feedback_generalization_min_values": 3,
"auto_apply_profile_suggestions": False, "auto_apply_profile_suggestions": False,
"threat_intel_enabled": False, "threat_intel_enabled": False,
"threat_intel_provider": "auto", "threat_intel_provider": "auto",
@@ -89,6 +90,11 @@ class ConfigStore:
current[key] = min(100, max(0, int(value))) current[key] = min(100, max(0, int(value)))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
elif key == "feedback_generalization_min_values":
try:
current[key] = min(20, max(2, int(value)))
except (TypeError, ValueError):
continue
elif key == "threat_intel_provider" and value in {"auto", "abuseipdb", "virustotal"}: elif key == "threat_intel_provider" and value in {"auto", "abuseipdb", "virustotal"}:
current[key] = value current[key] = value
elif key == "graylog_streams" and isinstance(value, list): elif key == "graylog_streams" and isinstance(value, list):

File diff suppressed because one or more lines are too long

View File

@@ -175,3 +175,36 @@ def feedback_matches(entry: dict[str, object], *, entity: str, stream_id: str, f
def select_match(candidates: list[dict[str, object]]) -> dict[str, object]: def select_match(candidates: list[dict[str, object]]) -> dict[str, object]:
return min(candidates, key=lambda item: SCOPE_PRIORITY.get(str(item.get("scope") or _resolve_scope(item)), 1)) return min(candidates, key=lambda item: SCOPE_PRIORITY.get(str(item.get("scope") or _resolve_scope(item)), 1))
def suggest_generalizations(
entries: list[dict[str, object]], *, min_values: int = 3, sample_limit: int = 5
) -> list[dict[str, object]]:
"""Find groups of narrow value-scope rules a human could merge into one field-scope rule.
Only ever looks at entries a human already created (value scope) — never
reasons about unseen values, so it can't pre-judge a new anomaly.
"""
groups: dict[tuple[str, str, str, str], list[dict[str, object]]] = {}
for entry in entries:
scope = str(entry.get("scope") or _resolve_scope(entry))
if scope != "value":
continue
status = str(entry.get("status", ""))
if status not in VALID_STATUSES:
continue
key = (str(entry.get("stream_id", "")), str(entry.get("entity", "")), str(entry.get("field", "")), status)
groups.setdefault(key, []).append(entry)
suggestions = []
for (stream_id, entity, field, status), group in groups.items():
distinct_values = sorted({str(item.get("value", "")) for item in group})
if len(distinct_values) < min_values:
continue
suggestions.append({
"stream_id": stream_id, "entity": entity, "field": field, "status": status,
"value_count": len(distinct_values), "sample_values": distinct_values[:sample_limit],
"entry_ids": [str(item.get("id", "")) for item in group],
})
suggestions.sort(key=lambda item: -item["value_count"])
return suggestions

View File

@@ -41,7 +41,7 @@ def ollama_summary(
timeout: int | None = None, timeout: int | None = None,
) -> str: ) -> str:
host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/") host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
selected_model = model or os.getenv("OLLAMA_MODEL", "llama3.1") selected_model = model or os.getenv("OLLAMA_MODEL", "qwen2.5:7b-instruct")
selected_timeout = timeout or int(os.getenv("OLLAMA_TIMEOUT", "180")) selected_timeout = timeout or int(os.getenv("OLLAMA_TIMEOUT", "180"))
prompt = { prompt = {
"analysis": analysis or {}, "analysis": analysis or {},
@@ -378,7 +378,7 @@ def ollama_profile_advice(
ambiguous_fields_by_stream: dict[str, list[str]] | None = None, ambiguous_fields_by_stream: dict[str, list[str]] | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/") host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
selected_model = model or os.getenv("FGAI_PROFILE_ADVISOR_MODEL", "qwen3:8b") selected_model = model or os.getenv("FGAI_PROFILE_ADVISOR_MODEL", "qwen2.5:3b-instruct")
selected_timeout = timeout or int(os.getenv("FGAI_PROFILE_ADVISOR_TIMEOUT", "240")) selected_timeout = timeout or int(os.getenv("FGAI_PROFILE_ADVISOR_TIMEOUT", "240"))
scoped = bool(ambiguous_fields_by_stream) scoped = bool(ambiguous_fields_by_stream)
compact = [ compact = [

View File

@@ -12,7 +12,7 @@ from .baseline import BaselineStore
from .config import ConfigStore from .config import ConfigStore
from .correlation import correlate_source_ips from .correlation import correlate_source_ips
from .event_context import build_event_context from .event_context import build_event_context
from .feedback import FeedbackStore, SUPPRESSING_STATUSES, feedback_matches, select_match from .feedback import FeedbackStore, SUPPRESSING_STATUSES, feedback_matches, select_match, suggest_generalizations
from .graylog_aggregate import GraylogAggregateSource from .graylog_aggregate import GraylogAggregateSource
from .graylog_mcp import GraylogMcpClient from .graylog_mcp import GraylogMcpClient
from .graylog_source import GraylogStreamSource from .graylog_source import GraylogStreamSource
@@ -524,6 +524,8 @@ def build_status(
deviation["feedback_rule_id"] = match.get("id", "") deviation["feedback_rule_id"] = match.get("id", "")
if match["status"] in SUPPRESSING_STATUSES: if match["status"] in SUPPRESSING_STATUSES:
deviation["score"] = 0 deviation["score"] = 0
generalization_min_values = int(runtime_values.get("feedback_generalization_min_values", 3) or 3)
feedback_generalization_suggestions = suggest_generalizations(feedback, min_values=generalization_min_values)
anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations) anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations)
baseline_events = baseline.ingest(events) if baseline else 0 baseline_events = baseline.ingest(events) if baseline else 0
profile_baseline_fields = ( profile_baseline_fields = (
@@ -691,6 +693,7 @@ def build_status(
"triage_queue": triage_queue, "triage_queue": triage_queue,
"sequence_findings": sequence_findings, "sequence_findings": sequence_findings,
"feedback": feedback, "feedback": feedback,
"feedback_generalization_suggestions": feedback_generalization_suggestions,
"cross_source_correlations": correlations, "cross_source_correlations": correlations,
"incidents": incidents, "incidents": incidents,
"data_quality": assess_data_quality(events, mcp_status), "data_quality": assess_data_quality(events, mcp_status),

68
systemd/README.md Normal file
View File

@@ -0,0 +1,68 @@
# Running SignalScope under systemd
These units replace `start.sh`/`stop.sh` with proper service supervision
(auto-restart, journald logging, boot-time start). They assume the repo lives
at `/opt/signalscope` and runs as a dedicated `signalscope` user — adjust the
paths and `User=`/`Group=` lines if you deploy elsewhere.
## One-time setup
```bash
sudo useradd --system --home-dir /opt/signalscope --shell /usr/sbin/nologin signalscope
sudo mkdir -p /opt/signalscope
sudo cp -r . /opt/signalscope/ # from a checkout of this repo
sudo chown -R signalscope:signalscope /opt/signalscope
# Create the venv and install dependencies as the service user
sudo -u signalscope python3 -m venv /opt/signalscope/.venv
sudo -u signalscope /opt/signalscope/.venv/bin/pip install --upgrade pip
sudo -u signalscope /opt/signalscope/.venv/bin/pip install -e /opt/signalscope
sudo cp systemd/*.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## Enable and start
```bash
sudo systemctl enable --now signalscope-monitor.service
sudo systemctl enable --now signalscope-dashboard.service
# Only if you use the local syslog input instead of Graylog MCP:
sudo systemctl enable --now signalscope-syslog.service
```
## Everyday operations
```bash
systemctl status signalscope-monitor signalscope-dashboard
journalctl -u signalscope-monitor -f
journalctl -u signalscope-dashboard -f
sudo systemctl restart signalscope-monitor signalscope-dashboard
```
Configure Graylog MCP, streams, and profiles the same way as always: open the
dashboard and use Settings — configuration lives in
`/opt/signalscope/state/fgai-config.json` regardless of how the process was
started.
## Updating
```bash
sudo systemctl stop signalscope-dashboard signalscope-monitor
sudo -u signalscope git -C /opt/signalscope pull
sudo -u signalscope /opt/signalscope/.venv/bin/pip install -e /opt/signalscope
sudo systemctl start signalscope-monitor signalscope-dashboard
```
## Notes
- `signalscope-dashboard.service` binds `127.0.0.1` by default, matching the
documented safety default in the main README. See "Exposing the dashboard
beyond loopback" there before changing `--host` to anything else.
- `state/` (config, feedback rules, baselines, incidents) is the only
directory that needs backing up — see `scripts/backup_state.sh` and the
"Backup and Restore" section in the main README.
- The monitor unit's `ExecStart` has a commented example for enabling the
Ollama dashboard assessment (`--llm ...`); the profile advisor is a
dashboard Settings toggle and needs no unit-file change.

View File

@@ -0,0 +1,29 @@
[Unit]
Description=SignalScope dashboard
After=network-online.target signalscope-monitor.service
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
# Binds to loopback only by default. Do not change --host to 0.0.0.0 without
# putting an authenticating reverse proxy in front — see the README section
# "Exposing the dashboard beyond loopback".
ExecStart=/opt/signalscope/.venv/bin/fgai dashboard \
--host 127.0.0.1 \
--port 8088 \
--status-file /opt/signalscope/state/fgai-status.json \
--config-file /opt/signalscope/state/fgai-config.json \
--image-dir /opt/signalscope/images
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/state
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,30 @@
[Unit]
Description=SignalScope continuous monitor
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
# To enable the local Ollama dashboard assessment, add to ExecStart below:
# --llm --llm-interval 300 --llm-timeout 180 --model qwen2.5:7b-instruct
ExecStart=/opt/signalscope/.venv/bin/fgai monitor \
--logs /opt/signalscope/logs/fg_syslog.jsonl \
--output /opt/signalscope/state/fgai-status.json \
--interval 10 \
--baseline-db /opt/signalscope/state/fgai-baseline.sqlite3 \
--history-db /opt/signalscope/state/signalscope-history.sqlite3 \
--status-cache-db /opt/signalscope/state/signalscope-status-cache.sqlite3 \
--config-file /opt/signalscope/state/fgai-config.json
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/state /opt/signalscope/logs /opt/signalscope/run
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,30 @@
[Unit]
Description=SignalScope local syslog listener (optional — only needed for the local_syslog log source, not for Graylog MCP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
ExecStart=/opt/signalscope/.venv/bin/fgai listen-syslog \
--host 0.0.0.0 \
--port 5514 \
--output /opt/signalscope/logs/fg_syslog.jsonl \
--rotate-bytes 26214400 \
--rotate-count 14 \
--quiet
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/logs
# To listen on the standard syslog port 514 instead of 5514, change --port
# above to 514 and uncomment the line below instead of running as root:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target

View File

@@ -82,6 +82,16 @@ class ConfigTests(unittest.TestCase):
public = store.update({"field_confidence_threshold": "not-a-number"}) public = store.update({"field_confidence_threshold": "not-a-number"})
self.assertEqual(public["field_confidence_threshold"], 70) self.assertEqual(public["field_confidence_threshold"], 70)
def test_feedback_generalization_min_values_is_numeric_and_clamped(self):
with tempfile.TemporaryDirectory() as directory:
store = ConfigStore(str(Path(directory) / "config.json"))
public = store.update({"feedback_generalization_min_values": "1"})
self.assertEqual(public["feedback_generalization_min_values"], 2)
public = store.update({"feedback_generalization_min_values": "500"})
self.assertEqual(public["feedback_generalization_min_values"], 20)
public = store.update({"feedback_generalization_min_values": "5"})
self.assertEqual(public["feedback_generalization_min_values"], 5)
def test_empty_stream_update_does_not_clear_existing_streams(self): def test_empty_stream_update_does_not_clear_existing_streams(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
store = ConfigStore(str(Path(directory) / "config.json")) store = ConfigStore(str(Path(directory) / "config.json"))

View File

@@ -94,6 +94,35 @@ class DashboardTests(unittest.TestCase):
self.assertIn("openDetailIds", HTML) self.assertIn("openDetailIds", HTML)
self.assertIn("addEventListener('toggle'", HTML) self.assertIn("addEventListener('toggle'", HTML)
def test_dashboard_field_deviations_are_collapsible_and_open_by_default(self):
start = HTML.index("Field Baseline Deviations")
preceding = HTML[max(0, start - 80):start]
self.assertIn('<details class="collapsible-section" open>', preceding)
self.assertIn('id="findingSummaryBadge"', HTML)
def test_dashboard_sorts_likely_worth_confirming_rows_first(self):
self.assertIn('const suggestPriority', HTML)
self.assertIn('suggestPriority(left) - suggestPriority(right)', HTML)
def test_dashboard_has_generalization_suggestion_panel(self):
self.assertIn('id="feedbackGeneralizationSuggestions"', HTML)
def test_dashboard_generalization_merge_button_confirms_before_widening_scope(self):
self.assertIn('merge-feedback-generalization', HTML)
self.assertIn('Merge into one field-wide rule', HTML)
self.assertIn('will be revoked', HTML)
def test_dashboard_generalization_merge_only_ever_posts_field_scope(self):
start = HTML.index("querySelectorAll('.merge-feedback-generalization')")
end = HTML.index('}));', start)
merge_handler = HTML[start:end]
self.assertIn("scope: 'field'", merge_handler)
self.assertNotIn("scope: 'entity'", merge_handler)
self.assertNotIn("scope: 'stream'", merge_handler)
def test_dashboard_exposes_feedback_generalization_setting(self):
self.assertIn('name="feedback_generalization_min_values"', HTML)
def test_dashboard_suggests_review_from_detector_history(self): def test_dashboard_suggests_review_from_detector_history(self):
self.assertIn("label:'Suggested', render:suggestReview", HTML) self.assertIn("label:'Suggested', render:suggestReview", HTML)
self.assertIn("likely false positive", HTML) self.assertIn("likely false positive", HTML)

View File

@@ -3,7 +3,7 @@ import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from fgai.feedback import FeedbackStore, feedback_matches, select_match from fgai.feedback import FeedbackStore, feedback_matches, select_match, suggest_generalizations
class FeedbackTests(unittest.TestCase): class FeedbackTests(unittest.TestCase):
@@ -120,6 +120,43 @@ class FeedbackTests(unittest.TestCase):
self.assertEqual(select_match(candidates), value_rule) self.assertEqual(select_match(candidates), value_rule)
self.assertEqual(select_match([stream_rule, entity_rule]), entity_rule) self.assertEqual(select_match([stream_rule, entity_rule]), entity_rule)
def test_suggest_generalizations_groups_narrow_value_rules_by_stream_entity_field_status(self):
entries = [
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "bob", "id": "1"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "carol", "id": "2"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "dave", "id": "3"},
{"scope": "value", "status": "confirmed", "entity": "alice", "stream_id": "windows", "field": "username", "value": "eve", "id": "4"},
]
suggestions = suggest_generalizations(entries, min_values=3)
self.assertEqual(len(suggestions), 1)
self.assertEqual(suggestions[0]["value_count"], 3)
self.assertEqual(suggestions[0]["status"], "false_positive")
self.assertEqual(set(suggestions[0]["entry_ids"]), {"1", "2", "3"})
def test_suggest_generalizations_respects_min_values_threshold(self):
entries = [
{"scope": "value", "status": "expected", "entity": "a", "stream_id": "s", "field": "f", "value": "x", "id": "1"},
{"scope": "value", "status": "expected", "entity": "a", "stream_id": "s", "field": "f", "value": "y", "id": "2"},
]
self.assertEqual(suggest_generalizations(entries, min_values=3), [])
def test_suggest_generalizations_ignores_broader_scope_entries(self):
entries = [
{"scope": "field", "status": "false_positive", "entity": "a", "stream_id": "s", "field": "f", "id": "1"},
{"scope": "entity", "status": "expected", "entity": "a", "id": "2"},
]
self.assertEqual(suggest_generalizations(entries, min_values=2), [])
def test_suggest_generalizations_keeps_different_entities_separate(self):
entries = [
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "s", "field": "f", "value": "1", "id": "1"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "s", "field": "f", "value": "2", "id": "2"},
{"scope": "value", "status": "false_positive", "entity": "bob", "stream_id": "s", "field": "f", "value": "3", "id": "3"},
]
self.assertEqual(suggest_generalizations(entries, min_values=2), [
{"stream_id": "s", "entity": "alice", "field": "f", "status": "false_positive", "value_count": 2, "sample_values": ["1", "2"], "entry_ids": ["1", "2"]}
])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()