diff --git a/README.md b/README.md index 24ad9f6..5a20487 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,24 @@ An [Omarchy](https://omarchy.com) bar-widget plugin that shows a live security b ## Features - Live badge: green when clean, amber on warnings, red when compromised -- Popup breakdown per scanner with last-scan timestamp +- Popup breakdown per scanner with last-scan timestamp, and a "next scan in Xh" line (or "skipped Xm ago (game active)" when GameMode preempted a run) - Manual "Scan now" button in popup - Per-project one-shot scan buttons (bun-check, bumblebee) -- **All three scanners are optional** — sections only appear when the tool is installed +- **All scanners are optional** except persistence — sections only appear when the tool is installed +- **AUR-Malware and persistence detail views**: "View detail" opens a breakdown of every failed/warned check, grouped by the package or reason behind it when one can be identified (an infected package name, an `/etc/hosts` comment, the package owning a flagged file). Findings from heuristic checks (not the ones that are direct evidence of an actual compromise) can be dismissed as a reviewed false positive — dismissed findings stay visible, dimmed, with a one-click undo, and stop counting toward the badge color until the underlying finding changes +- **Scan history**: a compact, capped log of past scans (clock icon in the header), so a one-off finding that's since cleared still left a trace instead of just vanishing on the next overwrite +- **Desktop notification** on genuinely new active findings (fingerprint-tracked, so already-seen or already-dismissed ones stay quiet) — the badge color alone is invisible to anyone not already looking at the bar +- Skips the scheduled scan while GameMode reports an active game (rather than competing for CPU/IO or silently going stale), and says so in the popup instead of just looking like it stopped running +- Panel follows the same design language as Omarchy's own bar panels (Audio, Network, Bluetooth...): keyboard navigation (Escape closes, Tab switches between bar panels), themed colors, same section-spacing conventions ## Scanners | Scanner | What it checks | How to install | |---|---|---| -| **AUR-Malware** | Atomic Arch IOC scan — pacman/AUR packages, npm/bun caches, eBPF rootkit artifacts, hidden processes | Clone [AUR-Malware](https://github.com/Atomic-Arch/AUR-Malware) to `/local/applications/AUR-Malware/` | +| **AUR-Malware** | Atomic Arch IOC scan — pacman/AUR packages, npm/bun caches, eBPF rootkit artifacts, hidden processes | Clone [AUR-Malware](https://github.com/nightdevil00/AUR-Malware) to `~/.local/share/AUR-Malware/` (the original `Atomic-Arch/AUR-Malware` this pointed at is gone; this fork ships the same `check-atomic-arch_new.sh`) | | **[bumblebee](https://github.com/perplexityai/bumblebee)** | Endpoint package inventory across npm, pypi, go, rubygems, homebrew, etc. | `GOBIN=$HOME/.local/bin go install github.com/perplexityai/bumblebee@latest` | | **bun-check** | Per-project dev-env one-shot scan (opens a terminal picker) | Bundled — run `install.sh` after adding the plugin | +| **persistence** | Bash-only, no install step. Scans autostart `.desktop` entries, user systemd units and crontab for the same injection pattern (`curl\|bash`, `eval "$(curl...)"`, etc.) AUR-Malware already checks shell configs for | Always active | The bun-check one-shot script (`qs-bun-check-oneshot.sh`) is included in this repo. After `omarchy plugin add`, run the optional install step: @@ -38,6 +44,10 @@ QS_SEC_BUMBLEBEE=bumblebee QS_SEC_BUMBLEBEE_CATALOG=~/.local/share/qs-security/threat-intel QS_BUN_CHECK=/path/to/bun-checkV2.sh QS_SEC_STATUS_FILE=~/.cache/qs-security-status.json +QS_SEC_LAST_RUN_FILE=~/.cache/qs-security-last-run.json +QS_SEC_HISTORY_FILE=~/.local/share/qs-security/history.json +QS_SEC_NOTIFIED_FILE=~/.config/qs-security/notified.json +QS_SEC_DISMISSED_FILE=~/.config/qs-security/dismissed.json ``` ## How it works diff --git a/SecurityWidget.qml b/SecurityWidget.qml index 7ee31c6..eb12d5b 100644 --- a/SecurityWidget.qml +++ b/SecurityWidget.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import Quickshell import Quickshell.Io import qs.Commons @@ -8,28 +9,94 @@ import qs.Ui // // Main view — live scan results from ~/.cache/qs-security-status.json // — per-scanner sections hidden when not installed or toggled off -// — gear icon opens Settings view +// — gear icon opens Settings view, history icon opens History // // Settings view — install / uninstall each scanner with one click // — toggle each scanner on/off (widget-side preference) // — preferences persisted to ~/.config/qs-security/settings.json // // Scanners: -// AUR-Malware git clone to /local/applications/AUR-Malware (or QS_SEC_AUR_MALWARE) +// AUR-Malware git clone to ~/.local/share/AUR-Malware (or QS_SEC_AUR_MALWARE) // bumblebee go install github.com/anchore/bumblebee@latest // bun-check bundled script → ~/.local/bin/qs-bun-check-oneshot.sh -BarWidget { +// persistence bash-only, always available — no install step. Scans +// autostart .desktop entries, user systemd units and +// crontab for the same injection pattern AUR-Malware already +// checks shell configs for. +Panel { id: root moduleName: "local.security-scan" + // Same pattern as the Dropbox/Tailscale panels shipped with Omarchy: + // inherit the active bar's colors instead of a fixed theme token, so the + // widget follows whichever theme/bar is currently active. + readonly property color foreground: bar ? bar.foreground : "#cacccc" + readonly property color urgent: bar ? bar.urgent : "#a55555" + // bar.foreground is the theme's static token (fine for popup content, + // which sits on its own opaque card). The bar icon itself needs + // bar.barForeground instead -- that's the one WidgetButton actually falls + // back to, and it's recomputed live against whatever is behind a + // transparent/blurred bar (see Bar.qml's transparentForegroundProc) so the + // icon stays legible. Since this widget's `active` is true almost + // whenever any scanner is installed, it never fell through to that + // dynamic default -- it always went through activeColor/badgeColor(), + // which used the static token and could end up lighter or darker than + // every other bar icon depending on what's behind the bar. + readonly property color barForeground: bar ? bar.barForeground : root.foreground + // ── scan state ────────────────────────────────────────────────────────── property var securityStatus: ({}) property bool securityScanning: false readonly property var aur: securityStatus.aur_malware || ({}) readonly property var bb: securityStatus.bumblebee || ({}) + readonly property var persistence: securityStatus.persistence || ({}) readonly property bool everScanned: !!securityStatus.checked + // ── last-run / next-run state (gaming-aware timer) ───────────────────── + property var lastRun: ({}) + readonly property bool lastRunSkipped: lastRun.skipped === true + // Matches OnUnitActiveSec= in qs-security-scan.timer -- kept as a plain + // property (not read from the unit file) so this stays simple; if that + // interval ever changes, update both. + readonly property int scanIntervalHours: 6 + readonly property string nextScanText: { + var at = lastRun.at + if (!at) return "" + var t = new Date(at).getTime() + if (isNaN(t)) return "" + var nextT = t + root.scanIntervalHours * 3600000 + var mins = Math.round((nextT - Date.now()) / 60000) + var etaText = mins <= 0 ? "shortly" : (mins < 60 ? "in " + mins + "m" : "in " + Math.round(mins / 60) + "h") + return root.lastRunSkipped + ? "skipped " + root.relTime(at) + " (game active) · next " + etaText + : "next " + etaText + } + + // ── history ────────────────────────────────────────────────────────── + property var scanHistory: [] + // Most recent first -- the file is appended to in chronological order. + readonly property var scanHistoryRecent: { + var out = scanHistory.slice() + out.reverse() + return out + } + function historyEntryStatus(entry) { + var sev = { fail: 2, error: 2, warn: 1, findings: 1 } + var worst = 0 + ;["aur_malware", "bumblebee", "persistence"].forEach(function(k) { + if (entry[k]) worst = Math.max(worst, sev[entry[k].status] || 0) + }) + return worst === 2 ? "fail" : (worst === 1 ? "warn" : "clean") + } + function historyEntrySummary(entry) { + var parts = [] + ;["aur_malware", "bumblebee", "persistence"].forEach(function(k) { + if (entry[k] && entry[k].summary) parts.push(entry[k].summary) + }) + return parts.join(" · ") + } + // ── user preferences (persisted) ─────────────────────────────────────── property var userSettings: ({}) readonly property bool aurUserEnabled: userSettings.aur_enabled !== false @@ -45,10 +112,53 @@ BarWidget { readonly property bool aurAvailable: aurInstalled && aurUserEnabled readonly property bool bbAvailable: bbInstalled && bbUserEnabled readonly property bool bunCheckEnabled: bunInstalled && bunUserEnabled - readonly property bool anyScanner: aurAvailable || bbAvailable + // Persistence is pure bash, nothing to install -- always active. + readonly property bool persistenceAvailable: true + readonly property bool anyScanner: aurAvailable || bbAvailable || root.persistenceAvailable // ── settings panel UI state ───────────────────────────────────────────── property bool showSettings: false + property bool showAurDetail: false + property bool showPersistenceDetail: false + property bool showHistory: false + + function closeSubviews() { + root.showSettings = false + root.showAurDetail = false + root.showPersistenceDetail = false + root.showHistory = false + } + + // Grouped by source (package/reason identified by qs-security-scan.sh, + // e.g. a comment in /etc/hosts, the package that owns a file, or the + // launcher/unit/crontab path for persistence) -- without this, every + // finding was a loose block of text with no indication of WHAT it + // belongs to. + function groupIssues(issues) { + var map = {}, order = [] + for (var i = 0; i < issues.length; i++) { + var it = issues[i] + var key = (it.source && it.source !== "") ? it.source : "OTHER" + if (!map[key]) { map[key] = []; order.push(key) } + map[key].push(it) + } + // "OTHER" (no identified source) always last -- specific things (a + // named package/app) first, generic afterward. + order.sort(function(a, b) { + if (a === "OTHER") return 1 + if (b === "OTHER") return -1 + return 0 + }) + var out = [] + for (var j = 0; j < order.length; j++) out.push({ source: order[j], issues: map[order[j]] }) + return out + } + + readonly property var aurIssues: aur.issues || [] + readonly property var aurGroups: root.groupIssues(root.aurIssues) + + readonly property var persistenceIssues: persistence.issues || [] + readonly property var persistenceGroups: root.groupIssues(root.persistenceIssues) property bool aurOpBusy: false property string aurOpMsg: "" @@ -67,17 +177,19 @@ BarWidget { readonly property string aurEffectivePath: { var ov = Quickshell.env("QS_SEC_AUR_MALWARE") - return ov ? ov : "/local/applications/AUR-Malware/check-atomic-arch_new.sh" + return ov ? ov : root.home + "/.local/share/AUR-Malware/check-atomic-arch_new.sh" } readonly property string aurMalwareDir: { var p = root.aurEffectivePath var i = p.lastIndexOf("/") - return i > 0 ? p.substring(0, i) : "/local/applications/AUR-Malware" + return i > 0 ? p.substring(0, i) : root.home + "/.local/share/AUR-Malware" } readonly property string bunDst: home + "/.local/bin/qs-bun-check-oneshot.sh" readonly property string bunSrc: home + "/.config/omarchy/plugins/io.github.elynch303.security-scan/qs-bun-check-oneshot.sh" readonly property string settingsPath: home + "/.config/qs-security/settings.json" + readonly property string lastRunPath: home + "/.cache/qs-security-last-run.json" + readonly property string historyPath: home + "/.local/share/qs-security/history.json" // ── file watchers ─────────────────────────────────────────────────────── FileView { @@ -92,6 +204,28 @@ BarWidget { onLoadFailed: {} } + FileView { + id: lastRunFile + path: root.lastRunPath + watchChanges: true + onFileChanged: lastRunFile.reload() + onLoaded: { + try { root.lastRun = JSON.parse(lastRunFile.text()) } catch (e) {} + } + onLoadFailed: { root.lastRun = ({}) } + } + + FileView { + id: historyFile + path: root.historyPath + watchChanges: true + onFileChanged: historyFile.reload() + onLoaded: { + try { root.scanHistory = JSON.parse(historyFile.text()) } catch (e) { root.scanHistory = [] } + } + onLoadFailed: { root.scanHistory = [] } + } + FileView { id: settingsFile path: root.settingsPath @@ -126,6 +260,8 @@ BarWidget { Component.onCompleted: { statusFile.reload() + lastRunFile.reload() + historyFile.reload() settingsFile.reload() bunProbe.reload() aurProbe.reload() @@ -171,7 +307,7 @@ BarWidget { id: rescanProc command: [home + "/.local/bin/qs-security-scan.sh"] running: false - onExited: statusFile.reload() + onExited: { statusFile.reload(); lastRunFile.reload(); historyFile.reload() } } function rescan() { @@ -186,6 +322,25 @@ BarWidget { onTriggered: { root.securityScanning = false; rescanProc.running = false } } + // ── dismiss / reactivate a heuristic finding ──────────────────────────── + // Only for issue.dismissible === true -- qs-security-scan.sh never marks + // as dismissible the checks that are direct evidence of a compromise + // (known-infected package, etc.), so this doesn't need checking here too: + // the row's button doesn't even exist for those. + Process { + id: issueDismissProc + // qs-security-dismiss.sh recomputes the status file directly (no + // rescan, ~13s), so a reload is all that's needed -- no root.rescan(), + // which would relaunch the full scan. + onExited: statusFile.reload() + } + function setIssueDismissed(name, detailText, dismissed, blockKey) { + issueDismissProc.command = [home + "/.local/bin/qs-security-dismiss.sh", + dismissed ? "dismiss" : "reactivate", name, detailText, blockKey] + issueDismissProc.running = false + issueDismissProc.running = true + } + // ── install / uninstall processes ─────────────────────────────────────── Process { id: aurInstallProc @@ -249,7 +404,7 @@ BarWidget { root.aurOpBusy = true; root.aurOpMsg = ""; root.aurOpError = false aurInstallProc.command = [ "bash", "-c", - "mkdir -p \"$(dirname \"$0\")\" && git clone https://github.com/Atomic-Arch/AUR-Malware.git \"$0\"", + "mkdir -p \"$(dirname \"$0\")\" && git clone https://github.com/nightdevil00/AUR-Malware.git \"$0\"", root.aurMalwareDir ] aurInstallProc.running = false; aurInstallProc.running = true @@ -264,7 +419,7 @@ BarWidget { root.bbOpBusy = true; root.bbOpMsg = "Installing via go…"; root.bbOpError = false bbInstallProc.command = [ "/usr/bin/mise", "exec", "--", "sh", "-c", - "GOBIN=$HOME/.local/bin go install github.com/perplexityai/bumblebee@latest" + "GOBIN=$HOME/.local/bin go install github.com/perplexityai/bumblebee/cmd/bumblebee@latest" ] bbInstallProc.running = false; bbInstallProc.running = true } @@ -296,19 +451,20 @@ BarWidget { var worst = 0 if (root.aurAvailable) worst = Math.max(worst, sev[root.aur.status] || 0) if (root.bbAvailable) worst = Math.max(worst, sev[root.bb.status] || 0) + if (root.persistenceAvailable && root.everScanned) worst = Math.max(worst, sev[root.persistence.status] || 0) return worst === 2 ? "fail" : (worst === 1 ? "warn" : "clean") } function badgeColor() { if (!root.anyScanner) return Color.accent if (!root.everScanned) return Color.accent - if (root.overallStatus === "fail") return Color.urgent + if (root.overallStatus === "fail") return root.urgent if (root.overallStatus === "warn") return "#e8a33d" - return Color.accent + return root.barForeground } function statusColor(s) { - if (s === "fail" || s === "error") return Color.urgent + if (s === "fail" || s === "error") return root.urgent if (s === "warn" || s === "findings") return "#e8a33d" return Color.accent } @@ -339,6 +495,7 @@ BarWidget { var parts = [] if (root.aurAvailable && root.aur.status) parts.push("AUR-Malware: " + (root.aur.summary || root.aur.status)) if (root.bbAvailable && root.bb.status) parts.push("bumblebee: " + (root.bb.summary || root.bb.status)) + if (root.persistenceAvailable && root.persistence.status) parts.push("persistence: " + (root.persistence.summary || root.persistence.status)) return parts.join("\n") + "\nClick to view details" } @@ -358,8 +515,8 @@ BarWidget { activeColor: root.badgeColor() active: root.aurInstalled || root.bbInstalled || root.bunInstalled onPressed: { - detail.open = !detail.open - if (!detail.open) root.showSettings = false + root.toggle() + if (!root.opened) root.closeSubviews() } } @@ -374,15 +531,26 @@ BarWidget { } // ── popup ──────────────────────────────────────────────────────────────── - PopupCard { + // Panel + KeyboardPanel (rather than BarWidget + PopupCard) to match the + // rest of Omarchy's first-party bar panels (Audio, Network, Bluetooth...), + // which all get keyboard navigation (Escape closes, Tab switches panels) + // this way. See qs.Ui's Panel.qml/KeyboardPanel.qml for what each provides. + KeyboardPanel { id: detail anchorItem: button bar: root.bar owner: root - contentWidth: Style.space(300) - contentHeight: (root.showSettings ? settingsCol.implicitHeight : mainCol.implicitHeight) + padding * 2 - - onOpenChanged: if (!open) root.showSettings = false + open: root.opened + focusTarget: keyCatcher + contentWidth: detail.fittedContentWidth(Style.space(380)) + contentHeight: detail.fittedContentHeight( + (root.showSettings ? settingsCol.implicitHeight + : root.showHistory ? historyCol.implicitHeight + : root.showPersistenceDetail ? persistenceDetailCol.implicitHeight + : root.showAurDetail ? aurDetailCol.implicitHeight + : mainCol.implicitHeight), Style.space(560)) + + onOpenChanged: if (!open) root.closeSubviews() // ── reusable action button component ────────────────────────────────── component ActionBtn: Rectangle { @@ -396,18 +564,18 @@ BarWidget { height: Style.spacing.controlHeight radius: Style.cornerRadius visible: !busy - color: abMa.containsMouse ? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.08) : "transparent" + color: abMa.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" border.width: 1 border.color: destructive - ? Qt.rgba(Color.urgent.r, Color.urgent.g, Color.urgent.b, abMa.containsMouse ? 0.65 : 0.3) - : (abMa.containsMouse ? Color.accent : Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.15)) + ? Qt.rgba(root.urgent.r, root.urgent.g, root.urgent.b, abMa.containsMouse ? 0.65 : 0.3) + : (abMa.containsMouse ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15)) Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent text: ab.label color: ab.destructive - ? (abMa.containsMouse ? Color.urgent : Qt.rgba(Color.urgent.r, Color.urgent.g, Color.urgent.b, 0.7)) - : (abMa.containsMouse ? Color.accent : Color.popups.text) + ? (abMa.containsMouse ? root.urgent : Qt.rgba(root.urgent.r, root.urgent.g, root.urgent.b, 0.7)) + : (abMa.containsMouse ? Color.accent : root.foreground) font.family: Style.font.family font.pixelSize: Style.font.caption } @@ -436,7 +604,7 @@ BarWidget { width: parent.width spacing: Style.spacing.sm - PanelSeparator { foreground: Color.popups.text } + PanelSeparator { foreground: root.foreground } Item { width: parent.width @@ -446,7 +614,7 @@ BarWidget { id: srHeader anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - foreground: Color.popups.text + foreground: root.foreground text: sr.title } @@ -456,7 +624,7 @@ BarWidget { visible: sr.installed checked: sr.userEnabled busy: sr.opBusy - foreground: Color.popups.text + foreground: root.foreground onToggled: sr.toggleClicked() } @@ -465,7 +633,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter visible: !sr.installed text: "Not installed" - color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.42) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.42) font.family: Style.font.family font.pixelSize: Style.font.caption } @@ -483,109 +651,312 @@ BarWidget { visible: sr.opBusy || sr.opMsg !== "" text: sr.opBusy ? "Working…" : sr.opMsg color: sr.opError - ? Color.urgent - : Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.58) + ? root.urgent + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.58) font.family: Style.font.family font.pixelSize: Style.font.caption wrapMode: Text.Wrap } } - // ═══════════════════════════════════════════════════════════════════════ - // Main view - // ═══════════════════════════════════════════════════════════════════════ - Column { - id: mainCol - visible: !root.showSettings - width: detail.contentWidth - detail.padding * 2 - spacing: Style.spacing.lg - - // Header: title + timestamp + gear + // ── reusable issue-detail row (used for both AUR-Malware and + // persistence -- same structure, different data source) ─────────────── + component IssueRow: Column { + required property var modelData + required property string blockKey + width: parent.width + spacing: Style.spacing.xxs + opacity: modelData.dismissed ? 0.5 : 1.0 + Behavior on opacity { NumberAnimation { duration: 120 } } + PanelSeparator { foreground: root.foreground } Item { width: parent.width - height: Style.spacing.xxl + height: Math.max(issueNameText.implicitHeight, issueDismissBtn.implicitHeight) Text { + id: issueNameText anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Security" - color: Color.popups.text + anchors.right: issueDismissBtn.visible ? issueDismissBtn.left : parent.right + anchors.rightMargin: issueDismissBtn.visible ? Style.spacing.xs : 0 + text: (modelData.status === "FAIL" ? "✕ " : "⚠ ") + modelData.name + + (modelData.dismissed ? " (dismissed)" : "") + color: modelData.status === "FAIL" ? root.urgent : "#e8a33d" font.family: Style.font.family - font.pixelSize: Style.font.subtitle + font.pixelSize: Style.font.bodySmall font.bold: true + wrapMode: Text.Wrap } - Text { - anchors.right: gearBtn.left - anchors.rightMargin: Style.spacing.sm + PanelActionButton { + id: issueDismissBtn + anchors.right: parent.right + anchors.top: parent.top + visible: modelData.dismissible === true + iconText: modelData.dismissed ? "󰑙" : "󰄬" + foreground: root.foreground + tooltipText: modelData.dismissed ? "Reactivate" : "Dismiss (reviewed false positive)" + onClicked: root.setIssueDismissed(modelData.name, modelData.detail, !modelData.dismissed, blockKey) + } + } + Text { + width: parent.width + text: modelData.detail + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.75) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap + } + } + + component IssueDetailView: Column { + id: idv + property string headerTitle: "" + property var groups: [] + property string blockKey: "" + signal backClicked() + + width: panelFlick.width + spacing: Style.spacing.md + + Item { + width: parent.width + height: Style.spacing.xxl + PanelActionButton { + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: root.everScanned ? root.relTime(root.securityStatus.checked) : "never scanned" - color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.52) + iconText: "󰁍" + foreground: root.foreground + tooltipText: "Back" + onClicked: idv.backClicked() + } + Text { + anchors.centerIn: parent + text: idv.headerTitle + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.7) font.family: Style.font.family font.pixelSize: Style.font.caption + font.bold: true } - PanelActionButton { - id: gearBtn + } + + Repeater { + model: idv.groups + delegate: Column { + required property var modelData + width: idv.width + spacing: Style.spacing.sm + PanelSectionHeader { foreground: root.foreground; text: modelData.source.toUpperCase() } + Repeater { model: modelData.issues; delegate: IssueRow { blockKey: idv.blockKey } } + } + } + } + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: (root.showSettings ? settingsCol.implicitHeight + : root.showHistory ? historyCol.implicitHeight + : root.showPersistenceDetail ? persistenceDetailCol.implicitHeight + : root.showAurDetail ? aurDetailCol.implicitHeight + : mainCol.implicitHeight) + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + // ═══════════════════════════════════════════════════════════════════════ + // Main view + // ═══════════════════════════════════════════════════════════════════════ + Column { + id: mainCol + visible: !root.showSettings && !root.showAurDetail && !root.showPersistenceDetail && !root.showHistory + width: panelFlick.width + spacing: Style.space(12) + + // Header: hero (icon + title + status) + next-scan line, grouped in + // their own column with tight spacing -- the big gap (Style.space(12) + // above) is what separates this block from the rest of the sections, + // not what separates the hero from its own status line. The icon + // sits in a fixed-width slot (heroIconSlot) so the "next scan" line + // below can line up precisely under the hero's title. + Column { + id: heroBlock + width: parent.width + spacing: Style.space(4) + readonly property real heroIconSlot: Style.space(28) + + Item { + width: parent.width + implicitHeight: hero.implicitHeight + + PanelHero { + id: hero + width: parent.width + title: "Security" + meta: root.everScanned + ? (root.overallStatus === "fail" ? "Compromised" + : root.overallStatus === "warn" ? "With warnings" + : "All clean") + " · " + root.relTime(root.securityStatus.checked) + : "no scan yet" + foreground: root.foreground + fontFamily: Style.font.family + + iconComponent: Component { + Item { + width: heroBlock.heroIconSlot + height: Style.font.display + Text { + anchors.centerIn: parent + text: root.securityScanning ? "…" : "󰒙" + color: root.badgeColor() + font.family: Style.font.family + font.pixelSize: Style.font.display + } + } + } + + trailingControl: Component { + Row { + spacing: Style.spacing.xs + PanelActionButton { + iconText: "󰋚" + foreground: root.foreground + tooltipText: "Scan history" + onClicked: root.showHistory = true + } + PanelActionButton { + iconText: "󰒓" + foreground: root.foreground + tooltipText: "Scanner setup" + onClicked: root.showSettings = true + } + } + } + } + } + + // Next-scan / skipped-due-to-gaming line -- only when there is + // something to say. Same indent as the hero's title: heroIconSlot + // plus the 14px margin PanelHero applies internally between icon + // and labels. + Text { + visible: root.nextScanText !== "" + anchors.left: parent.left + anchors.leftMargin: heroBlock.heroIconSlot + Style.space(14) anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - iconText: "󰒓" - foreground: Color.popups.text - tooltipText: "Scanner setup" - onClicked: root.showSettings = true + text: root.nextScanText + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.42) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap } } - // AUR-Malware results + // AUR-Malware results -- the separator is a sibling of the content + // column (not nested inside it), matching how Omarchy's own panels + // (e.g. Network's "DNS PROVIDER" section) separate a section from the + // previous one with the view's larger spacing, while keeping a + // tighter spacing for title→summary→button within the section. + PanelSeparator { visible: root.aurAvailable; foreground: root.foreground } Column { visible: root.aurAvailable width: parent.width - spacing: Style.spacing.xs - PanelSeparator { foreground: Color.popups.text } + spacing: Style.space(10) Item { width: parent.width height: Math.max(aurH.implicitHeight, aurSt.implicitHeight) - PanelSectionHeader { id: aurH; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; foreground: Color.popups.text; text: "AUR-MALWARE" } + PanelSectionHeader { id: aurH; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; foreground: root.foreground; text: "AUR-MALWARE" } Text { id: aurSt; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: root.everScanned ? root.statusLabel(root.aur.status) : "—"; color: root.statusColor(root.aur.status); font.family: Style.font.family; font.pixelSize: Style.font.caption; font.bold: true } } - Text { width: parent.width; text: root.everScanned ? (root.aur.summary || "no data") : "no scan yet"; color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.8); font.family: Style.font.family; font.pixelSize: Style.font.bodySmall; wrapMode: Text.Wrap } + Text { width: parent.width; text: root.everScanned ? (root.aur.summary || "no data") : "no scan yet"; color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.8); font.family: Style.font.family; font.pixelSize: Style.font.bodySmall; wrapMode: Text.Wrap } + Rectangle { + visible: root.aurIssues.length > 0 + width: parent.width + height: Style.spacing.controlHeight + radius: Style.cornerRadius + color: aurDetailMa.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" + border.color: aurDetailMa.containsMouse ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) + border.width: 1 + Behavior on color { ColorAnimation { duration: 120 } } + Text { anchors.centerIn: parent; text: "View detail"; color: aurDetailMa.containsMouse ? Color.accent : root.foreground; font.family: Style.font.family; font.pixelSize: Style.font.bodySmall } + MouseArea { id: aurDetailMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: root.showAurDetail = true } + } + } + + // Persistence results -- always available, no install step. + PanelSeparator { visible: root.persistenceAvailable; foreground: root.foreground } + Column { + visible: root.persistenceAvailable + width: parent.width + spacing: Style.space(10) + Item { + width: parent.width + height: Math.max(persH.implicitHeight, persSt.implicitHeight) + PanelSectionHeader { id: persH; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; foreground: root.foreground; text: "PERSISTENCE" } + Text { id: persSt; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: root.everScanned ? root.statusLabel(root.persistence.status) : "—"; color: root.statusColor(root.persistence.status); font.family: Style.font.family; font.pixelSize: Style.font.caption; font.bold: true } + } + Text { width: parent.width; text: root.everScanned ? (root.persistence.summary || "no data") : "no scan yet"; color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.8); font.family: Style.font.family; font.pixelSize: Style.font.bodySmall; wrapMode: Text.Wrap } + Rectangle { + visible: root.persistenceIssues.length > 0 + width: parent.width + height: Style.spacing.controlHeight + radius: Style.cornerRadius + color: persDetailMa.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" + border.color: persDetailMa.containsMouse ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) + border.width: 1 + Behavior on color { ColorAnimation { duration: 120 } } + Text { anchors.centerIn: parent; text: "View detail"; color: persDetailMa.containsMouse ? Color.accent : root.foreground; font.family: Style.font.family; font.pixelSize: Style.font.bodySmall } + MouseArea { id: persDetailMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: root.showPersistenceDetail = true } + } } // Bumblebee results + PanelSeparator { visible: root.bbAvailable; foreground: root.foreground } Column { visible: root.bbAvailable width: parent.width - spacing: Style.spacing.xs - PanelSeparator { foreground: Color.popups.text } + spacing: Style.space(10) Item { width: parent.width height: Math.max(bbH.implicitHeight, bbSt.implicitHeight) - PanelSectionHeader { id: bbH; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; foreground: Color.popups.text; text: "BUMBLEBEE" } + PanelSectionHeader { id: bbH; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; foreground: root.foreground; text: "BUMBLEBEE" } Text { id: bbSt; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: root.everScanned ? root.statusLabel(root.bb.status) : "—"; color: root.statusColor(root.bb.status); font.family: Style.font.family; font.pixelSize: Style.font.caption; font.bold: true } } - Text { width: parent.width; text: root.everScanned ? (root.bb.summary || "no data") : "no scan yet"; color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.8); font.family: Style.font.family; font.pixelSize: Style.font.bodySmall; wrapMode: Text.Wrap } + Text { width: parent.width; text: root.everScanned ? (root.bb.summary || "no data") : "no scan yet"; color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.8); font.family: Style.font.family; font.pixelSize: Style.font.bodySmall; wrapMode: Text.Wrap } } - // No active scanners + // No active scanners (bumblebee/AUR disabled -- persistence always + // counts as active, so this block is almost never seen) + PanelSeparator { visible: !root.aurAvailable && !root.bbAvailable && !root.persistenceAvailable; foreground: root.foreground } Column { - visible: !root.aurAvailable && !root.bbAvailable + visible: !root.aurAvailable && !root.bbAvailable && !root.persistenceAvailable width: parent.width - spacing: Style.spacing.xs - PanelSeparator { foreground: Color.popups.text } + spacing: Style.space(10) Text { width: parent.width text: "No system scanners active.\nClick ⚙ to install or enable scanners." - color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.52) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.52) font.family: Style.font.family font.pixelSize: Style.font.bodySmall wrapMode: Text.Wrap } } - // One-shot project scan + // Scanning a single project folder -- a separate action from the + // general status above, so it goes before "Scan now", not right + // next to it. + PanelSeparator { visible: root.bunCheckEnabled || root.bbAvailable; foreground: root.foreground } Column { visible: root.bunCheckEnabled || root.bbAvailable width: parent.width - spacing: Style.spacing.md - PanelSeparator { foreground: Color.popups.text } - PanelSectionHeader { foreground: Color.popups.text; text: "SCAN A PROJECT FOLDER" } + spacing: Style.space(10) + PanelSectionHeader { foreground: root.foreground; text: "SCAN A PROJECT FOLDER" } Row { width: parent.width spacing: root.bunCheckEnabled && root.bbAvailable ? Style.spacing.lg : 0 @@ -594,56 +965,169 @@ BarWidget { width: root.bunCheckEnabled && root.bbAvailable ? (parent.width - Style.spacing.lg) / 2 : parent.width height: Style.spacing.controlHeight radius: Style.cornerRadius - color: bunMa.containsMouse ? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.08) : "transparent" - border.color: bunMa.containsMouse ? Color.accent : Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.15) + color: bunMa.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" + border.color: bunMa.containsMouse ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) border.width: 1 Behavior on color { ColorAnimation { duration: 120 } } - Text { anchors.centerIn: parent; text: "bun-check…"; color: bunMa.containsMouse ? Color.accent : Color.popups.text; font.family: Style.font.family; font.pixelSize: Style.font.caption } - MouseArea { id: bunMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: { detail.open = false; bunCheckRunner.running = false; bunCheckRunner.running = true } } + Text { anchors.centerIn: parent; text: "bun-check…"; color: bunMa.containsMouse ? Color.accent : root.foreground; font.family: Style.font.family; font.pixelSize: Style.font.caption } + MouseArea { id: bunMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: { root.close(); bunCheckRunner.running = false; bunCheckRunner.running = true } } } Rectangle { visible: root.bbAvailable width: root.bunCheckEnabled && root.bbAvailable ? (parent.width - Style.spacing.lg) / 2 : parent.width height: Style.spacing.controlHeight radius: Style.cornerRadius - color: bbMa.containsMouse ? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.08) : "transparent" - border.color: bbMa.containsMouse ? Color.accent : Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.15) + color: bbMa.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" + border.color: bbMa.containsMouse ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) border.width: 1 Behavior on color { ColorAnimation { duration: 120 } } - Text { anchors.centerIn: parent; text: "bumblebee…"; color: bbMa.containsMouse ? Color.accent : Color.popups.text; font.family: Style.font.family; font.pixelSize: Style.font.caption } - MouseArea { id: bbMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: { detail.open = false; bumblebeeRunner.running = false; bumblebeeRunner.running = true } } + Text { anchors.centerIn: parent; text: "bumblebee…"; color: bbMa.containsMouse ? Color.accent : root.foreground; font.family: Style.font.family; font.pixelSize: Style.font.caption } + MouseArea { id: bbMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: { root.close(); bumblebeeRunner.running = false; bumblebeeRunner.running = true } } } } } - // Scan now + // "Scan now" -- the final action, separate from everything above it. + // Same Style.spacing.lg gap from mainCol that separates the rest of + // the sections from each other, no special treatment. + PanelSeparator { visible: root.anyScanner; foreground: root.foreground } Column { visible: root.anyScanner width: parent.width - PanelSeparator { foreground: Color.popups.text } Rectangle { width: parent.width height: Style.spacing.controlHeight radius: Style.cornerRadius opacity: root.securityScanning ? 0.5 : 1.0 - color: (scanMa.containsMouse && !root.securityScanning) ? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.08) : "transparent" - border.color: (scanMa.containsMouse && !root.securityScanning) ? Color.accent : Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.15) + color: (scanMa.containsMouse && !root.securityScanning) ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" + border.color: (scanMa.containsMouse && !root.securityScanning) ? Color.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) border.width: 1 Behavior on color { ColorAnimation { duration: 120 } } - Text { anchors.centerIn: parent; text: root.securityScanning ? "Scanning…" : "Scan now"; color: (scanMa.containsMouse && !root.securityScanning) ? Color.accent : Color.popups.text; font.family: Style.font.family; font.pixelSize: Style.font.bodySmall } + Text { anchors.centerIn: parent; text: root.securityScanning ? "Scanning…" : "Scan now"; color: (scanMa.containsMouse && !root.securityScanning) ? Color.accent : root.foreground; font.family: Style.font.family; font.pixelSize: Style.font.bodySmall } MouseArea { id: scanMa; anchors.fill: parent; hoverEnabled: true; enabled: !root.securityScanning; cursorShape: root.securityScanning ? Qt.ArrowCursor : Qt.PointingHandCursor; onClicked: root.rescan() } } } } + // ═══════════════════════════════════════════════════════════════════════ + // AUR-Malware detail view — what failed/warned, and why. The main view + // only ever had room for a status word + a one-line summary; this is + // where "which check, and what did it actually find" lives instead of + // dumping it into that summary line. + // ═══════════════════════════════════════════════════════════════════════ + IssueDetailView { + id: aurDetailCol + visible: root.showAurDetail + headerTitle: "AUR-MALWARE DETAIL" + groups: root.aurGroups + blockKey: "aur_malware" + onBackClicked: root.showAurDetail = false + } + + // ═══════════════════════════════════════════════════════════════════════ + // Persistence detail view — same pattern as the AUR-Malware one. + // ═══════════════════════════════════════════════════════════════════════ + IssueDetailView { + id: persistenceDetailCol + visible: root.showPersistenceDetail + headerTitle: "PERSISTENCE DETAIL" + groups: root.persistenceGroups + blockKey: "persistence" + onBackClicked: root.showPersistenceDetail = false + } + + // ═══════════════════════════════════════════════════════════════════════ + // History view — a compact record of past scans, not just the latest + // one. Without this, a one-off finding that is no longer present leaves + // no trace that it ever happened. + // ═══════════════════════════════════════════════════════════════════════ + Column { + id: historyCol + visible: root.showHistory + width: panelFlick.width + spacing: Style.space(12) + + Item { + width: parent.width + height: Style.spacing.xxl + PanelActionButton { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + iconText: "󰁍" + foreground: root.foreground + tooltipText: "Back" + onClicked: root.showHistory = false + } + Text { + anchors.centerIn: parent + text: "SCAN HISTORY" + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.7) + font.family: Style.font.family + font.pixelSize: Style.font.caption + font.bold: true + } + } + + Text { + visible: root.scanHistoryRecent.length === 0 + width: parent.width + text: "No history yet — it builds up with each scan." + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.52) + font.family: Style.font.family + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + } + + Repeater { + model: root.scanHistoryRecent + delegate: Column { + required property var modelData + width: historyCol.width + spacing: Style.spacing.xxs + PanelSeparator { foreground: root.foreground } + Item { + width: parent.width + height: Math.max(histTime.implicitHeight, histSt.implicitHeight) + Text { + id: histTime + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.relTime(modelData.checked) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.6) + font.family: Style.font.family + font.pixelSize: Style.font.caption + } + Text { + id: histSt + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.statusLabel(root.historyEntryStatus(modelData)) + color: root.statusColor(root.historyEntryStatus(modelData)) + font.family: Style.font.family + font.pixelSize: Style.font.caption + font.bold: true + } + } + Text { + width: parent.width + text: root.historyEntrySummary(modelData) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.75) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap + } + } + } + } + // ═══════════════════════════════════════════════════════════════════════ // Settings view // ═══════════════════════════════════════════════════════════════════════ Column { id: settingsCol visible: root.showSettings - width: detail.contentWidth - detail.padding * 2 - spacing: Style.spacing.md + width: panelFlick.width + spacing: Style.space(12) // Header: back + title Item { @@ -653,14 +1137,14 @@ BarWidget { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter iconText: "󰁍" - foreground: Color.popups.text + foreground: root.foreground tooltipText: "Back" onClicked: root.showSettings = false } Text { anchors.centerIn: parent text: "SCANNER SETUP" - color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.7) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.7) font.family: Style.font.family font.pixelSize: Style.font.caption font.bold: true @@ -703,7 +1187,24 @@ BarWidget { onToggleClicked: root.toggleBun() } + PanelSeparator { foreground: root.foreground } + Column { + width: parent.width + spacing: Style.space(10) + PanelSectionHeader { foreground: root.foreground; text: "PERSISTENCE" } + Text { + width: parent.width + text: "Always active — checks app launchers, user systemd units and crontab. No install step needed." + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.52) + font.family: Style.font.family + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap + } + } + Item { width: parent.width; height: Style.spacing.xs } } + } + } } } diff --git a/install.sh b/install.sh index a0f0373..8f26026 100755 --- a/install.sh +++ b/install.sh @@ -1,25 +1,43 @@ #!/usr/bin/env bash # Optional install step for the Security Scan plugin. -# Copies qs-bun-check-oneshot.sh to ~/.local/bin/ so the widget can -# show the per-project bun-check scan button. +# - Copies qs-bun-check-oneshot.sh to ~/.local/bin/ so the widget can +# show the per-project bun-check scan button. +# - Copies qs-security-scan.sh to ~/.local/bin/ and enables the systemd +# user timer that runs it every 6h -- without this, the widget has +# nothing writing ~/.cache/qs-security-status.json and never shows a +# result even once a scanner is installed. # # Usage: -# bash install.sh # asks whether to install bun-check -# bash install.sh --bun-check # install bun-check without prompting -# bash install.sh --no-bun-check # skip bun-check +# bash install.sh # asks about both steps +# bash install.sh --bun-check # install bun-check without prompting +# bash install.sh --no-bun-check # skip bun-check +# bash install.sh --scan-timer # install+enable the scan timer without prompting +# bash install.sh --no-scan-timer # skip the scan timer set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN_DIR="${HOME}/.local/bin" +SYSTEMD_DIR="${HOME}/.config/systemd/user" + BUN_CHECK_SRC="$SCRIPT_DIR/qs-bun-check-oneshot.sh" BUN_CHECK_DST="$BIN_DIR/qs-bun-check-oneshot.sh" +SCAN_SCRIPT_SRC="$SCRIPT_DIR/qs-security-scan.sh" +SCAN_SCRIPT_DST="$BIN_DIR/qs-security-scan.sh" +DISMISS_SCRIPT_SRC="$SCRIPT_DIR/qs-security-dismiss.sh" +DISMISS_SCRIPT_DST="$BIN_DIR/qs-security-dismiss.sh" +SCAN_SERVICE_SRC="$SCRIPT_DIR/systemd/qs-security-scan.service" +SCAN_TIMER_SRC="$SCRIPT_DIR/systemd/qs-security-scan.timer" + install_bun_check= +install_scan_timer= for arg in "$@"; do case "$arg" in --bun-check) install_bun_check=true ;; --no-bun-check) install_bun_check=false ;; + --scan-timer) install_scan_timer=true ;; + --no-scan-timer) install_scan_timer=false ;; esac done @@ -36,3 +54,23 @@ if [[ "$install_bun_check" == true ]]; then else echo "Skipped bun-check install. Run with --bun-check later to add it." fi + +if [[ -z "$install_scan_timer" ]]; then + read -rp "Install and enable the periodic security-scan timer (every 6h)? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] && install_scan_timer=true || install_scan_timer=false +fi + +if [[ "$install_scan_timer" == true ]]; then + mkdir -p "$BIN_DIR" "$SYSTEMD_DIR" + cp "$SCAN_SCRIPT_SRC" "$SCAN_SCRIPT_DST" + chmod +x "$SCAN_SCRIPT_DST" + cp "$DISMISS_SCRIPT_SRC" "$DISMISS_SCRIPT_DST" + chmod +x "$DISMISS_SCRIPT_DST" + cp "$SCAN_SERVICE_SRC" "$SYSTEMD_DIR/qs-security-scan.service" + cp "$SCAN_TIMER_SRC" "$SYSTEMD_DIR/qs-security-scan.timer" + systemctl --user daemon-reload + systemctl --user enable --now qs-security-scan.timer + echo "Installed $SCAN_SCRIPT_DST and enabled qs-security-scan.timer" +else + echo "Skipped scan timer install. Run with --scan-timer later to add it." +fi diff --git a/qs-security-dismiss.sh b/qs-security-dismiss.sh new file mode 100755 index 0000000..4027a5b --- /dev/null +++ b/qs-security-dismiss.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Marks a finding (AUR-Malware or persistence) as reviewed/dismissed, or +# reactivates it. Called from SecurityWidget.qml (detail tab). The optional +# 4th argument says which status-file block to recompute ("aur_malware" by +# default, or "persistence"). +# +# The fingerprint is check-name + a hash of the EXACT detail text -- if the +# detail changes (e.g. a new infected package shows up next to the one +# already reviewed, or a modification date changes), it stops matching what +# was saved and the finding counts as active again on its own. There is no +# way for "dismiss this" to silence a different/new finding. +# +# Only applies to checks that qs-security-scan.sh marks as dismissible +# (heuristics prone to false positives: /etc/hosts, modified shell configs, +# etc.) -- checks that are direct evidence of an actual compromise +# (known-infected package, malicious npm/bun indicator, eBPF, hidden +# processes...) have no dismiss button in the UI, so they should never reach +# here for those, but the filter is also applied on the qs-security-scan.sh +# side just in case. +set -euo pipefail + +ACTION="$1" # dismiss | reactivate +NAME="$2" +DETAIL="$3" +BLOCK_KEY="${4:-aur_malware}" # aur_malware | persistence +DISMISSED_FILE="${QS_SEC_DISMISSED_FILE:-$HOME/.config/qs-security/dismissed.json}" +STATUS_FILE="${QS_SEC_STATUS_FILE:-$HOME/.cache/qs-security-status.json}" + +mkdir -p "$(dirname "$DISMISSED_FILE")" +python3 -c ' +import json, sys, hashlib, datetime, os, re + +action, name, detail, dismissed_path, status_path, block_key = sys.argv[1:7] +fp = name + "::" + hashlib.sha1(detail.encode()).hexdigest()[:10] + +try: + dismissed = json.load(open(dismissed_path)) +except Exception: + dismissed = {} + +if action == "dismiss": + dismissed[fp] = {"name": name, "dismissedAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} +else: + dismissed.pop(fp, None) + +os.makedirs(os.path.dirname(dismissed_path), exist_ok=True) +json.dump(dismissed, open(dismissed_path, "w"), indent=2) + +# Recompute the affected block status/summary on the existing status file +# WITHOUT rerunning the scanner (~13s for the network fetch + all 18 +# checks) -- dismiss/reactivate is just bookkeeping over an already-run +# scan, it does not need a fresh one to be reflected in the badge. +try: + status = json.load(open(status_path)) +except Exception: + sys.exit() # nothing to recompute yet (no scan has run) + +block = status.get(block_key) +if not block or "issues" not in block: + sys.exit() + +for issue in block["issues"]: + issue_fp = issue.get("fingerprint", "") + issue["dismissed"] = issue_fp in dismissed + +active_fail = sum(1 for i in block["issues"] if i["status"] == "FAIL" and not i["dismissed"]) +active_warn = sum(1 for i in block["issues"] if i["status"] == "WARN" and not i["dismissed"]) +dismissed_n = sum(1 for i in block["issues"] if i["dismissed"]) +block["status"] = "fail" if active_fail else ("warn" if active_warn else "clean") + +if block_key == "persistence": + active = active_fail + active_warn + if active: + summary = f"{active} persistence finding" + ("s" if active != 1 else "") + else: + summary = "no persistence indicators" +else: + total = None + m = re.search(r"out of (\d+) checks", block.get("summary", "")) + if m: total = int(m.group(1)) + if active_fail or active_warn: + bits = [] + if active_fail: bits.append(str(active_fail) + " failures") + if active_warn: bits.append(str(active_warn) + " warnings") + summary = ", ".join(bits) + (" out of " + str(total) + " checks" if total is not None else "") + else: + summary = (str(total) + "/" + str(total) + " checks passed") if total is not None else "no active findings" + +if dismissed_n: + summary += " (" + str(dismissed_n) + " dismissed)" +block["summary"] = summary + +json.dump(status, open(status_path, "w")) +' "$ACTION" "$NAME" "$DETAIL" "$DISMISSED_FILE" "$STATUS_FILE" "$BLOCK_KEY" diff --git a/qs-security-scan.sh b/qs-security-scan.sh new file mode 100755 index 0000000..7b422c0 --- /dev/null +++ b/qs-security-scan.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# Writes ~/.cache/qs-security-status.json for the "Security Scan" Omarchy +# bar-widget plugin (io.github.elynch303.security-scan). The plugin's own +# README documents this script and a systemd timer to run it every 6h, but +# doesn't actually ship either -- reported upstream: +# https://github.com/elynch303/security-scan/issues/1 +# +# Extras added in this change on top of #2/#3: +# - Skips the run while a game is active (GameMode), logging that to +# $LAST_RUN_FILE so the widget can show it instead of silently going stale. +# - A bash-only "persistence" scanner (autostart .desktop entries, user +# systemd units, crontab) -- same injection-pattern heuristic AUR-Malware +# already uses for shell configs, applied to the other classic persistence +# spots malware drops into. +# - Appends a compact entry to $HISTORY_FILE (capped) so past scans aren't +# thrown away the moment the next one overwrites $STATUS_FILE. +# - Sends a real desktop notification for genuinely NEW active findings +# (tracked in $NOTIFIED_FILE by fingerprint) instead of only changing the +# badge color, which nobody sees unless they're already looking at the bar. +# +# AUR-Malware is skipped entirely unless something executable already sits +# at QS_SEC_AUR_MALWARE -- omitting the key is safe, the widget only shows +# a section when its own presence probe finds the tool installed. Default +# path matches SecurityWidget.qml's own install button, which clones +# nightdevil00/AUR-Malware -- the upstream Atomic-Arch/AUR-Malware this repo +# originally pointed at is gone (404); nightdevil00's fork ships the same +# check-atomic-arch_new.sh entry point. +set -uo pipefail + +STATUS_FILE="${QS_SEC_STATUS_FILE:-$HOME/.cache/qs-security-status.json}" +AUR_MALWARE_PATH="${QS_SEC_AUR_MALWARE:-$HOME/.local/share/AUR-Malware/check-atomic-arch_new.sh}" +BUMBLEBEE_BIN="${QS_SEC_BUMBLEBEE:-$HOME/.local/bin/bumblebee}" +CATALOG="${QS_SEC_BUMBLEBEE_CATALOG:-$HOME/.local/share/qs-security/threat-intel}" +LAST_RUN_FILE="${QS_SEC_LAST_RUN_FILE:-$HOME/.cache/qs-security-last-run.json}" +HISTORY_FILE="${QS_SEC_HISTORY_FILE:-$HOME/.local/share/qs-security/history.json}" +NOTIFIED_FILE="${QS_SEC_NOTIFIED_FILE:-$HOME/.config/qs-security/notified.json}" +DISMISSED_FILE="${QS_SEC_DISMISSED_FILE:-$HOME/.config/qs-security/dismissed.json}" +HISTORY_CAP=30 + +# ── skip while gaming ──────────────────────────────────────────────────── +# Doing this as the first thing in the script (rather than only via +# ExecCondition= in the systemd unit) means we can actually log *why* the +# run was skipped for the widget to show -- ExecCondition failing stops +# systemd from ever starting this script at all, so it has no chance to +# write anything. +if command -v gamemoded >/dev/null 2>&1 && gamemoded -s 2>/dev/null | grep -q "is active"; then + mkdir -p "$(dirname "$LAST_RUN_FILE")" + python3 -c ' +import json, sys, datetime +print(json.dumps({ + "skipped": True, + "reason": "gamemode-active", + "at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), +})) +' >"$LAST_RUN_FILE" + exit 0 +fi + +aur_json="null" +if [[ -x $AUR_MALWARE_PATH ]]; then + # --json still prints its live colored progress to stdout before the final + # JSON blob, and the script's exit code is always 0 regardless of findings + # -- a plain "last line" / exit-code check always reports "clean" with a + # disclaimer fragment as the summary, silently hiding real findings. The + # JSON itself is the last '{'-only line to EOF. + aur_out=$("$AUR_MALWARE_PATH" --json 2>/dev/null) + aur_json=$(awk '/^\{$/{f=1} f' <<<"$aur_out" | QS_SEC_DISMISSED_FILE="$DISMISSED_FILE" python3 -c ' +import json, sys, re, subprocess, hashlib, os + +# Checks that are direct evidence of an actual compromise (matched against a +# known-infected package/indicator list, or a very specific rootkit +# artifact) -- these can NEVER be dismissed from the UI, unlike the +# heuristic ones (modification date, /etc/hosts, unusual SUID...) that are +# genuinely prone to legitimate false positives. This is what makes +# "dismiss" safe on a malware-detection tool: you can never silence the +# finding that actually matters, only the heuristic noise around it. +NON_DISMISSIBLE = { + "Known-infected AUR packages", + "Malicious npm/bun/pnpm/yarn packages", + "eBPF artifacts", + "Hidden processes", + "Pacman log analysis", + "ld.so.preload injection", + "Atomic Arch persistence artifacts", +} + +DISMISSED_FILE = os.path.expanduser(os.environ.get("QS_SEC_DISMISSED_FILE", "~/.config/qs-security/dismissed.json")) +try: + DISMISSED = json.load(open(DISMISSED_FILE)) +except Exception: + DISMISSED = {} + +def fingerprint(name, detail): + return name + "::" + hashlib.sha1(detail.encode()).hexdigest()[:10] + +def source_for(name, detail): + """Best-effort: which package/reason explains this finding, so the + detail tab does not leave the user guessing. Two cases: + - Actual malware: the checks that really matter (known-infected AUR + package, malicious npm/bun indicator) already put the package name in + the text itself -- it just needs extracting per exact format, a + generic path regex is not enough for this. + - False positive with an identifiable cause: e.g. Overwolf entries in + /etc/hosts are a deliberate telemetry block for CurseForge, not + malware -- /etc/hosts itself already says so in a comment above the + entry.""" + # "Known-infected AUR packages" / "Pacman log analysis": AUR-Malware + # already gives the package name(s) as-is, space-separated, after + # "package(s):" or "pacman log:". + if name in ("Known-infected AUR packages", "Pacman log analysis"): + m = re.search(r"(?:package\(s\)|pacman log):\s*(.+)$", detail) + if m: + return m.group(1).strip() + # "Malicious npm/bun/pnpm/yarn packages" / "...global hook scripts": + # each hit is "package-name(location)", e.g. "atomic-lockfile(npm-cache)". + if name in ("Malicious npm/bun/pnpm/yarn packages", "npm/bun/pnpm/yarn global hook scripts"): + pkgs = re.findall(r"(\S+)\([^)]+\)", detail.split(":", 1)[-1]) + if pkgs: + return ", ".join(dict.fromkeys(pkgs)) + for domain, _ip in re.findall(r"(\S+)->(\S+)", detail): + try: + lines = open("/etc/hosts").read().splitlines() + except Exception: + lines = [] + for i, hl in enumerate(lines): + if domain in hl: + j = i - 1 + while j >= 0 and lines[j].strip() == "": + j -= 1 + if j >= 0 and lines[j].strip().startswith("#"): + return lines[j].strip().lstrip("#").strip() + for path in set(re.findall(r"(/(?:home|etc|usr|var|opt)/\S+?)(?=[\s()\x27\"]|$)", detail)): + try: + out = subprocess.run(["pacman", "-Qo", path], capture_output=True, text=True, timeout=2) + m = re.search(r"is owned by (\S+)", out.stdout) + if m: + return m.group(1) + except Exception: + pass + return "" + +try: + d = json.load(sys.stdin) +except Exception: + print("null"); sys.exit() +s = d.get("summary", {}) +total = s.get("total", 0) + +# Only the checks that did not pass -- the widget uses these for the detail +# tab (see SecurityWidget.qml), no need to carry all 18 every time. +issues = [] +for c in d.get("checks", []): + if c.get("status") == "PASS": + continue + name, detail = str(c.get("name", "")), str(c.get("detail", "")) + fp = fingerprint(name, detail) + issues.append({ + "name": name, "status": c.get("status", ""), "detail": detail, + "source": source_for(name, detail), + "dismissible": name not in NON_DISMISSIBLE, + "dismissed": fp in DISMISSED, + "fingerprint": fp, + }) + +# The verdict/summary that drives the badge color counts only ACTIVE +# (non-dismissed) findings -- otherwise dismissing a known false positive +# would not stop the badge going red every 6h for the same thing, which is +# exactly the problem this mechanism is meant to solve. +active_fail = sum(1 for i in issues if i["status"] == "FAIL" and not i["dismissed"]) +active_warn = sum(1 for i in issues if i["status"] == "WARN" and not i["dismissed"]) +dismissed_n = sum(1 for i in issues if i["dismissed"]) +status = "fail" if active_fail else ("warn" if active_warn else "clean") +if active_fail or active_warn: + bits = [] + if active_fail: bits.append(str(active_fail) + " failures") + if active_warn: bits.append(str(active_warn) + " warnings") + summary = ", ".join(bits) + " out of " + str(total) + " checks" +else: + summary = str(total) + "/" + str(total) + " checks passed" +if dismissed_n: + summary += " (" + str(dismissed_n) + " dismissed)" +print(json.dumps({"status": status, "summary": summary, "issues": issues})) +') +fi + +bb_json="null" +if [[ -x $BUMBLEBEE_BIN ]]; then + catalog_args=() + [[ -d $CATALOG ]] && catalog_args=(--exposure-catalog "$CATALOG") + scan_out=$("$BUMBLEBEE_BIN" scan --profile baseline "${catalog_args[@]}" 2>/dev/null) + packages=$(grep -c '"record_type":"package"' <<<"$scan_out") + findings=$(grep -c '"record_type":"finding"' <<<"$scan_out") + bb_status=$([[ $findings -gt 0 ]] && echo findings || echo clean) + bb_json=$(python3 -c ' +import json, sys +status, packages, findings = sys.argv[1], sys.argv[2], sys.argv[3] +summary = f"{packages} packages inventoried, {findings} findings against threat-intel catalog" +print(json.dumps({"status": status, "summary": summary})) +' "$bb_status" "$packages" "$findings") +fi + +# ── persistence: autostart entries / user systemd units / crontab ──────── +# Same injection-pattern heuristic AUR-Malware already uses for shell +# configs (curl|..., bash <(curl...), eval "$(curl|wget|base64|bash|sh ...", +# base64 -d | ...), applied to the other classic spots malware plants +# persistence into: app launchers, user timers/services, cron. +persistence_json=$(QS_SEC_DISMISSED_FILE="$DISMISSED_FILE" python3 -c ' +import json, os, re, glob, hashlib, subprocess, datetime + +DISMISSED_FILE = os.path.expanduser(os.environ.get("QS_SEC_DISMISSED_FILE", "~/.config/qs-security/dismissed.json")) +try: + DISMISSED = json.load(open(DISMISSED_FILE)) +except Exception: + DISMISSED = {} + +home = os.path.expanduser("~") + +INJECTION = re.compile( + r"(curl[ \t]+[^|]+[ \t]*\||bash[ \t]*<\(curl|eval[ \t]+\"?\$\([ \t]*(curl|wget|base64|bash|sh)([ \t]|[;|)]|$)|base64[ \t]+-d[ \t]*\|)" +) + +def fingerprint(name, detail): + return name + "::" + hashlib.sha1(detail.encode()).hexdigest()[:10] + +findings = [] # (check_name, source_path, matched_line) + +def scan_lines(path, key_filter=None): + try: + with open(path, errors="replace") as f: + lines = f.readlines() + except Exception: + return + for line in lines: + line = line.rstrip("\n") + if key_filter and not key_filter(line): + continue + if INJECTION.search(line): + yield line.strip() + +# .desktop launchers: user-installed apps + autostart entries. Only the +# Exec= line matters -- everything else in a .desktop is display metadata. +for pattern in (home + "/.local/share/applications/*.desktop", home + "/.config/autostart/*.desktop"): + for path in glob.glob(pattern): + for line in scan_lines(path, lambda l: l.startswith("Exec=")): + findings.append(("App launcher persistence", path, line)) + +# Real user-authored systemd units directly under ~/.config/systemd/user -- +# not the *.wants/ enablement symlinks, those point into /usr/lib and are +# package-managed, not something malware would edit. +unit_dir = home + "/.config/systemd/user" +if os.path.isdir(unit_dir): + for entry in os.listdir(unit_dir): + path = os.path.join(unit_dir, entry) + if not (entry.endswith(".service") or entry.endswith(".timer")): + continue + if os.path.islink(path) or not os.path.isfile(path): + continue + for line in scan_lines(path, lambda l: l.strip().startswith(("ExecStart", "ExecStartPre", "ExecStartPost", "ExecCondition"))): + findings.append(("systemd unit persistence", path, line)) + +# User crontab, if any. +try: + out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=3) + for line in out.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if INJECTION.search(line): + findings.append(("crontab persistence", "crontab -l", line)) +except Exception: + pass + +issues = [] +for name, source, line in findings: + detail = f"{source}: {line}" + fp = fingerprint(name, detail) + issues.append({ + "name": name, "status": "FAIL", "detail": detail, + "source": source, + "dismissible": True, + "dismissed": fp in DISMISSED, + "fingerprint": fp, + }) + +active = [i for i in issues if not i["dismissed"]] +dismissed_n = len(issues) - len(active) +status = "fail" if active else "clean" +if active: + summary = f"{len(active)} persistence finding" + ("s" if len(active) != 1 else "") +else: + summary = "no persistence indicators" +if dismissed_n: + summary += f" ({dismissed_n} dismissed)" +print(json.dumps({"status": status, "summary": summary, "issues": issues})) +') + +mkdir -p "$(dirname "$STATUS_FILE")" +python3 -c ' +import json, sys, datetime +aur, bb, persistence = json.loads(sys.argv[1]), json.loads(sys.argv[2]), json.loads(sys.argv[3]) +out = {"checked": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} +if aur is not None: out["aur_malware"] = aur +if bb is not None: out["bumblebee"] = bb +out["persistence"] = persistence +print(json.dumps(out)) +' "$aur_json" "$bb_json" "$persistence_json" >"$STATUS_FILE" + +mkdir -p "$(dirname "$LAST_RUN_FILE")" +python3 -c ' +import json, datetime +print(json.dumps({"skipped": False, "at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")})) +' >"$LAST_RUN_FILE" + +# ── history: keep a compact record of each real scan (not the ones skipped +# for gaming, those already exit early above) ──────────────────────────── +mkdir -p "$(dirname "$HISTORY_FILE")" +python3 -c ' +import json, os, sys + +status_path, history_path, cap = sys.argv[1], sys.argv[2], int(sys.argv[3]) +status = json.load(open(status_path)) + +entry = {"checked": status.get("checked")} +for key in ("aur_malware", "bumblebee", "persistence"): + v = status.get(key) + if v: + entry[key] = {"status": v.get("status"), "summary": v.get("summary")} + +try: + history = json.load(open(history_path)) + if not isinstance(history, list): + history = [] +except Exception: + history = [] + +history.append(entry) +history = history[-cap:] +json.dump(history, open(history_path, "w")) +' "$STATUS_FILE" "$HISTORY_FILE" "$HISTORY_CAP" + +# ── real desktop notification, only for ACTIVE and NEW findings (never seen +# before, not already dismissed) -- the badge changing color is invisible to +# anyone who isn't already looking at the bar right then ───────────────── +mkdir -p "$(dirname "$NOTIFIED_FILE")" +notify_summary=$(python3 -c ' +import json, os, sys + +status_path, notified_path, history_path = sys.argv[1], sys.argv[2], sys.argv[3] +status = json.load(open(status_path)) + +try: + notified = json.load(open(notified_path)) + if not isinstance(notified, dict): + notified = {} +except Exception: + notified = {} + +new_items = [] # (severity, label) +worst = "warn" + +for key in ("aur_malware", "persistence"): + block = status.get(key) + if not block: + continue + for issue in block.get("issues", []): + if issue.get("dismissed"): + continue + fp = issue.get("fingerprint", "") + if not fp or fp in notified: + continue + notified[fp] = True + label = issue.get("source") or issue.get("name") + new_items.append((issue.get("status", "WARN"), label)) + if issue.get("status") == "FAIL": + worst = "fail" + +# bumblebee has no per-finding fingerprint -- only notify on the clean -> +# findings transition, by comparing against the previous history entry. +bb = status.get("bumblebee") +if bb and bb.get("status") == "findings": + try: + history = json.load(open(history_path)) + prev = history[-2] if len(history) >= 2 else None + except Exception: + prev = None + prev_bb_status = (prev or {}).get("bumblebee", {}).get("status") + if prev_bb_status != "findings": + new_items.append(("WARN", "bumblebee: " + bb.get("summary", ""))) + +json.dump(notified, open(notified_path, "w")) + +if new_items: + print(worst) + for sev, label in new_items: + print(sev + "\t" + label) +' "$STATUS_FILE" "$NOTIFIED_FILE" "$HISTORY_FILE") + +if [[ -n $notify_summary ]]; then + worst_severity=$(head -1 <<<"$notify_summary") + items=$(tail -n +2 <<<"$notify_summary") + count=$(wc -l <<<"$items") + urgency=$([[ $worst_severity == fail ]] && echo critical || echo normal) + # Standard freedesktop icon names so this picks up the user's own icon + # theme automatically instead of shipping/generating custom artwork. + icon=$([[ $worst_severity == fail ]] && echo dialog-error || echo dialog-warning) + body=$(cut -f2 <<<"$items" | head -5 | sed 's/^/• /') + if [[ $count -gt 5 ]]; then + body="$body"$'\n'"… and $((count - 5)) more" + fi + notify-send --app-name="Security Scan" --urgency="$urgency" --icon="$icon" \ + "$count new security finding$([[ $count != 1 ]] && echo s)" \ + "$body" 2>/dev/null || true +fi diff --git a/systemd/qs-security-scan.service b/systemd/qs-security-scan.service new file mode 100644 index 0000000..8425f30 --- /dev/null +++ b/systemd/qs-security-scan.service @@ -0,0 +1,6 @@ +[Unit] +Description=Security scan for omarchy bar + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/qs-security-scan.sh diff --git a/systemd/qs-security-scan.timer b/systemd/qs-security-scan.timer new file mode 100644 index 0000000..c3beba0 --- /dev/null +++ b/systemd/qs-security-scan.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Periodic security scan for omarchy bar + +[Timer] +OnBootSec=2min +OnUnitActiveSec=6h + +[Install] +WantedBy=timers.target