Skip to content

Add persistence scanner, scan history, notifications, and native-panel UI - #5

Open
Percius04 wants to merge 6 commits into
elynch303:mainfrom
Percius04:add-persistence-scanner-history-notifications
Open

Add persistence scanner, scan history, notifications, and native-panel UI#5
Percius04 wants to merge 6 commits into
elynch303:mainfrom
Percius04:add-persistence-scanner-history-notifications

Conversation

@Percius04

Copy link
Copy Markdown

Depends on #3 (reuses IssueDetailView / groupIssues / dismiss infrastructure for a second scanner).

New scanner: persistence

Bash-only, no install step, always active. Scans autostart .desktop entries, user systemd units (real files, not the *.wants/ enablement symlinks) and crontab for the same injection-pattern heuristic AUR-Malware already uses for shell configs (curl|bash, eval "$(curl...)", base64 -d |, etc). Same detail view, grouping, and dismiss/reactivate flow as AUR-Malware, sharing the IssueRow/IssueDetailView components and qs-security-dismiss.sh (now takes an optional 4th "block key" argument so it can recompute either aur_malware or persistence).

Scan history

Each real scan (not ones skipped for gaming) appends a compact entry to ~/.local/share/qs-security/history.json (capped at 30). A new "History" view, opened from a clock icon in the header, lists past results newest-first, so a finding that's since cleared still left a trace instead of vanishing the moment the next scan overwrites the status file.

Gaming-aware timer + "next scan" display

qs-security-scan.sh now checks gamemoded -s itself and exits early while a game is active, logging that (and the real timestamp on a normal run) to ~/.cache/qs-security-last-run.json. The popup header shows "next scan in Xh" normally, or "skipped Xm ago (game active) · next in Xh" when a run was preempted — previously a paused scan just looked like the widget had silently stopped working. This is independent of and complementary to #4's systemd ExecCondition (which stops the unit from ever starting); doing the check in-script as well means there's something to log even if that unit change isn't present.

Desktop notifications

A real notify-send for genuinely NEW active findings (tracked by fingerprint in ~/.config/qs-security/notified.json, so already-seen or already-dismissed findings stay quiet) — the badge changing color is invisible to anyone not already looking at the bar. bumblebee has no per-finding fingerprint, so it only notifies on the clean → findings transition by comparing against the previous history entry.

UI: match Omarchy's native panel design language

Compared this widget against Omarchy's 10 first-party bar panels (Audio, Network, Bluetooth, Power, Dropbox, Tailscale...) and found a few real gaps:

  • Root type BarWidget + PopupCardPanel + KeyboardPanel, same as every native panel — gains keyboard navigation (Escape closes, Tab switches between bar panels) the widget didn't have before.
  • Header rebuilt with the shared PanelHero component (icon + title + uppercase status line), matching Dropbox/Tailscale's pattern, instead of a hand-rolled Text+PanelActionButton row.
  • Colors now read from the active bar (root.foreground/root.urgent, same fallback pattern Dropbox/Tailscale use) instead of generic Color.popups.text/Color.urgent tokens, so the widget actually follows the active theme like the rest of the bar.
  • contentWidth 300 → 380 (every native panel but Clock/Weather uses 380), and section spacing brought in line with Network's actual values — separators are now a sibling of their content column (with the view's bigger inter-section spacing) rather than nested inside it sharing the same tight spacing, matching how Network's own "DNS provider" section is built.

Testing

Verified end-to-end on a real machine: persistence scanner catches a synthetic curl|bash systemd unit and clears once removed; dismiss/reactivate works for both aur_malware and persistence blocks; history accumulates across real scans; notification fires once for a new finding and stays quiet on rescan; gamemode skip logged correctly with gamemoded running. qmllint clean, no brace/id mismatches.

Happy to split this differently (e.g. persistence scanner separate from the UI refresh) if that's easier to review — just let me know, and no worries at all if you'd rather take any of this in a different direction!

Fixes elynch303#1 (partially -- AUR-Malware's dead upstream URL is a separate,
unresolved problem left out of this PR on purpose).

- SecurityWidget.qml: bbInstallProc targeted the bumblebee module root
  (github.com/perplexityai/bumblebee@latest), which go install rejects
  ('found, but does not contain package ...'). The installable binary
  lives at cmd/bumblebee. Verified working end-to-end after the fix.

- qs-security-scan.sh + systemd/qs-security-scan.{service,timer}: the
  README documents this script and timer as what feeds the widget's
  status file, but neither was actually shipped in the repo -- so even
  a correctly installed scanner never produced a result. Adds the
  script (skips AUR-Malware cleanly when it's not installed, runs
  bumblebee scan and summarizes package/finding counts) and the unit
  files, plus wires an opt-in step into install.sh mirroring the
  existing bun-check flow.

Tested on a real machine: bumblebee installs and scans correctly,
qs-security-scan.timer runs on the 6h schedule and writes a status
file the widget reads and displays.
Atomic-Arch/AUR-Malware (what the install button cloned and the README
pointed at) has been deleted -- clone always failed, surfaced as the
generic "maybe git isn't in PATH" error. The fallback destination,
/local/applications/AUR-Malware, also isn't writable by a normal user
(no sudo in this flow), so even a working URL would have failed there.

nightdevil00/AUR-Malware -- already linked in this repo's own GitHub
"About" section as the intended source, just never wired into the
README or the install button -- is a maintained fork shipping the same
check-atomic-arch_new.sh entry point. Repointed the install button,
default path/env-var fallback, and README to it, using
~/.local/share/AUR-Malware instead of the root-owned path.

Also fixes the AUR-Malware branch of qs-security-scan.sh, which never
surfaced findings: the script's exit code is always 0 regardless of
result, and the previous "last output line" heuristic picked up a
disclaimer fragment instead of a verdict. Switched to --json and parse
the trailing JSON object (verdict + pass/warn/fail counts) directly.

Fixes elynch303#1.
The main popup only ever had room for a status word and a one-line
summary ("2 failures, 1 warning out of 18 checks") -- it never said
WHICH checks failed or why, so acting on a finding meant running the
scanner by hand in a terminal.

Detail view (accessed via a "View detail" button, same pattern as the
existing Settings view):
- Findings are grouped by source when one can be identified: a known-
  infected package name (straight from the scanner's own output), an
  npm/bun malicious-indicator package name, the package owning a
  flagged file path (pacman -Qo), or the comment sitting above a
  flagged /etc/hosts entry. Ungrouped findings fall back to "OTHER",
  sorted after the named groups.
- Each finding shows its check name and full detail text, with a
  fail/warn icon and color.

Dismiss/reactivate a reviewed false positive:
- Only for checks that qs-security-scan.sh marks dismissible --
  anything that is direct evidence of an actual compromise (known-
  infected package, malicious npm/bun indicator, eBPF artifacts,
  hidden processes, pacman log hits, ld.so.preload injection,
  persistence artifacts) has no dismiss button at all. This is what
  keeps dismiss safe on a malware-detection tool: you can silence
  heuristic noise (a stale /etc/hosts entry, a shell config edited
  after the incident date), never the finding that actually matters.
- Identified by check name + a hash of the exact detail text, so if
  the detail changes (a new infected package appears, a date moves)
  the old dismissal stops matching and the finding is active again on
  its own.
- The badge color and summary count only active (non-dismissed)
  findings, otherwise dismissing a known false positive would not
  stop the badge going red for the same thing every 6h.
- qs-security-dismiss.sh recomputes the existing status file directly
  instead of rerunning the scanner (~13s for the network fetch + all
  18 checks) -- dismissing is bookkeeping over an already-run scan, it
  doesn't need a fresh one to show up in the badge.
…l UI

Builds on top of elynch303#3 (reuses IssueDetailView / groupIssues / dismiss
infrastructure for a second scanner).

## New scanner: persistence

Bash-only, no install step, always active. Scans autostart .desktop
entries, user systemd units (real files, not the *.wants/ enablement
symlinks) and crontab for the same injection-pattern heuristic
AUR-Malware already uses for shell configs (curl|bash, eval "$(curl...)",
base64 -d |, etc). Same detail view, grouping, and dismiss/reactivate
flow as AUR-Malware, sharing the IssueRow/IssueDetailView components and
qs-security-dismiss.sh (now takes an optional 4th "block key" argument
so it can recompute either aur_malware or persistence).

## Scan history

Each real scan (not ones skipped for gaming) appends a compact entry to
~/.local/share/qs-security/history.json (capped at 30). A new "History"
view, opened from a clock icon in the header, lists past results
newest-first so a finding that's since cleared still left a trace
instead of vanishing the moment the next scan overwrites the status
file.

## Gaming-aware timer + "next scan" display

qs-security-scan.sh now checks `gamemoded -s` itself and exits early
while a game is active, logging that (and the real timestamp on a
normal run) to ~/.cache/qs-security-last-run.json. The popup header
shows "next scan in Xh" normally, or "skipped Xm ago (game active) ·
next in Xh" when a run was preempted -- previously a paused scan just
looked like the widget had silently stopped working. This is
independent of and complementary to elynch303#4's systemd ExecCondition (which
stops the unit from ever starting); doing the check in-script as well
means there's something to log even if that unit change isn't present.

## Desktop notifications

A real notify-send for genuinely NEW active findings (tracked by
fingerprint in ~/.config/qs-security/notified.json, so already-seen or
already-dismissed findings stay quiet) -- the badge changing color is
invisible to anyone not already looking at the bar. bumblebee has no
per-finding fingerprint, so it only notifies on the clean -> findings
transition by comparing against the previous history entry.

## UI: match Omarchy's native panel design language

Compared this widget against Omarchy's 10 first-party bar panels
(Audio, Network, Bluetooth, Power, Dropbox, Tailscale...) and found a
few real gaps:

- Root type BarWidget + PopupCard -> Panel + KeyboardPanel, same as
  every native panel -- gains keyboard navigation (Escape closes, Tab
  switches between bar panels) the widget didn't have before.
- Header rebuilt with the shared PanelHero component (icon + title +
  uppercase status line), matching Dropbox/Tailscale's pattern, instead
  of a hand-rolled Text+PanelActionButton row.
- Colors now read from the active bar (root.foreground/root.urgent,
  same fallback pattern Dropbox/Tailscale use) instead of generic
  Color.popups.text/Color.urgent tokens, so the widget actually follows
  the active theme like the rest of the bar.
- contentWidth 300 -> 380 (every native panel but Clock/Weather uses
  380), and section spacing brought in line with Network's actual
  values -- separators are now a sibling of their content column (with
  the view's bigger inter-section spacing) rather than nested inside it
  sharing the same tight spacing, matching how Network's own "DNS
  provider" section is built.

## Testing

Verified end-to-end on a real machine: persistence scanner catches a
synthetic curl|bash systemd unit and clears once removed; dismiss/
reactivate works for both aur_malware and persistence blocks; history
accumulates across real scans; notification fires once for a new
finding and stays quiet on rescan; gamemode skip logged correctly with
gamemoded running. qmllint clean, no brace/id mismatches.

Happy to split this differently (e.g. persistence scanner separate from
the UI refresh) if that's easier to review -- just let me know.
Percius04 added a commit to Percius04/security-scan that referenced this pull request Aug 1, 2026
…are fixes, detail view + dismiss, persistence scanner + history + notifications + native-panel UI
badgeColor() already switched to root.urgent/root.foreground for the
alert states, but the "all clean" case still fell through to
Color.accent -- the same generic token this PR moved everything else
away from. Since the badge's `active` flag is true whenever any
scanner is installed (not just when there's something to report), this
made the badge sit permanently in the theme's accent color instead of
blending in with the rest of the bar like Bluetooth/Network/Volume do
at rest. Only the clean-state return changes; fail/warn keep standing
out on purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Percius04

Copy link
Copy Markdown
Author

Pushed one more small fix on top: badgeColor()'s clean-scan case still returned Color.accent even after this PR moved the alert states over to root.foreground/root.urgent -- since active is true whenever any scanner is installed, that left the badge permanently accent-tinted instead of blending in with the rest of the bar at rest, like Bluetooth/Network/Volume do. Only the clean-state return changes.

The previous commit (c0ce361) moved the clean-state badge color from
Color.accent to root.foreground, but root.foreground is bar.foreground --
the theme's static token. WidgetButton's own default fallback (used by
every other bar icon when it's not signaling something) is
bar.barForeground instead: a dynamic value Bar.qml recomputes against
whatever is actually behind a transparent/blurred bar
(transparentForegroundProc), so icons stay legible regardless of theme
or wallpaper.

This widget's `active` is true almost anytime a scanner is installed
(not just when there's something to report), so it always goes through
activeColor/badgeColor() and never falls through to that dynamic
default like other icons do. Net effect: the badge could render
lighter or darker than the rest of the bar depending on what the bar's
contrast logic decided at that moment, confirmed with before/after
screenshots against Bluetooth/Network/Volume on the same bar.

Only the clean-state return changes; fail/warn/setup states are
unaffected on purpose -- they're meant to stand out regardless of the
bar's backdrop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Percius04

Copy link
Copy Markdown
Author

Pushed one more commit here: the clean-state badge color still didn't quite match the rest of the bar in some cases. Root cause was that root.foreground resolves to bar.foreground (the theme's static token), but WidgetButton's own fallback — what every other bar icon uses when it's not signaling anything — is bar.barForeground, a dynamic value Bar.qml recomputes against whatever's actually behind a transparent/blurred bar. Since this widget's active is true almost anytime a scanner is installed, it never fell through to that dynamic default the way native icons do.

Confirmed with before/after screenshots against Bluetooth/Network/Volume on the same bar. Only the clean-state return changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant