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:
90
README.md
90
README.md
@@ -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
|
||||
field coverage and cardinality. These recommendations use deterministic
|
||||
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
|
||||
validated against fields actually seen in the stream before it can be applied.
|
||||
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:
|
||||
|
||||
```bash
|
||||
ollama pull llama3.3
|
||||
ollama pull qwen2.5:7b-instruct
|
||||
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
|
||||
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
|
||||
|
||||
## Synthetic Windows Test Input
|
||||
@@ -723,7 +736,7 @@ end
|
||||
- `FORTIGATE_VERIFY_TLS`: `true` or `false`, defaults to `true`.
|
||||
- `FGAI_ALLOWLIST`: comma-separated IPs/CIDRs never to block.
|
||||
- `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`.
|
||||
- `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes.
|
||||
- `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.
|
||||
- Blocking requires `--execute`.
|
||||
- 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
46
scripts/backup_state.sh
Executable 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"
|
||||
@@ -28,11 +28,12 @@ DEFAULT_CONFIG: dict[str, object] = {
|
||||
"llm_model": "",
|
||||
"llm_timeout": 180,
|
||||
"profile_advisor_enabled": False,
|
||||
"profile_advisor_model": "qwen3:8b",
|
||||
"profile_advisor_model": "qwen2.5:3b-instruct",
|
||||
"profile_advisor_timeout": 240,
|
||||
"profile_advisor_interval_seconds": 300,
|
||||
"field_confidence_threshold": 60,
|
||||
"field_confidence_retention_days": 90,
|
||||
"feedback_generalization_min_values": 3,
|
||||
"auto_apply_profile_suggestions": False,
|
||||
"threat_intel_enabled": False,
|
||||
"threat_intel_provider": "auto",
|
||||
@@ -89,6 +90,11 @@ class ConfigStore:
|
||||
current[key] = min(100, max(0, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
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"}:
|
||||
current[key] = value
|
||||
elif key == "graylog_streams" and isinstance(value, list):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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]:
|
||||
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
|
||||
|
||||
@@ -41,7 +41,7 @@ def ollama_summary(
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
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"))
|
||||
prompt = {
|
||||
"analysis": analysis or {},
|
||||
@@ -378,7 +378,7 @@ def ollama_profile_advice(
|
||||
ambiguous_fields_by_stream: dict[str, list[str]] | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
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"))
|
||||
scoped = bool(ambiguous_fields_by_stream)
|
||||
compact = [
|
||||
|
||||
@@ -12,7 +12,7 @@ from .baseline import BaselineStore
|
||||
from .config import ConfigStore
|
||||
from .correlation import correlate_source_ips
|
||||
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_mcp import GraylogMcpClient
|
||||
from .graylog_source import GraylogStreamSource
|
||||
@@ -524,6 +524,8 @@ def build_status(
|
||||
deviation["feedback_rule_id"] = match.get("id", "")
|
||||
if match["status"] in SUPPRESSING_STATUSES:
|
||||
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)
|
||||
baseline_events = baseline.ingest(events) if baseline else 0
|
||||
profile_baseline_fields = (
|
||||
@@ -691,6 +693,7 @@ def build_status(
|
||||
"triage_queue": triage_queue,
|
||||
"sequence_findings": sequence_findings,
|
||||
"feedback": feedback,
|
||||
"feedback_generalization_suggestions": feedback_generalization_suggestions,
|
||||
"cross_source_correlations": correlations,
|
||||
"incidents": incidents,
|
||||
"data_quality": assess_data_quality(events, mcp_status),
|
||||
|
||||
68
systemd/README.md
Normal file
68
systemd/README.md
Normal 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.
|
||||
29
systemd/signalscope-dashboard.service
Normal file
29
systemd/signalscope-dashboard.service
Normal 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
|
||||
30
systemd/signalscope-monitor.service
Normal file
30
systemd/signalscope-monitor.service
Normal 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
|
||||
30
systemd/signalscope-syslog.service
Normal file
30
systemd/signalscope-syslog.service
Normal 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
|
||||
@@ -82,6 +82,16 @@ class ConfigTests(unittest.TestCase):
|
||||
public = store.update({"field_confidence_threshold": "not-a-number"})
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = ConfigStore(str(Path(directory) / "config.json"))
|
||||
|
||||
@@ -94,6 +94,35 @@ class DashboardTests(unittest.TestCase):
|
||||
self.assertIn("openDetailIds", 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):
|
||||
self.assertIn("label:'Suggested', render:suggestReview", HTML)
|
||||
self.assertIn("likely false positive", HTML)
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
import unittest
|
||||
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):
|
||||
@@ -120,6 +120,43 @@ class FeedbackTests(unittest.TestCase):
|
||||
self.assertEqual(select_match(candidates), value_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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user