package checks
import (
"os"
"path/filepath"
"strings"
"github.com/samber/lo"
)
type PasswordManagerCheck struct {
passed bool
}
func (pmc *PasswordManagerCheck) Name() string {
return "Password Manager Presence"
}
func (pmc *PasswordManagerCheck) Run() error {
appNames := []string{
"1Password.app",
"1Password 8.app",
"1Password 7.app",
"Bitwarden.app",
"Dashlane.app",
"KeePassXC.app",
"KeePassX.app",
}
if checkInstalledApplications(appNames) || checkForBrowserExtensions() {
pmc.passed = true
} else {
pmc.passed = false
}
return nil
}
func checkInstalledApplications(appNames []string) bool {
searchPaths := []string{
"/Applications",
"/System/Applications",
filepath.Join(os.Getenv("HOME"), "Applications"),
}
for _, path := range searchPaths {
if contents, err := os.ReadDir(path); err == nil {
for _, entry := range contents {
if entry.IsDir() && lo.Contains(appNames, entry.Name()) {
return true
}
}
}
}
return false
}
func checkForBrowserExtensions() bool {
home := os.Getenv("HOME")
extensionPaths := map[string]string{
"Google Chrome": filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Extensions"),
"Firefox": filepath.Join(home, "Library", "Application Support", "Firefox", "Profiles"),
"Microsoft Edge": filepath.Join(home, "Library", "Application Support", "Microsoft Edge", "Default", "Extensions"),
"Brave Browser": filepath.Join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser", "Default", "Extensions"),
}
browserExtensions := []string{
"LastPass",
"ProtonPass",
"NordPass",
"Bitwarden",
"1Password",
"KeePass",
"Dashlane",
}
for _, extPath := range extensionPaths {
if _, err := os.Stat(extPath); err == nil {
entries, err := os.ReadDir(extPath)
if err == nil {
for _, entry := range entries {
name := strings.ToLower(entry.Name())
for _, ext := range browserExtensions {
if strings.Contains(name, strings.ToLower(ext)) {
return true
}
}
}
}
}
}
return false
}
func (pmc *PasswordManagerCheck) Passed() bool {
return pmc.passed
}
func (pmc *PasswordManagerCheck) IsRunnable() bool {
return true
}
func (pmc *PasswordManagerCheck) UUID() string {
return "f962c423-fdf5-428a-a57a-827abc9b253e"
}
func (pmc *PasswordManagerCheck) PassedMessage() string {
return "Password manager is present"
}
func (pmc *PasswordManagerCheck) FailedMessage() string {
return "No password manager found"
}
func (pmc *PasswordManagerCheck) RequiresRoot() bool {
return false
}
func (pmc *PasswordManagerCheck) Status() string {
if pmc.Passed() {
return pmc.PassedMessage()
}
return pmc.FailedMessage()
}
package checks
import (
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/samber/lo"
)
type ApplicationUpdates struct {
passed bool
details string
}
// Name returns the name of the check
func (f *ApplicationUpdates) Name() string {
return "Apps are up to date"
}
// parseFlatpak parses the output of flatpak update commands and extracts application
func (f *ApplicationUpdates) parseFlatpak(updateLines string) (apps map[string]string) {
apps = make(map[string]string)
for line := range strings.Lines(updateLines) {
// Skip empty lines
if strings.TrimSpace(line) == "" {
continue
}
// Skip lines that do not contain a dot, which indicates a version number
if !strings.Contains(line, ".") {
continue
}
// Split the line into parts, expecting at least two: application ID and version
parts := strings.Fields(line)
if len(parts) >= 2 {
apps[parts[0]] = parts[1]
}
}
return
}
func (f *ApplicationUpdates) checkUpdates() (bool, string) {
updates := []string{}
// Check flatpak
if _, err := lookPath("flatpak"); err == nil {
updatesOutput, err := shared.RunCommand("flatpak", "remote-ls", "--app", "--updates", "--columns=application,version")
if err != nil {
log.WithError(err).Error("Failed to check flatpak updates")
return true, "Flatpak updates check failed"
}
installedOutput, err := shared.RunCommand("flatpak", "list", "--app", "--columns=application,version")
if err != nil {
log.WithError(err).Error("Failed to list installed flatpak apps")
return true, "Flatpak installed apps check failed"
}
installedApps := f.parseFlatpak(string(installedOutput))
updatableApps := f.parseFlatpak(string(updatesOutput))
log.WithField("updates", updatesOutput).WithField("installed", installedOutput).Debug("Flatpak updates")
for app, version := range installedApps {
if installed, ok := updatableApps[app]; ok && version != installed {
updates = append(updates, "Flatpak")
break
}
}
}
// Check apt
if _, err := lookPath("apt"); err == nil {
output, err := shared.RunCommand("apt", "list", "--upgradable")
log.WithField("output", string(output)).Debug("APT updates")
if err == nil && len(output) > 0 && strings.Contains(string(output), "upgradable") {
updates = append(updates, "APT")
}
}
// Check dnf
if _, err := lookPath("dnf"); err == nil {
if out, _ := shared.RunCommand("dnf", "updateinfo", "list", "--security", "--quiet"); !lo.IsEmpty(out) {
outStr := string(out)
if strings.Contains(outStr, "security") && strings.Count(outStr, "\n") > 0 {
updates = append(updates, "DNF")
}
}
}
// Check pacman
if _, err := lookPath("pacman"); err == nil {
output, err := shared.RunCommand("pacman", "-Qu")
log.WithField("output", string(output)).Debug("Pacman updates")
if err == nil && len(output) > 0 {
updates = append(updates, "Pacman")
}
}
// Check snap
if _, err := lookPath("snap"); err == nil {
// Check if snapd is running
snapdStatus, err := shared.RunCommand("systemctl", "is-active", "snapd")
if err == nil && strings.TrimSpace(string(snapdStatus)) == "active" {
output, err := shared.RunCommand("snap", "refresh", "--list")
log.WithField("output", string(output)).Debug("Snap updates")
if err == nil && len(output) > 0 && !strings.Contains(string(output), "All snaps up to date.") {
updates = append(updates, "Snap")
}
} else {
log.Debug("snapd is not running, skipping snap updates check")
}
}
if len(updates) == 0 {
return true, "All packages are up to date"
}
updates = lo.Uniq(updates)
return false, "Updates available for: " + strings.Join(updates, ", ")
}
// Run executes the check
func (f *ApplicationUpdates) Run() error {
var ok bool
ok, f.details = f.checkUpdates()
f.passed = ok
return nil
}
// Passed returns the status of the check
func (f *ApplicationUpdates) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *ApplicationUpdates) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *ApplicationUpdates) UUID() string {
return "7436553a-ae52-479b-937b-2ae14d15a520"
}
// PassedMessage returns the message to return if the check passed
func (f *ApplicationUpdates) PassedMessage() string {
return "All apps are up to date"
}
// FailedMessage returns the message to return if the check failed
func (f *ApplicationUpdates) FailedMessage() string {
return "Some apps are out of date"
}
// RequiresRoot returns whether the check requires root access
func (f *ApplicationUpdates) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *ApplicationUpdates) Status() string {
return f.details
}
// Package linux provides checks for Linux systems.
package checks
import (
"strings"
"github.com/ParetoSecurity/agent/shared"
)
// Autologin checks for autologin misconfiguration.
type Autologin struct {
passed bool
status string
}
// Name returns the name of the check
func (f *Autologin) Name() string {
return "Automatic login is disabled"
}
// Run executes the check
func (f *Autologin) Run() error {
f.passed = true
// Check KDE (SDDM) autologin
sddmFiles, _ := filepathGlob("/etc/sddm.conf.d/*.conf")
for _, file := range sddmFiles {
content, err := shared.ReadFile(file)
if err == nil {
if strings.Contains(string(content), "Autologin=true") {
f.passed = false
f.status = "Autologin=true in SDDM is enabled"
return nil
}
}
}
// Check main SDDM config
if content, err := shared.ReadFile("/etc/sddm.conf"); err == nil {
if strings.Contains(string(content), "Autologin=true") {
f.passed = false
f.status = "Autologin=true in SDDM is enabled"
return nil
}
}
// Check GNOME (GDM) autologin
gdmPaths := []string{"/etc/gdm3/custom.conf", "/etc/gdm/custom.conf"}
for _, path := range gdmPaths {
if content, err := shared.ReadFile(path); err == nil {
if strings.Contains(string(content), "AutomaticLoginEnable=true") {
f.passed = false
f.status = "AutomaticLoginEnable=true in GDM is enabled"
return nil
}
}
}
// Check GNOME (GDM) autologin using dconf
output, err := shared.RunCommand("dconf", "read", "/org/gnome/login-screen/enable-automatic-login")
if err == nil && strings.TrimSpace(string(output)) == "true" {
f.passed = false
f.status = "Automatic login is enabled in GNOME"
return nil
}
return nil
}
// Passed returns the status of the check
func (f *Autologin) Passed() bool {
return f.passed
}
// IsRunnable returns whether Autologin is runnable.
func (f *Autologin) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *Autologin) UUID() string {
return "f962c423-fdf5-428a-a57a-816abc9b253e"
}
// PassedMessage returns the message to return if the check passed
func (f *Autologin) PassedMessage() string {
return "Automatic login is off"
}
// FailedMessage returns the message to return if the check failed
func (f *Autologin) FailedMessage() string {
return "Automatic login is on"
}
// RequiresRoot returns whether the check requires root access
func (f *Autologin) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *Autologin) Status() string {
if !f.Passed() {
return f.status
}
return f.PassedMessage()
}
package checks
import (
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/samber/lo"
)
type DockerAccess struct {
passed bool
status string
}
// Name returns the name of the check
func (f *DockerAccess) Name() string {
return "Access to Docker is restricted"
}
// Run executes the check
func (f *DockerAccess) Run() error {
// Check if we deprecate packages installed via apt
// https://docs.docker.com/engine/install/ubuntu/#uninstall-old-versions
if _, err := shared.RunCommand("which", "dpkg-query"); err == nil {
out, err := shared.RunCommand("dpkg-query", "-W", "-f='${Package}'", "docker.io")
if err == nil && strings.Contains(out, "docker") {
f.passed = false
f.status = "Deprecated docker.io package installed via apt"
return nil
}
}
output, err := shared.RunCommand("docker", "info", "--format", "{{.SecurityOptions}}")
if err != nil || lo.IsEmpty(output) {
f.passed = false
f.status = "Failed to get Docker info"
return err
}
if !strings.Contains(output, "rootless") {
f.passed = false
f.status = f.FailedMessage()
return nil
}
f.passed = true
return nil
}
// Passed returns the status of the check
func (f *DockerAccess) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *DockerAccess) IsRunnable() bool {
// Check if Docker is installed
out, _ := shared.RunCommand("docker", "version")
if !strings.Contains(out, "Version") {
f.status = "Docker is not installed"
return false
}
// Check if the user has access to the Docker daemon
// This is a workaround for the issue where the Docker daemon is running as manager only (via systemd)
// and the user does not access to the Docker daemon
if strings.Contains(out, "Cannot connect to the Docker daemon") {
f.status = "No access to Docker daemon, with the current user"
return false
}
return true
}
// UUID returns the UUID of the check
func (f *DockerAccess) UUID() string {
return "25443ceb-c1ec-408c-b4f3-2328ea0c84e1"
}
// PassedMessage returns the message to return if the check passed
func (f *DockerAccess) PassedMessage() string {
return "Docker is running in rootless mode"
}
// FailedMessage returns the message to return if the check failed
func (f *DockerAccess) FailedMessage() string {
return "Docker is not running in rootless mode"
}
// RequiresRoot returns whether the check requires root access
func (f *DockerAccess) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *DockerAccess) Status() string {
if !f.Passed() {
return f.status
}
return f.PassedMessage()
}
package checks
import (
"bufio"
"strconv"
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
)
// Firewall checks the system firewall.
type Firewall struct {
passed bool
}
// Name returns the name of the check
func (f *Firewall) Name() string {
return "Firewall is configured"
}
// checkNFTables verifies if NFTables is properly configured on the system.
func (f *Firewall) checkNFTables() bool {
output, err := shared.RunCommand("nft", "list", "ruleset")
if err != nil {
log.WithError(err).Warn("Failed to check nftables status")
return false
}
log.WithField("output", output).Debug("Nftables status")
// Check if the output contains input CHAIN
scanner := bufio.NewScanner(strings.NewReader(output))
inInputChain := false
hasDropPolicy := false
for scanner.Scan() {
line := strings.ToLower(strings.TrimSpace(scanner.Text()))
// Check if we're entering the INPUT chain definition
if strings.HasPrefix(line, "chain input") || strings.HasPrefix(line, "chain filter_input") {
inInputChain = true
continue
}
// If we're in the INPUT chain and find policy information
if inInputChain && strings.Contains(line, "policy") {
if strings.Contains(line, "policy drop") {
hasDropPolicy = true
break // We've found the policy, no need to continue
}
}
// If we're in the INPUT chain and find policy information
if inInputChain && strings.Contains(line, "reject") {
if strings.Contains(line, "reject with") {
hasDropPolicy = true
break // We've found the policy, no need to continue
}
}
// Exit the INPUT chain section if we encounter a new chain
if inInputChain && (strings.HasPrefix(line, "chain") || strings.HasPrefix(line, "}")) {
inInputChain = false // Exit the INPUT chain section
break
}
}
return inInputChain && hasDropPolicy
}
// checkIptables checks if iptables is active
func (f *Firewall) checkIptables() bool {
output, err := shared.RunCommand("iptables", "-L", "INPUT", "--line-numbers")
if err != nil {
log.WithError(err).WithField("output", output).Warn("Failed to check iptables status")
return false
}
log.WithField("output", output).Debug("Iptables status")
// Define a struct to hold iptables rule information
type IptablesRule struct {
Number int
Target string
Protocol string
Options string
Source string
Destination string
}
var rules []IptablesRule
var policy string
// Parse the output to check if there are any rules or chains defined
scanner := bufio.NewScanner(strings.NewReader(output))
lineCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// Extract policy from the first line
if lineCount == 1 && strings.Contains(line, "Chain INPUT") {
if strings.Contains(line, "policy ACCEPT") {
policy = "ACCEPT"
} else if strings.Contains(line, "policy DROP") {
policy = "DROP"
} else if strings.Contains(line, "policy REJECT") {
policy = "REJECT"
}
continue
}
// Skip the header line
if lineCount == 2 {
continue
}
// Parse rule lines
fields := strings.Fields(line)
if len(fields) >= 6 {
ruleNum, err := strconv.Atoi(fields[0])
if err != nil {
continue // Skip lines that don't start with a number
}
rule := IptablesRule{
Number: ruleNum,
Target: fields[1],
Protocol: fields[2],
Options: fields[3],
Source: fields[4],
Destination: fields[5],
}
rules = append(rules, rule)
}
}
// Check for custom chains like nixos-fw
hasCustomChain := false
for _, rule := range rules {
if rule.Target != "ACCEPT" && rule.Target != "DROP" && rule.Target != "REJECT" {
hasCustomChain = true
break
}
}
log.WithField("rules_count", len(rules)).
WithField("policy", policy).
WithField("has_custom_chain", hasCustomChain).
Debug("Iptables has active rules or restrictive policy")
// Firewall is active if there are rules or the policy is restrictive or custom chains are used
return len(rules) > 0 || policy == "DROP" || policy == "REJECT" || hasCustomChain
}
// Run executes the check
func (f *Firewall) Run() error {
f.passed = f.checkIptables()
if !f.passed {
f.passed = f.checkNFTables()
}
return nil
}
// Passed returns the status of the check
func (f *Firewall) Passed() bool {
return f.passed
}
// IsRunnable returns whether Firewall is runnable.
func (f *Firewall) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *Firewall) UUID() string {
return "2e46c89a-5461-4865-a92e-3b799c12034a"
}
// PassedMessage returns the message to return if the check passed
func (f *Firewall) PassedMessage() string {
return "Firewall is on"
}
// FailedMessage returns the message to return if the check failed
func (f *Firewall) FailedMessage() string {
return "Firewall is off"
}
// RequiresRoot returns whether the check requires root access
func (f *Firewall) RequiresRoot() bool {
return true
}
// Status returns the status of the check
func (f *Firewall) Status() string {
if f.Passed() {
return f.PassedMessage()
}
return f.FailedMessage()
}
package checks
import (
"bufio"
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
)
type EncryptingFS struct {
passed bool
}
// Name returns the name of the check
func (f *EncryptingFS) Name() string {
return "Filesystem encryption is enabled"
}
// Passed returns the status of the check
func (f *EncryptingFS) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *EncryptingFS) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *EncryptingFS) UUID() string {
return "21830a4e-84f1-48fe-9c5b-beab436b2cdb"
}
// PassedMessage returns the message to return if the check passed
func (f *EncryptingFS) PassedMessage() string {
return "Block device encryption is enabled"
}
// FailedMessage returns the message to return if the check failed
func (f *EncryptingFS) FailedMessage() string {
return "Block device encryption is disabled"
}
// RequiresRoot returns whether the check requires root access
func (f *EncryptingFS) RequiresRoot() bool {
return true
}
// Run executes the check
func (f *EncryptingFS) Run() error {
f.passed = false
// Check if the system is using LUKS
if maybeCryptoViaLuks() {
f.passed = true
return nil
}
// Check if the system is using kernel parameters for encryption
if maybeCryptoViaKernel() {
f.passed = true
return nil
}
return nil
}
// Status returns the status of the check
func (f *EncryptingFS) Status() string {
if f.Passed() {
return f.PassedMessage()
}
return f.FailedMessage()
}
func maybeCryptoViaLuks() bool {
// Check if the system is using LUKS
lsblk, err := shared.RunCommand("lsblk", "-o", "TYPE,MOUNTPOINT")
if err != nil {
log.WithError(err).Warn("Failed to run lsblk command")
return false
}
scanner := bufio.NewScanner(strings.NewReader(lsblk))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "crypt") {
log.WithField("line", line).Debug("LUKS encryption detected")
return true
}
}
log.WithField("output", lsblk).Warn("Failed to scan lsblk output")
return false
}
func maybeCryptoViaKernel() bool {
// Read kernel parameters to check if root is booted via crypt
cmdline, err := shared.ReadFile("/proc/cmdline")
if err != nil {
log.WithError(err).Warn("Failed to read /proc/cmdline")
}
params := strings.Fields(string(cmdline))
for _, param := range params {
if strings.HasPrefix(param, "cryptdevice=") {
parts := strings.Split(param, ":")
if len(parts) == 3 && parts[2] == "root" {
log.WithField("param", param).Debug("Kernel crypto parameters detected")
return true
}
}
}
return false
}
package checks
import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/ParetoSecurity/agent/shared"
)
// lookPathMock is a mock function that simulates the behavior of
// the os/exec.LookPath function. It takes a file name as input
// and returns the path to the executable file along with an error
// if the file is not found or any other issue occurs.
var lookPathMock func(file string) (string, error)
func lookPath(file string) (string, error) {
if testing.Testing() && lookPathMock != nil {
return lookPathMock(file)
}
return exec.LookPath(file)
}
var osStatMock func(file string) (os.FileInfo, error)
// osStat checks if a file exists by attempting to get its file info.
// During testing, it uses a mock implementation via osStatMock.
// It returns the file path if the file exists, otherwise returns an empty string and error.
func osStat(file string) (os.FileInfo, error) {
if testing.Testing() && osStatMock != nil {
return osStatMock(file)
}
return os.Stat(file)
}
var filepathGlobMock func(pattern string) ([]string, error)
// filepathGlob retrieves file paths that match the provided glob pattern.
//
// In a testing environment (when testing.Testing() returns true), it delegates
// the matching to filepathGlobMock to simulate the behavior. Otherwise, it uses
// the standard library's filepath.Glob to perform glob pattern matching.
func filepathGlob(pattern string) ([]string, error) {
if testing.Testing() && filepathGlobMock != nil {
return filepathGlobMock(pattern)
}
return filepath.Glob(pattern)
}
var osReadFileMock func(file string) ([]byte, error)
// osReadFile reads the contents of the specified file.
//
// If the testing mode is enabled, it delegates the file reading to a mock function.
// Otherwise, it reads the file from disk using the standard os.ReadFile function.
func osReadFile(file string) ([]byte, error) {
if testing.Testing() && osReadFileMock != nil {
return osReadFileMock(file)
}
return os.ReadFile(file)
}
var osReadDirMock func(dirname string) ([]os.DirEntry, error)
// osReadDir reads the directory specified by dirname and returns a slice of os.DirEntry.
// In testing mode, it delegates to osReadDirMock for controlled behavior; otherwise,
// it uses os.ReadDir from the standard library.
func osReadDir(dirname string) ([]os.DirEntry, error) {
if testing.Testing() && osReadDirMock != nil {
return osReadDirMock(dirname)
}
return os.ReadDir(dirname)
}
// mockDirEntry is a simple implementation of os.DirEntry for testing.
type mockDirEntry struct {
name string
isDir bool
mode fs.FileMode
info os.FileInfo // optional, may be nil if you don’t need it
}
// Name returns the file name.
func (m mockDirEntry) Name() string {
return m.name
}
// IsDir returns true if the entry represents a directory.
func (m mockDirEntry) IsDir() bool {
return m.isDir
}
// Type returns the file mode bits that describe the file type.
func (m mockDirEntry) Type() fs.FileMode {
return m.mode
}
// Info returns the os.FileInfo for the entry.
// In this simple mock, if m.info is nil, we return an error.
func (m mockDirEntry) Info() (os.FileInfo, error) {
if m.info != nil {
return m.info, nil
}
return nil, errors.New("file info not available")
}
// convertCommandMapToMocks converts a map of command strings to outputs
// into a slice of RunCommandMock structs
func convertCommandMapToMocks(commandMap map[string]string) []shared.RunCommandMock {
mocks := []shared.RunCommandMock{}
for cmd, output := range commandMap {
parts := strings.Split(cmd, " ")
command := parts[0]
args := []string{}
if len(parts) > 1 {
args = parts[1:]
}
mocks = append(mocks, shared.RunCommandMock{
Command: command,
Args: args,
Out: output,
Err: nil,
})
}
return mocks
}
package checks
import (
"os"
"path/filepath"
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
)
type PasswordManagerCheck struct {
passed bool
}
func (pmc *PasswordManagerCheck) Name() string {
return "Password Manager Presence"
}
func (pmc *PasswordManagerCheck) isManagerInstalled() bool {
passwordManagers := []string{"1password", "bitwarden", "dashlane", "keepassx", "keepassxc", "gnome-keyring"}
for _, pwdManager := range passwordManagers {
if isPackageInstalled(pwdManager) {
log.Debug("Password manager found: " + pwdManager)
return true
}
}
return false
}
func (pmc *PasswordManagerCheck) Run() error {
// Check for password managers installed via package managers
if pmc.isManagerInstalled() {
pmc.passed = true
return nil
}
pmc.passed = checkForBrowserExtensions()
return nil
}
func checkForBrowserExtensions() bool {
home := os.Getenv("HOME")
extensionPaths := map[string]string{
"Google Chrome": filepath.Join(home, ".config", "google-chrome", "Default", "Extensions"),
"Microsoft Edge": filepath.Join(home, ".config", "microsoft-edge", "Default", "Extensions"),
"Brave Browser": filepath.Join(home, ".config", "BraveSoftware", "Brave-Browser", "Default", "Extensions"),
}
browserExtensions := []string{
"hdokiejnpimakedhajhdlcegeplioahd", // LastPass
"ghmbeldphafepmbegfdlkpapadhbakde", // ProtonPass
"eiaeiblijfjekdanodkjadfinkhbfgcd", // nordpass
"nngceckbapebfimnlniiiahkandclbl", // bitwarden
"aeblfdkhhhdcdjpifhhbdiojplfjncoa", // 1password
"fdjamakpfbbddfjaooikfcpapjohcfmg", // dashlane
}
for _, extPath := range extensionPaths {
entries, err := osReadDir(extPath)
if err == nil {
for _, entry := range entries {
name := strings.ToLower(entry.Name())
for _, ext := range browserExtensions {
if strings.Contains(name, strings.ToLower(ext)) {
log.Debug("Password manager extension found: " + ext)
return true
}
}
}
}
}
return false
}
func isPackageInstalled(pkgName string) bool {
output, err := shared.RunCommand("which", pkgName)
if err == nil && !strings.Contains(output, "not found") {
log.Debug("Package found in PATH: " + pkgName)
return true
}
pkgManagers := make(map[string]string)
// Check which package managers are available
if _, err := shared.RunCommand("which", "dpkg"); err == nil {
pkgManagers["apt"] = "dpkg -l"
log.Debug("apt package manager found")
}
if _, err := shared.RunCommand("which", "snap"); err == nil {
pkgManagers["snap"] = "snap list"
log.Debug("snap package manager found")
}
if _, err := shared.RunCommand("which", "yum"); err == nil {
pkgManagers["yum"] = "yum list installed"
log.Debug("yum package manager found")
}
if _, err := shared.RunCommand("which", "flatpak"); err == nil {
pkgManagers["flatpak"] = "flatpak list"
log.Debug("flatpak package manager found")
}
if _, err := shared.RunCommand("which", "pacman"); err == nil {
pkgManagers["pacman"] = "pacman -Q"
log.Debug("pacman package manager found")
}
if _, err := shared.RunCommand("which", "nix-store"); err == nil {
pkgManagers["nix"] = "if [ -e ~/.nix-profile ]; then nix-store -q --requisites /run/current-system ~/.nix-profile; else nix-store -q --requisites /run/current-system; fi"
log.Debug("nix package manager found")
}
for pkgManager, baseCmd := range pkgManagers {
// Use cache or get fresh data
cacheKey := "pkg_" + pkgManager
cached, ok := shared.GetCache(cacheKey)
if !ok {
var err error
cached, err = shared.RunCommand("sh", "-c", baseCmd)
if err != nil {
continue
}
shared.SetCache(cacheKey, cached, 10) // Cache for 10 seconds
}
if strings.Contains(cached, pkgName) {
return true
}
}
return false
}
func (pmc *PasswordManagerCheck) Passed() bool {
return pmc.passed
}
func (pmc *PasswordManagerCheck) IsRunnable() bool {
return true
}
func (pmc *PasswordManagerCheck) UUID() string {
return "f962c423-fdf5-428a-a57a-827abc9b253e"
}
func (pmc *PasswordManagerCheck) PassedMessage() string {
return "Password manager is present"
}
func (pmc *PasswordManagerCheck) FailedMessage() string {
return "No password manager found"
}
func (pmc *PasswordManagerCheck) RequiresRoot() bool {
return false
}
func (pmc *PasswordManagerCheck) Status() string {
if pmc.Passed() {
return pmc.PassedMessage()
}
return pmc.FailedMessage()
}
package checks
import (
"path/filepath"
"strings"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
)
// PasswordToUnlock represents a check to ensure that a password is required to unlock the screen.
type PasswordToUnlock struct {
passed bool
}
// Name returns the name of the check
func (f *PasswordToUnlock) Name() string {
return "Password is required to unlock the screen"
}
func (f *PasswordToUnlock) checkGnome() bool {
out, err := shared.RunCommand("gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled")
if err != nil {
log.WithError(err).Debug("Failed to check GNOME screensaver settings")
return false
}
result := strings.TrimSpace(string(out)) == "true"
log.WithField("setting", out).WithField("passed", result).Debug("GNOME screensaver lock check")
return result
}
func (f *PasswordToUnlock) checkKDE5() bool {
// First try reading config file directly
if homeDir, err := shared.UserHomeDir(); err == nil {
configPath := filepath.Join(homeDir, ".config", "kscreenlockerrc")
if content, err := shared.ReadFile(configPath); err == nil {
configStr := string(content)
// Check if LockOnResume=false is present
if strings.Contains(configStr, "LockOnResume=false") {
log.WithField("config_file", configPath).Debug("Found LockOnResume=false in KDE config")
return false
}
// If LockOnResume=true is explicitly set or not present (defaults to true)
log.WithField("config_file", configPath).Debug("KDE config allows screen locking")
return true
}
return true // Default to true if config file not found or read error, we trust that runtime settings are correct
}
return true // Default to true if config file not found or read error
}
// Run executes the check
func (f *PasswordToUnlock) Run() error {
// Check if running GNOME
if _, err := lookPath("gsettings"); err == nil {
f.passed = f.checkGnome()
} else {
log.Info("GNOME environment not detected for screensaver lock check")
}
// Check if running KDE
if _, err := lookPath("kreadconfig5"); err == nil {
f.passed = f.checkKDE5()
} else {
log.Debug("KDE environment(5) not detected for screensaver lock check")
}
return nil
}
// Passed returns the status of the check
func (f *PasswordToUnlock) Passed() bool {
return f.passed
}
// IsRunnable returns whether the check can run
func (f *PasswordToUnlock) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *PasswordToUnlock) UUID() string {
return "37dee029-605b-4aab-96b9-5438e5aa44d8"
}
// PassedMessage returns the message to return if the check passed
func (f *PasswordToUnlock) PassedMessage() string {
return "Password after sleep or screensaver is on"
}
// FailedMessage returns the message to return if the check failed
func (f *PasswordToUnlock) FailedMessage() string {
return "Password after sleep or screensaver is off"
}
// RequiresRoot returns whether the check requires root access
func (f *PasswordToUnlock) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *PasswordToUnlock) Status() string {
if f.Passed() {
return f.PassedMessage()
}
return f.FailedMessage()
}
package checks
import (
"fmt"
sharedchecks "github.com/ParetoSecurity/agent/checks/shared"
"github.com/caarlos0/log"
)
type Printer struct {
passed bool
ports map[int]string
}
// Name returns the name of the check
func (f *Printer) Name() string {
return "Sharing printers is off"
}
// Run executes the check
func (f *Printer) Run() error {
f.passed = true
f.ports = make(map[int]string)
// Samba, NFS and CUPS ports to check
printService := map[int]string{
631: "CUPS",
}
for port, service := range printService {
if sharedchecks.CheckPort(port, "tcp") {
log.WithField("check", f.Name()).WithField("port", port).WithField("service", service).Debug("Port open")
f.passed = false
f.ports[port] = service
}
}
return nil
}
// Passed returns the status of the check
func (f *Printer) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *Printer) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *Printer) UUID() string {
return "b96524e0-150b-4bb8-abc7-517051b6c14e"
}
// PassedMessage returns the message to return if the check passed
func (f *Printer) PassedMessage() string {
return "Sharing printers is off"
}
// FailedMessage returns the message to return if the check failed
func (f *Printer) FailedMessage() string {
return "Sharing printers is on"
}
// RequiresRoot returns whether the check requires root access
func (f *Printer) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *Printer) Status() string {
if !f.Passed() {
msg := "Printer sharing services found running on ports:"
for port, service := range f.ports {
msg += fmt.Sprintf(" %s(%d)", service, port)
}
return msg
}
return f.PassedMessage()
}
package checks
import "os"
// SecureBoot checks secure boot configuration.
type SecureBoot struct {
passed bool
status string
}
// Name returns the name of the check
func (f *SecureBoot) Name() string {
return "SecureBoot is enabled"
}
// Run executes the check
func (f *SecureBoot) Run() error {
if _, err := osStat("/sys/firmware/efi/efivars/"); err != nil && os.IsNotExist(err) {
f.passed = false
f.status = "System is not running in UEFI mode"
return nil
}
// Find and read the SecureBoot EFI variable
pattern := "/sys/firmware/efi/efivars/SecureBoot-*"
matches, err := filepathGlob(pattern)
if err != nil || len(matches) == 0 {
f.passed = false
f.status = "Could not find SecureBoot EFI variable"
return nil
}
data, err := osReadFile(matches[0])
if err != nil {
f.passed = false
f.status = "Could not read SecureBoot status"
return nil
}
// The SecureBoot variable has a 5-byte structure
// First 4 bytes are the attribute flags, last byte is the value
// Value of 1 means enabled, 0 means disabled
if len(data) >= 5 && data[4] == 1 {
f.passed = true
return nil
}
f.passed = false
return nil
}
// Passed returns the status of the check
func (f *SecureBoot) Passed() bool {
return f.passed
}
// IsRunnable returns whether SecureBoot is runnable.
func (f *SecureBoot) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *SecureBoot) UUID() string {
return "c96524f2-850b-4bb9-abc7-517051b6c14e"
}
// PassedMessage returns the message to return if the check passed
func (f *SecureBoot) PassedMessage() string {
return "SecureBoot is enabled"
}
// FailedMessage returns the message to return if the check failed
func (f *SecureBoot) FailedMessage() string {
return "SecureBoot is disabled"
}
// RequiresRoot returns whether the check requires root access
func (f *SecureBoot) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *SecureBoot) Status() string {
if f.Passed() {
return f.PassedMessage()
}
if f.status != "" {
return f.status
}
return f.FailedMessage()
}
package checks
import (
"fmt"
sharedchecks "github.com/ParetoSecurity/agent/checks/shared"
"github.com/caarlos0/log"
)
type Sharing struct {
passed bool
ports map[int]string
}
// Name returns the name of the check
func (f *Sharing) Name() string {
return "File Sharing is disabled"
}
// Run executes the check
func (f *Sharing) Run() error {
f.passed = true
f.ports = make(map[int]string)
// Samba and NFS ports to check
shareServices := map[int]string{
139: "NetBIOS",
445: "SMB",
2049: "NFS",
111: "RPC",
8200: "DLNA",
1900: "Ubuntu Media Sharing",
}
for port, service := range shareServices {
if sharedchecks.CheckPort(port, "tcp") {
f.passed = false
log.WithField("check", f.Name()).WithField("port:tcp", port).WithField("service", service).Debug("Port open")
f.ports[port] = service
}
}
return nil
}
// Passed returns the status of the check
func (f *Sharing) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *Sharing) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *Sharing) UUID() string {
return "b96524e0-850b-4bb8-abc7-517051b6c14e"
}
// PassedMessage returns the message to return if the check passed
func (f *Sharing) PassedMessage() string {
return "No file sharing services found running"
}
// FailedMessage returns the message to return if the check failed
func (f *Sharing) FailedMessage() string {
return "Sharing services found running "
}
// RequiresRoot returns whether the check requires root access
func (f *Sharing) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *Sharing) Status() string {
if !f.Passed() {
msg := "Sharing services found running on ports:"
for port, service := range f.ports {
msg += fmt.Sprintf(" %s(%d)", service, port)
}
return msg
}
return f.PassedMessage()
}
package shared
import (
"os"
"testing"
)
// checkPortMock is a mock function used for testing purposes. It simulates
// checking the availability of a port for a given protocol. The function
// takes an integer port number and a string representing the protocol
// (e.g., "tcp", "udp") as arguments, and returns a boolean indicating
// whether the port is available (true) or not (false).
var CheckPortMock func(port int, proto string) bool
var osReadFileMock func(file string) ([]byte, error)
// osReadFile reads the contents of the specified file.
//
// If the testing mode is enabled, it delegates the file reading to a mock function.
// Otherwise, it reads the file from disk using the standard os.ReadFile function.
func osReadFile(file string) ([]byte, error) {
if testing.Testing() {
return osReadFileMock(file)
}
return os.ReadFile(file)
}
package shared
import (
"context"
"fmt"
"runtime"
"slices"
"strings"
"time"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/carlmjohnson/requests"
"github.com/samber/lo"
)
type ParetoRelease struct {
Version string `json:"tag_name,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
Draft bool `json:"draft,omitempty"`
Prerelease bool `json:"prerelease,omitempty"`
}
type ParetoUpdated struct {
passed bool
details string
}
// Name returns the name of the check
func (f *ParetoUpdated) Name() string {
return "Pareto Security is up to date"
}
// Run executes the check
func (f *ParetoUpdated) Run() error {
f.passed = false
res := []ParetoRelease{}
device := shared.CurrentReportingDevice()
platform := "linux"
if runtime.GOOS == "darwin" {
platform = "macos"
}
if runtime.GOOS == "windows" {
platform = "windows"
}
// Create a context with a timeout for the request
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if shared.IsLinked() {
err := requests.URL("https://paretosecurity.com/api/updates").
Param("uuid", device.MachineUUID).
Param("version", shared.Version).
Param("os_version", device.OSVersion).
Param("platform", platform).
Param("app", "auditor").
Param("distribution", func() string {
if shared.IsLinked() {
return "app-live-team"
}
return "app-live-opensource"
}()).
Header("Accept", "application/vnd.github+json").
Header("X-GitHub-Api-Version", "2022-11-28").
Header("User-Agent", shared.UserAgent()).
ToJSON(&res).
Fetch(ctx)
if err != nil {
log.WithError(err).
Warnf("Failed to check for updates")
return err
}
latestVersion, latest := f.checkVersion(res)
f.passed = latest
f.details = fmt.Sprintf("Current version: %s, Latest version: %s", shared.Version, latestVersion)
return nil
}
err := requests.URL("https://api.github.com/repos/ParetoSecurity/agent/releases").
Header("Accept", "application/vnd.github+json").
Header("X-GitHub-Api-Version", "2022-11-28").
Header("User-Agent", shared.UserAgent()).
ToJSON(&res).
Fetch(ctx)
if err != nil {
log.WithError(err).
Warnf("Failed to check for updates")
return err
}
latestVersion, latest := f.checkVersion(res)
f.passed = latest
f.details = fmt.Sprintf("Current version: %s, Latest version: %s", shared.Version, latestVersion)
return nil
}
func (f *ParetoUpdated) checkVersion(res []ParetoRelease) (string, bool) {
// Sort releases by published date (newest first)
slices.SortFunc(res, func(a, b ParetoRelease) int {
return strings.Compare(b.PublishedAt.Format(time.RFC3339), a.PublishedAt.Format(time.RFC3339))
})
// Find the latest stable release
latestRelease, found := lo.Find(res, func(release ParetoRelease) bool {
return !release.Draft && !release.Prerelease
})
if !found {
return "Could not compare versions", false
}
// Only fail if latest release is older than 10 days and current version does not match
tenDaysAgo := time.Now().AddDate(0, 0, -10)
if latestRelease.PublishedAt.Before(tenDaysAgo) {
currentVersion := shared.Version
if strings.Contains(currentVersion, "-") {
// Strip any pre-release suffix for comparison
currentVersion = strings.Split(currentVersion, "-")[0]
}
if currentVersion != latestRelease.Version {
return latestRelease.Version, false
}
}
// Within 10 days grace period or version matches
return latestRelease.Version, true
}
// Passed returns the status of the check
func (f *ParetoUpdated) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *ParetoUpdated) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *ParetoUpdated) UUID() string {
return "44e4754a-0b42-4964-9cc2-b88b2023cb1e"
}
// PassedMessage returns the message to return if the check passed
func (f *ParetoUpdated) PassedMessage() string {
return "Pareto Security is up to date"
}
// FailedMessage returns the message to return if the check failed
func (f *ParetoUpdated) FailedMessage() string {
return "Pareto Security is outdated " + f.details
}
// RequiresRoot returns whether the check requires root access
func (f *ParetoUpdated) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *ParetoUpdated) Status() string {
if f.passed {
return f.PassedMessage()
}
return f.FailedMessage()
}
package shared
import (
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/caarlos0/log"
)
// checkPort tests if a port is open
func CheckPort(port int, proto string) bool {
if testing.Testing() {
return CheckPortMock(port, proto)
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
var wg sync.WaitGroup
resultCh := make(chan bool, len(addrs))
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
continue
}
// Filter out 127.0.0.1
if ip.IsLoopback() {
continue
}
wg.Add(1)
go func(ipAddr net.IP) {
defer wg.Done()
address := net.JoinHostPort(ipAddr.String(), fmt.Sprintf("%d", port))
conn, err := net.DialTimeout(proto, address, 1*time.Second)
if err == nil {
defer conn.Close()
log.WithField("address", address).WithField("state", true).Debug("Checking port")
resultCh <- true
}
}(ip)
}
// Wait in a separate goroutine
go func() {
wg.Wait()
close(resultCh)
}()
// Check if any connection succeeded
for result := range resultCh {
if result {
return true
}
}
return false
}
package shared
import (
"fmt"
"github.com/caarlos0/log"
)
type RemoteLogin struct {
passed bool
ports map[int]string
}
// Name returns the name of the check
func (f *RemoteLogin) Name() string {
return "Remote login is disabled"
}
// Run executes the check
func (f *RemoteLogin) Run() error {
f.passed = true
f.ports = make(map[int]string)
// Check common remote access ports
portsToCheck := map[int]string{
22: "SSH",
3389: "RDP",
3390: "RDP",
5900: "VNC",
}
for port, service := range portsToCheck {
if CheckPort(port, "tcp") {
log.WithField("check", f.Name()).WithField("port", port).WithField("service", service).Debug("Remote access service found")
f.passed = false
f.ports[port] = service
}
}
return nil
}
// Passed returns the status of the check
func (f *RemoteLogin) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *RemoteLogin) IsRunnable() bool {
return true
}
// UUID returns the UUID of the check
func (f *RemoteLogin) UUID() string {
return "4ced961d-7cfc-4e7b-8f80-195f6379446e"
}
// PassedMessage returns the message to return if the check passed
func (f *RemoteLogin) PassedMessage() string {
return "No remote access services found running"
}
// FailedMessage returns the message to return if the check failed
func (f *RemoteLogin) FailedMessage() string {
return "Remote access services found running"
}
// RequiresRoot returns whether the check requires root access
func (f *RemoteLogin) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *RemoteLogin) Status() string {
if !f.Passed() {
msg := "Remote access services found running on ports:"
for port, service := range f.ports {
msg += fmt.Sprintf(" %s(%d)", service, port)
}
return msg
}
return f.PassedMessage()
}
package shared
import (
"os"
"path/filepath"
"strings"
sharedG "github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"golang.org/x/crypto/ssh"
)
type SSHKeys struct {
passed bool
failedKeys []string
details string
}
// Name returns the name of the check
func (f *SSHKeys) Name() string {
return "SSH keys have password protection"
}
// checks if private key has password protection
func (f *SSHKeys) hasPassword(privateKeyPath string) bool {
keyBytes, err := sharedG.ReadFile(privateKeyPath)
if err != nil {
return true // assume secure if can't read
}
_, err = ssh.ParsePrivateKey(keyBytes)
return err != nil // if error occurs, key likely has password or it's FIDO2 managed key
}
// Run executes the check
func (f *SSHKeys) Run() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
sshDir := filepath.Join(home, ".ssh")
files, err := os.ReadDir(sshDir)
if err != nil {
f.passed = true
return nil
}
f.passed = true
for _, file := range files {
if strings.HasSuffix(file.Name(), ".pub") {
privateKeyPath := filepath.Join(sshDir, strings.TrimSuffix(file.Name(), ".pub"))
if _, err := os.Stat(privateKeyPath); err == nil {
if !f.hasPassword(privateKeyPath) {
f.passed = false
f.failedKeys = append(f.failedKeys, file.Name())
}
}
}
}
return nil
}
// Passed returns the status of the check
func (f *SSHKeys) Passed() bool {
return f.passed
}
// CanRun returns whether the check can run
func (f *SSHKeys) IsRunnable() bool {
f.details = "No private keys found in .ssh directory"
home, err := os.UserHomeDir()
if err != nil {
return false
}
sshPath := filepath.Join(home, ".ssh")
if _, err := os.Stat(sshPath); os.IsNotExist(err) {
return false
}
//check if there are any private keys in the .ssh directory
files, err := os.ReadDir(sshPath)
if err != nil {
return false
}
for _, file := range files {
if strings.HasSuffix(file.Name(), ".pub") {
privateKeyPath := filepath.Join(sshPath, strings.TrimSuffix(file.Name(), ".pub"))
if _, err := os.Stat(privateKeyPath); err == nil {
f.details = "Found private key: " + file.Name()
log.WithField("file", file.Name()).Debug("Found private key")
return true
}
}
}
return false
}
// UUID returns the UUID of the check
func (f *SSHKeys) UUID() string {
return "b6aaec0f-d76c-429e-aecf-edab7f1ac400"
}
// PassedMessage returns the message to return if the check passed
func (f *SSHKeys) PassedMessage() string {
return "SSH keys are password protected"
}
// FailedMessage returns the message to return if the check failed
func (f *SSHKeys) FailedMessage() string {
return "SSH keys are not using password"
}
// RequiresRoot returns whether the check requires root access
func (f *SSHKeys) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *SSHKeys) Status() string {
if f.Passed() {
return f.PassedMessage()
}
if f.details != "" {
return f.details
}
return "Found unprotected SSH key(s): " + strings.Join(f.failedKeys, ", ")
}
// Package shared provides SSH key algo utilities.
package shared
import (
"crypto/rsa"
"os"
"path/filepath"
"strings"
"github.com/caarlos0/log"
"golang.org/x/crypto/ssh" // Import the crypto/ssh package
)
// SSHKeysAlgo runs the SSH keys algorithm.
type SSHKeysAlgo struct {
passed bool
sshKey string
sshPath string
details string
}
// Name returns the name of the check
func (f *SSHKeysAlgo) Name() string {
return "SSH keys have sufficient algorithm strength"
}
func (f *SSHKeysAlgo) isKeyStrong(path string) bool {
keyBytes, err := osReadFile(path)
if err != nil {
return false
}
key, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes)
if err != nil {
log.WithError(err).Warn("Failed to parse public key")
return false
}
switch key.Type() {
case "ssh-rsa":
rsaKey, ok := key.(ssh.CryptoPublicKey).CryptoPublicKey().(*rsa.PublicKey)
if !ok {
return false
}
return rsaKey.N.BitLen() >= 2048
case "ssh-dss":
return false // DSS is considered weak
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
return true // ECDSA is considered strong enough
case "ssh-ed25519", "sk-ssh-ed25519@openssh.com":
return true // Ed25519 is considered strong
default:
log.WithField("keyType", key.Type()).Warn("Unknown key type")
return false
}
}
// Run executes the check
func (f *SSHKeysAlgo) Run() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
f.sshPath = filepath.Join(home, ".ssh")
entries, err := os.ReadDir(f.sshPath)
if err != nil {
return err
}
f.passed = true
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".pub") {
// Skip non-public key files
continue
}
pubPath := filepath.Join(f.sshPath, entry.Name())
privPath := strings.TrimSuffix(pubPath, ".pub")
if _, err := os.Stat(privPath); os.IsNotExist(err) {
// Skip if the corresponding private key does not exist
continue
}
if !f.isKeyStrong(pubPath) {
log.WithField("file", entry.Name()).Warn("Weak SSH key algorithm detected")
f.passed = false
f.sshKey = strings.TrimSuffix(entry.Name(), ".pub")
break
}
}
return nil
}
// Passed returns the status of the check
func (f *SSHKeysAlgo) Passed() bool {
return f.passed
}
// IsRunnable returns whether SSHKeysAlgo is runnable.
func (f *SSHKeysAlgo) IsRunnable() bool {
f.details = "No private keys found in the .ssh directory"
// Check if the user home directory exists
home, err := os.UserHomeDir()
if err != nil {
return false
}
// Check if the .ssh directory exists
sshPath := filepath.Join(home, ".ssh")
if _, err := os.Stat(sshPath); os.IsNotExist(err) {
return false
}
//check if there are any private keys in the .ssh directory
files, err := os.ReadDir(sshPath)
if err != nil {
return false
}
// Check if there are any private keys in the .ssh directory
for _, file := range files {
if strings.HasSuffix(file.Name(), ".pub") {
privateKeyPath := filepath.Join(sshPath, strings.TrimSuffix(file.Name(), ".pub"))
if _, err := os.Stat(privateKeyPath); err == nil {
log.WithField("file", file.Name()).Debug("Found private key")
f.details = "Found private key: " + file.Name()
return true
}
}
}
return false
}
// UUID returns the UUID of the check
func (f *SSHKeysAlgo) UUID() string {
return "ef69f752-0e89-46e2-a644-310429ae5f45"
}
// PassedMessage returns the message to return if the check passed
func (f *SSHKeysAlgo) PassedMessage() string {
return "SSH keys use strong encryption"
}
// FailedMessage returns the message to return if the check failed
func (f *SSHKeysAlgo) FailedMessage() string {
return "SSH keys are using weak encryption"
}
// RequiresRoot returns whether the check requires root access
func (f *SSHKeysAlgo) RequiresRoot() bool {
return false
}
// Status returns the status of the check
func (f *SSHKeysAlgo) Status() string {
if f.Passed() {
return f.PassedMessage()
}
if f.details != "" {
return f.details
}
return "SSH key " + f.sshKey + " is using weak encryption"
}
package checks
import (
"encoding/json"
"fmt"
"time"
"github.com/ParetoSecurity/agent/shared"
)
type AutomaticUpdatesCheck struct {
passed bool
status string
}
type autoUpdateSettings struct {
NotificationLevel int `json:"NotificationLevel"`
}
func (a *AutomaticUpdatesCheck) Name() string {
return "Automatic Updates are enabled"
}
func (a *AutomaticUpdatesCheck) Run() error {
out, err := shared.RunCommand("powershell", "-Command", "(New-Object -ComObject Microsoft.Update.AutoUpdate).Settings | ConvertTo-Json")
if err != nil {
a.passed = false
a.status = "Failed to query update settings"
return nil
}
var settings autoUpdateSettings
if err := json.Unmarshal([]byte(out), &settings); err != nil {
a.passed = false
a.status = "Failed to parse update settings"
return nil
}
// NotificationLevel 1 = Never check for updates, 2 = Notify before download, 3 = Notify before install, 4 = Scheduled install
if settings.NotificationLevel == 1 {
a.status = "Automatic Updates are disabled"
a.passed = false
return nil
}
// Check if updates are paused
psCmd := `try { Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -Name "PauseUpdatesExpiryTime" } catch { 0 }`
pauseOut, pauseErr := shared.RunCommand("powershell", "-Command", psCmd)
if pauseErr == nil && len(pauseOut) > 0 {
// Parse output as int64 (epoch seconds)
var expiry int64
_, scanErr := fmt.Sscanf(pauseOut, "%d", &expiry)
if scanErr == nil && expiry > 0 {
now := time.Now().Unix()
if expiry > now {
a.passed = false
a.status = "Updates are paused"
return nil
}
}
}
a.passed = true
return nil
}
func (a *AutomaticUpdatesCheck) Passed() bool {
return a.passed
}
func (a *AutomaticUpdatesCheck) IsRunnable() bool {
return true
}
func (a *AutomaticUpdatesCheck) UUID() string {
return "28d98536-a93a-4092-845a-92ec081cc82a"
}
func (a *AutomaticUpdatesCheck) PassedMessage() string {
return "Automatic Updates are on"
}
func (a *AutomaticUpdatesCheck) FailedMessage() string {
return "Automatic Updates are off/paused"
}
func (a *AutomaticUpdatesCheck) RequiresRoot() bool {
return false
}
func (a *AutomaticUpdatesCheck) Status() string {
if a.Passed() {
return a.PassedMessage()
}
if a.status != "" {
return a.status
}
return a.FailedMessage()
}
package checks
import (
"encoding/json"
"strings"
"github.com/ParetoSecurity/agent/shared"
)
type WindowsDefender struct {
passed bool
status string
}
type mpStatus struct {
RealTimeProtectionEnabled bool
IoavProtectionEnabled bool
AntispywareEnabled bool
}
func (d *WindowsDefender) Name() string {
return "Windows Defender is enabled"
}
func (d *WindowsDefender) Run() error {
out, err := shared.RunCommand("powershell", "-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json")
if err != nil {
d.passed = false
d.status = "Failed to query Defender status"
return nil
}
// Remove BOM if present
outStr := strings.TrimPrefix(string(out), "\xef\xbb\xbf")
var status mpStatus
if err := json.Unmarshal([]byte(outStr), &status); err != nil {
d.passed = false
d.status = "Failed to parse Defender status"
return nil
}
if status.RealTimeProtectionEnabled && status.IoavProtectionEnabled && status.AntispywareEnabled {
d.passed = true
d.status = ""
} else {
d.passed = false
// Compose a status message with details
if !status.RealTimeProtectionEnabled {
d.status = "Defender has disabled real-time protection"
return nil
}
if !status.IoavProtectionEnabled {
d.status = "Defender has disabled tamper protection"
return nil
}
if !status.AntispywareEnabled {
d.status = "Defender is disabled"
return nil
}
}
return nil
}
func (d *WindowsDefender) Passed() bool {
return d.passed
}
func (d *WindowsDefender) IsRunnable() bool {
return true
}
func (d *WindowsDefender) UUID() string {
return "2be03cd7-5cb5-4778-a01a-7ba2fb22750a"
}
func (d *WindowsDefender) PassedMessage() string {
return "Microsoft Defender is on"
}
func (d *WindowsDefender) FailedMessage() string {
return "Microsoft Defender is off"
}
func (d *WindowsDefender) RequiresRoot() bool {
return false
}
func (d *WindowsDefender) Status() string {
if d.Passed() {
return d.PassedMessage()
}
if d.status != "" {
return d.status
}
return d.FailedMessage()
}
package checks
import (
"strings"
"github.com/ParetoSecurity/agent/shared"
)
type WindowsFirewall struct {
passed bool
status string
}
func (f *WindowsFirewall) checkFirewallProfile(profile string) bool {
out, err := shared.RunCommand("powershell", "-Command", "Get-NetFirewallProfile -Name '"+profile+"' | Select-Object -ExpandProperty Enabled")
if err != nil {
f.status = "Failed to query Windows Firewall for " + profile + " profile"
return false
}
enabled := strings.TrimSpace(string(out))
if enabled == "True" {
return true
}
f.status = "Windows Firewall is not enabled for " + profile + " profile"
return false
}
func (f *WindowsFirewall) Name() string {
return "Windows Firewall is enabled"
}
func (f *WindowsFirewall) Run() error {
f.passed = f.checkFirewallProfile("Public") && f.checkFirewallProfile("Private")
return nil
}
func (f *WindowsFirewall) Passed() bool {
return f.passed
}
func (f *WindowsFirewall) IsRunnable() bool {
return true
}
func (f *WindowsFirewall) UUID() string {
return "e632fdd2-b939-4aeb-9a3e-5df2d67d3110"
}
func (f *WindowsFirewall) PassedMessage() string {
return "Windows Firewall is on"
}
func (f *WindowsFirewall) FailedMessage() string {
return "Windows Firewall is off"
}
func (f *WindowsFirewall) RequiresRoot() bool {
return false
}
func (f *WindowsFirewall) Status() string {
if f.Passed() {
return f.PassedMessage()
}
if f.status != "" {
return f.status
}
return f.FailedMessage()
}
package checks
import (
"os"
"testing"
)
var osStatMock map[string]bool
// osStat checks if a file exists by attempting to get its file info.
// During testing, it uses a mock implementation via osStatMock.
// It returns the file path if the file exists, otherwise returns an empty string and error.
func osStat(file string) (string, error) {
if testing.Testing() {
if found := osStatMock[file]; found {
return file, nil
}
return "", os.ErrNotExist
}
_, err := os.Stat(file)
if err != nil {
return "", err
}
return file, nil
}
package checks
import (
"os"
"path/filepath"
"strings"
)
type PasswordManagerCheck struct {
passed bool
}
func (pmc *PasswordManagerCheck) Name() string {
return "Password Manager Presence"
}
func (pmc *PasswordManagerCheck) Run() error {
// TODO; need real paths
userProfile := os.Getenv("USERPROFILE")
paths := []string{
filepath.Join(userProfile, "AppData", "Local", "1Password", "app", "8", "1Password.exe"),
filepath.Join(userProfile, "AppData", "Local", "Programs", "Bitwarden", "Bitwarden.exe"),
filepath.Join(os.Getenv("PROGRAMFILES"), "KeePass Password Safe 2", "KeePass.exe"),
filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "KeePass Password Safe 2", "KeePass.exe"),
filepath.Join(os.Getenv("PROGRAMFILES"), "KeePassXC", "KeePassXC.exe"),
filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "KeePassXC", "KeePassXC.exe"),
}
for _, path := range paths {
if _, err := osStat(path); err == nil {
pmc.passed = true
return nil
}
}
pmc.passed = checkForBrowserExtensions()
return nil
}
func checkForBrowserExtensions() bool {
home := os.Getenv("USERPROFILE")
extensionPaths := map[string]string{
"Google Chrome": filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Extensions"),
"Firefox": filepath.Join(home, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles"),
"Microsoft Edge": filepath.Join(home, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default", "Extensions"),
"Brave Browser": filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Extensions"),
}
browserExtensions := []string{
"LastPass",
"ProtonPass",
"NordPass",
"Bitwarden",
"1Password",
"KeePass",
"Dashlane",
}
for _, extPath := range extensionPaths {
if _, err := os.Stat(extPath); err == nil {
entries, err := os.ReadDir(extPath)
if err == nil {
for _, entry := range entries {
name := strings.ToLower(entry.Name())
for _, ext := range browserExtensions {
if strings.Contains(name, strings.ToLower(ext)) {
return true
}
}
}
}
}
}
return false
}
func (pmc *PasswordManagerCheck) Passed() bool {
return pmc.passed
}
func (pmc *PasswordManagerCheck) IsRunnable() bool {
return true
}
func (pmc *PasswordManagerCheck) UUID() string {
return "f962c423-fdf5-428a-a57a-827abc9b253e"
}
func (pmc *PasswordManagerCheck) PassedMessage() string {
return "Password manager is present"
}
func (pmc *PasswordManagerCheck) FailedMessage() string {
return "No password manager found"
}
func (pmc *PasswordManagerCheck) RequiresRoot() bool {
return false
}
func (pmc *PasswordManagerCheck) Status() string {
if pmc.Passed() {
return pmc.PassedMessage()
}
return pmc.FailedMessage()
}
package cmd
import (
"context"
"time"
"github.com/ParetoSecurity/agent/claims"
"github.com/ParetoSecurity/agent/runner"
shared "github.com/ParetoSecurity/agent/shared"
team "github.com/ParetoSecurity/agent/team"
"github.com/caarlos0/log"
"github.com/spf13/cobra"
)
var checkCmd = &cobra.Command{
Use: "check [--skip <uuid>] [--only <uuid>]",
Short: "Run checks on your system",
Run: func(cc *cobra.Command, args []string) {
skipUUIDs, _ := cc.Flags().GetStringArray("skip")
onlyUUID, _ := cc.Flags().GetString("only")
checkCommand(skipUUIDs, onlyUUID)
},
}
func init() {
rootCmd.AddCommand(checkCmd)
checkCmd.Flags().StringArray("skip", []string{}, "skip checks by UUID")
checkCmd.Flags().String("only", "", "only run checks by UUID")
}
func checkCommand(skipUUIDs []string, onlyUUID string) {
if shared.IsRoot() {
log.Warn("Please run this command as a normal user, as it won't report all checks correctly.")
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
done := make(chan struct{})
go func() {
runner.Check(ctx, claims.All, skipUUIDs, onlyUUID)
close(done)
}()
select {
case <-done:
if shared.IsLinked() {
err := team.ReportToTeam(false)
if err != nil {
log.WithError(err).Warn("failed to report to team")
}
}
// if checks failed, exit with a non-zero status code
if !shared.AllChecksPassed() {
// Log the failed checks
if failedChecks := shared.GetFailedChecks(); len(failedChecks) > 0 && verbose {
for _, check := range failedChecks {
log.Errorf("Failed check: %s (UUID: %s)", check.Name, check.UUID)
}
}
log.Fatal("You can use `paretosecurity check --verbose` to get a detailed report.")
}
case <-ctx.Done():
log.Fatal("Check run timed out")
}
}
package cmd
import (
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configure application settings",
Long: "Configure application settings, such as enabling or disabling specific checks.",
}
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset configuration settings",
Long: "Reset configuration settings to their default values.",
Run: func(cmd *cobra.Command, args []string) {
shared.ResetConfig()
log.WithField("config", shared.ConfigPath).Info("Configuration reset to default values.")
},
}
var enableCmd = &cobra.Command{
Use: "enable [check UUID]",
Short: "Enable a specific check",
Long: "Enable a specific check by providing its id.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
check := args[0]
err := shared.EnableCheck(check)
if err != nil {
log.WithError(err).Fatalf("Failed to enable check: %s", check)
} else {
log.WithField("check", check).Info("Check enabled successfully.")
}
},
}
var disableCmd = &cobra.Command{
Use: "disable [check UUID]",
Short: "Disable a specific check",
Long: "Disable a specific check by providing its id.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
check := args[0]
err := shared.DisableCheck(check)
if err != nil {
log.WithError(err).Fatalf("Failed to disable check: %s", check)
} else {
log.WithField("check", check).Info("Check disabled successfully.")
}
},
}
func init() {
rootCmd.AddCommand(configCmd)
configCmd.AddCommand(resetCmd)
configCmd.AddCommand(enableCmd)
configCmd.AddCommand(disableCmd)
}
package cmd
import (
"net"
"os"
"github.com/ParetoSecurity/agent/runner"
shared "github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/samber/lo"
"github.com/spf13/cobra"
)
// runHelperServer listens on a socket (passed via file descriptor 0) and handles incoming connections.
// It's designed to be run in a systemd context where systemd provides the socket.
// The server accepts a single connection, handles it using runner.HandleConnection, and then exits.
// It logs the socket path and version information upon startup and logs any errors encountered during socket creation or connection acceptance.
func runHelperServer() {
// Get the socket from file descriptor 0
file := os.NewFile(0, "socket")
listener, err := net.FileListener(file)
if err != nil {
log.WithError(err).Fatal("Failed to create listener, not running in systemd context")
}
defer listener.Close()
log.WithField("socket", runner.SocketPath).WithField("version", shared.Version).Info("Listening on socket")
for {
conn, err := listener.Accept()
if err != nil {
log.WithError(err).Warn("Failed to accept connection")
continue
}
runner.HandleConnection(conn)
break
}
}
var helperCmd = &cobra.Command{
Use: "helper [--socket]",
Short: "A root helper",
Long: `A root helper that listens on a Unix domain socket and responds to authenticated requests.`,
Run: func(cmd *cobra.Command, args []string) {
socketFlag, _ := cmd.Flags().GetString("socket")
if lo.IsNotEmpty(socketFlag) {
runner.SocketPath = socketFlag
}
runHelperServer()
},
}
func init() {
rootCmd.AddCommand(helperCmd)
helperCmd.Flags().Bool("socket", false, "socket path")
}
package cmd
import (
"encoding/json"
"runtime"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/elastic/go-sysinfo"
"github.com/spf13/cobra"
)
var infoCmd = &cobra.Command{
Use: "info",
Short: "Print the system information",
Run: func(cmd *cobra.Command, args []string) {
log.Infof("%s@%s %s", shared.Version, shared.Commit, shared.Date)
log.Infof("Built with %s", runtime.Version())
log.Infof("Team: %s\n", shared.Config.TeamID)
device := shared.CurrentReportingDevice()
jsonOutput, err := json.MarshalIndent(device, "", " ")
if err != nil {
log.Warn("Failed to marshal host info")
}
log.Infof("Device Info: %s\n", string(jsonOutput))
hostInfo, err := sysinfo.Host()
if err != nil {
log.Warn("Failed to get process information")
}
envInfo := hostInfo.Info()
envInfo.IPs = []string{} // Exclude IPs for privacy
envInfo.MACs = []string{} // Exclude MACs for privacy
jsonOutput, err = json.MarshalIndent(envInfo, "", " ")
if err != nil {
log.Warn("Failed to marshal host info")
}
log.Infof("Host Info: %s\n", string(jsonOutput))
},
}
func init() {
rootCmd.AddCommand(infoCmd)
}
package cmd
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/ParetoSecurity/agent/notify"
shared "github.com/ParetoSecurity/agent/shared"
"github.com/ParetoSecurity/agent/team"
"github.com/caarlos0/log"
"github.com/golang-jwt/jwt/v5"
"github.com/samber/lo"
"github.com/spf13/cobra"
)
var rsaPublicKey = `
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwGh64DK49GOq1KX+ojyg
Y9JSAZ4cfm5apavetQ42D2gTjfhDu1kivrDRwhjqj7huUWRI2ExMdMHp8CzrJI3P
zpzutEUXTEHloe0vVMZqPoP/r2f1cl4bmDkFZyHr6XTgiYPE4GgMjxUc04J2ksqU
/XbNwOVsBiuy1T2BduLYiYr1UyIx8VqEb+3tunQKlyRKF7a5LoEZatt5F/5vaMMI
4zp1yIc2PMoBdlBH4/tpJmC/PiwjBuwgp5gMIle4Hy7zwW4+rIJzF5P3Tg+Am+Lg
davB8TIZDBlqIWV7zK1kWBPj364a5cnaUP90BnOriMJBh7zPG0FNGTXTiJED2qDM
fajDrji3oAPO24mJsCCzSd8LIREK5c6iAf1X4UI/UFP+UhOBCsANrhNSXRpO2KyM
+60JYzFpMvyhdK9zMo7Tc+KM6R0YRNmBCYK/ePAGk3WU6qxN5+OmSjdTvFrqC4JQ
FyK51WJI80PKvp3B7ZB7XpH5B24wr/OhMRh5YZOcrpuBykfHaMozkDCudgaj/V+x
K79CqMF/BcSxCSBktWQmabYCM164utpmJaCSpZyDtKA4bYVv9iRCGTqFQT7jX+/h
Z37gmg/+TlIdTAeB5TG2ffHxLnRhT4AAhUgYmk+QP3a1hxP5xj2otaSTZ3DxQd6F
ZaoGJg3y8zjrxYBQDC8gF6sCAwEAAQ==
`
type InviteClaims struct {
TeamAuth string `json:"token"`
TeamUUID string `json:"teamID"`
jwt.RegisteredClaims
}
var linkCmd = &cobra.Command{
Use: "link <url>",
Short: "Link this device to a team",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if shared.IsRoot() {
return fmt.Errorf("please run this command as a normal user")
}
err := runLinkCommand(args[0])
if err != nil {
log.WithError(err).Error("Failed to link team")
notify.Toast("Failed to add device to the team!")
return err
}
notify.Toast("Device successfully linked to the team!")
return nil
},
}
func runLinkCommand(teamURL string) error {
if lo.IsEmpty(teamURL) {
log.Warn("Please provide a team URL")
return errors.New("no team URL provided")
}
if strings.Contains(teamURL, "https://") {
return errors.New("team URL should not contain the protocol")
}
if shared.IsLinked() {
log.Warn("Already linked to a team")
log.Warn("Unlink first with `paretosecurity unlink`")
log.Infof("Team ID: %s", shared.Config.TeamID)
return errors.New("already linked to a team")
}
if lo.IsNotEmpty(teamURL) {
token, err := getTokenFromURL(teamURL)
if err != nil {
log.WithError(err).Warn("failed to get token from URL")
return err
}
parsedToken, err := parseJWT(token)
if err != nil {
log.WithError(err).Warn("failed to parse JWT")
return err
}
shared.Config.TeamID = parsedToken.TeamUUID
shared.Config.AuthToken = parsedToken.TeamAuth
err = team.ReportToTeam(true)
if err != nil {
log.WithError(err).Warn("failed to link to team")
return err
}
err = shared.SaveConfig()
if err != nil {
log.Errorf("Error saving config: %v", err)
return err
}
// Report to team
if shared.IsLinked() {
err := team.ReportToTeam(false)
if err != nil {
log.WithError(err).Warn("failed to report to team")
}
}
log.Infof("Device successfully linked to team: %s", parsedToken.TeamUUID)
}
return nil
}
func getTokenFromURL(teamURL string) (string, error) {
parsedURL, err := url.Parse(teamURL)
if err != nil {
return "", err
}
queryParams := parsedURL.Query()
token := queryParams.Get("token")
if token == "" {
return "", fmt.Errorf("token not found in URL")
}
return token, nil
}
func parseJWT(token string) (*InviteClaims, error) {
jwttToken, _ := jwt.ParseWithClaims(token, &InviteClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(strings.ReplaceAll(rsaPublicKey, "\n", "")), nil
})
if claims, ok := jwttToken.Claims.(*InviteClaims); ok {
return claims, nil
}
return nil, fmt.Errorf("failed to parse JWT")
}
func init() {
rootCmd.AddCommand(linkCmd)
}
// Package main provides the entry point for the application.
package main
import (
"github.com/ParetoSecurity/agent/cmd"
shared "github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
)
func main() {
if err := shared.LoadConfig(); err != nil {
if !shared.IsRoot() {
log.WithError(err).Warn("failed to load config")
}
}
cmd.Execute()
}
package cmd
import (
shared "github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/spf13/cobra"
)
var verbose bool
var rootCmd = &cobra.Command{
Use: "paretosecurity --help --version [command]",
Short: "Pareto Security CLI",
Version: shared.Version,
Long: `Pareto Security CLI is a tool for running and reporting audits to paretosecurity.com.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if verbose {
log.SetLevel(log.DebugLevel)
}
},
}
func init() {
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "output verbose logs")
}
func Execute() {
if rootCmd.Execute() != nil {
log.Fatal("Failed to execute command")
}
}
package cmd
import (
"github.com/ParetoSecurity/agent/claims"
"github.com/ParetoSecurity/agent/runner"
"github.com/spf13/cobra"
)
var schemaCmd = &cobra.Command{
Use: "schema",
Short: "Output schema for all checks",
Long: "Output schema for all checks in JSON format.",
Run: func(cc *cobra.Command, args []string) {
runner.PrintSchemaJSON(claims.All)
},
}
func init() {
rootCmd.AddCommand(schemaCmd)
}
package cmd
import (
"github.com/ParetoSecurity/agent/shared"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Print the status of the checks",
Run: func(cmd *cobra.Command, args []string) {
shared.PrintStates()
},
}
func init() {
rootCmd.AddCommand(statusCmd)
}
//go:build linux || darwin
// +build linux darwin
package cmd
import (
"fyne.io/systray"
"github.com/ParetoSecurity/agent/trayapp"
"github.com/caarlos0/log"
"github.com/spf13/cobra"
)
var trayiconCmd = &cobra.Command{
Use: "trayicon",
Short: "Display the status of the checks in the system tray",
Run: func(cc *cobra.Command, args []string) {
onExit := func() {
log.Info("Exiting...")
}
systray.Run(trayapp.OnReady, onExit)
},
}
func init() {
rootCmd.AddCommand(trayiconCmd)
}
package cmd
import (
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/spf13/cobra"
)
var unlinkCmd = &cobra.Command{
Use: "unlink",
Short: "Unlink this device from the team",
Run: func(cc *cobra.Command, args []string) {
log.Info("Unlinking device ...")
shared.Config.TeamID = ""
shared.Config.AuthToken = ""
if err := shared.SaveConfig(); err != nil {
log.WithError(err).Fatal("failed to save config")
}
},
}
func init() {
rootCmd.AddCommand(unlinkCmd)
}
package notify
import (
"github.com/caarlos0/log"
"github.com/godbus/dbus/v5"
)
// Toast sends a persistent desktop notification using D-Bus on Linux systems.
// It displays a notification with the title "Pareto Security" and the provided body text.
// The notification is configured to be resident (persistent) and will expire after 10 seconds.
func Toast(body string) {
conn, err := dbus.SessionBus()
if err != nil {
log.WithError(err).Error("failed to connect to session bus")
return
}
defer conn.Close()
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
call := obj.Call("org.freedesktop.Notifications.Notify", 0,
"ParetoSecurity", // app_name
uint32(0), // replaces_id
"dialog-information", // app_icon
"Pareto Security", // summary
body, // body
[]string{}, // actions
map[string]dbus.Variant{
"resident": dbus.MakeVariant(true), // keeps notification persistent
},
int32(10000), // expire_timeout (0 = no expiration)
)
if call.Err != nil {
log.WithError(call.Err).Error("failed to send notification")
}
}
package runner
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"github.com/fatih/color"
"github.com/ParetoSecurity/agent/check"
"github.com/ParetoSecurity/agent/claims"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/samber/lo"
)
// wrapStatusRoot formats the check status with color-coded indicators.
func wrapStatusRoot(status *CheckStatus, chk check.Check, err error) string {
if err != nil {
return fmt.Sprintf("%s %s", color.RedString("[ERROR]"), err.Error())
}
msg := status.Details
if lo.IsEmpty(msg) {
msg = chk.Status()
}
if status.Passed {
return fmt.Sprintf("%s %s", color.GreenString("[OK]"), msg)
}
return fmt.Sprintf("%s %s", color.RedString("[FAIL]"), msg)
}
// wrapStatus formats the check status with color-coded indicators.
func wrapStatus(chk check.Check, err error) string {
if err != nil {
return fmt.Sprintf("%s %s", color.RedString("[ERROR]"), err.Error())
}
if chk.Passed() {
return fmt.Sprintf("%s %s", color.GreenString("[OK]"), chk.Status())
}
return fmt.Sprintf("%s %s", color.RedString("[FAIL]"), chk.Status())
}
// Check runs a series of checks concurrently for a list of claims.
//
// It iterates over each claim provided in claimsTorun and, for each claim,
// over its associated checks. Each check is executed in its own goroutine.
func Check(ctx context.Context, claimsTorun []claims.Claim, skipUUIDs []string, onlyUUID string) {
var checkLogger = log.New(os.Stdout)
var wg sync.WaitGroup
checkLogger.Info("Starting checks...")
for _, claim := range claimsTorun {
for _, chk := range claim.Checks {
// Skip checks that are skipped
if lo.Contains(skipUUIDs, chk.UUID()) {
checkLogger.Warn(fmt.Sprintf("%s: %s > %s", claim.Title, chk.Name(), fmt.Sprintf("%s Skipped by the command rule", color.YellowString("[SKIP]"))))
continue
}
wg.Add(1)
go func(claim claims.Claim, chk check.Check) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
// Skip checks that are not in the onlyUUID list
if onlyUUID != "" && onlyUUID != chk.UUID() {
checkLogger.Debug(fmt.Sprintf("%s: %s > %s", claim.Title, chk.Name(), fmt.Sprintf("%s Skipped by the command rule", color.YellowString("[SKIP]"))))
return
}
// Skip checks that are not runnable or are disabled
if !chk.IsRunnable() || shared.IsCheckDisabled(chk.UUID()) {
reason := chk.Status()
if shared.IsCheckDisabled(chk.UUID()) {
reason = "Disabled by the config file"
}
checkLogger.Warn(fmt.Sprintf("%s: %s > %s %s", claim.Title, chk.Name(), color.YellowString("[DISABLED]"), reason))
return
}
hasError := false
if chk.RequiresRoot() {
log.Debug("Running check via root helper")
// Run as root
status, err := RunCheckViaRoot(chk.UUID())
if err != nil {
hasError = true
checkLogger.Info(fmt.Sprintf("[root] %s: %s > %s", claim.Title, chk.Name(), wrapStatusRoot(status, chk, err)))
} else {
if status.Passed {
checkLogger.Info(fmt.Sprintf("[root] %s: %s > %s", claim.Title, chk.Name(), wrapStatusRoot(status, chk, err)))
} else {
checkLogger.Warn(fmt.Sprintf("[root] %s: %s > %s", claim.Title, chk.Name(), wrapStatusRoot(status, chk, err)))
}
}
shared.UpdateLastState(shared.LastState{
UUID: chk.UUID(),
Name: chk.Name(),
Passed: status.Passed,
HasError: hasError,
Details: status.Details,
})
} else {
if err := chk.Run(); err != nil {
hasError = true
checkLogger.Info(fmt.Sprintf("%s: %s > %s", claim.Title, chk.Name(), wrapStatus(chk, err)))
} else {
if chk.Passed() {
checkLogger.Info(fmt.Sprintf("%s: %s > %s", claim.Title, chk.Name(), wrapStatus(chk, err)))
} else {
checkLogger.Warn(fmt.Sprintf("%s: %s > %s", claim.Title, chk.Name(), wrapStatus(chk, err)))
}
}
shared.UpdateLastState(shared.LastState{
UUID: chk.UUID(),
Name: chk.Name(),
Passed: chk.Passed(),
HasError: hasError,
Details: chk.Status(),
})
}
}
}(claim, chk)
}
}
wg.Wait()
if err := shared.CommitLastState(); err != nil {
log.WithError(err).Warn("failed to commit last state")
}
checkLogger.Info("Checks completed.")
}
// PrintSchemaJSON constructs and prints a JSON schema generated from a slice of claims.
// For each claim, the function builds a nested map where the claim's title is the key and its
// value is another map. This inner map associates each check's UUID with a slice that contains
// the check's passed message and failed message.
// The resulting schema is marshalled into an indented JSON string and printed to standard output.
// In case of an error during marshalling, the function logs a warning with the error details.
func PrintSchemaJSON(claimsTorun []claims.Claim) {
schema := make(map[string]map[string][]string)
for _, claim := range claimsTorun {
checks := make(map[string][]string)
for _, chk := range claim.Checks {
checks[chk.UUID()] = []string{chk.PassedMessage(), chk.FailedMessage()}
}
schema[claim.Title] = checks
}
out, err := json.MarshalIndent(schema, "", " ")
if err != nil {
log.WithError(err).Warn("cannot marshal schema")
}
fmt.Println(string(out))
}
package runner
import (
"encoding/json"
"errors"
"net"
"github.com/ParetoSecurity/agent/claims"
"github.com/caarlos0/log"
)
type CheckStatus struct {
UUID string `json:"uuid"`
Passed bool `json:"passed"`
Details string `json:"details"`
}
// handleConnection handles an incoming network connection.
// It reads input from the connection, processes the input to run checks,
// and sends back the status of the checks as a JSON response.
//
// The input is expected to be a JSON object containing a "uuid" key.
// The function will look for checks that are runnable, require root,
// and match the provided UUID. It will run those checks and collect their status.
func HandleConnection(conn net.Conn) {
defer conn.Close()
log.Info("Connection received")
// Read input from connection
decoder := json.NewDecoder(conn)
var input map[string]string
if err := decoder.Decode(&input); err != nil {
log.Debugf("Failed to decode input: %v\n", err)
return
}
uuid, ok := input["uuid"]
if !ok {
log.Debugf("UUID not found in input")
return
}
log.Debugf("Received UUID: %s", uuid)
status := &CheckStatus{
UUID: uuid,
Passed: false,
Details: "Check not found",
}
for _, claim := range claims.All {
for _, chk := range claim.Checks {
if chk.RequiresRoot() && uuid == chk.UUID() {
log.Infof("Running check %s\n", chk.UUID())
if chk.Run() != nil {
log.Warnf("Failed to run check %s\n", chk.UUID())
continue
}
log.Infof("Check %s completed\n", chk.UUID())
status.Passed = chk.Passed()
status.Details = chk.Status()
log.Infof("Check %s status: %v\n", chk.UUID(), status.Passed)
}
}
}
// Handle the request
response, err := json.Marshal(status)
if err != nil {
log.Debugf("Failed to marshal response: %v\n", err)
return
}
if _, err = conn.Write(response); err != nil {
log.Debugf("Failed to write to connection: %v\n", err)
}
}
// RunCheckViaRoot connects to a Unix socket, sends a UUID, and receives a boolean status.
// It is used to execute a check with root privileges via a helper process.
// The function establishes a connection to the socket specified by SocketPath,
// sends the UUID as a JSON-encoded string, and then decodes the JSON response
// to determine the status of the check. It returns the boolean status associated
// with the UUID and any error encountered during the process.
func RunCheckViaRoot(uuid string) (*CheckStatus, error) {
rateLimitCall.Take()
log.WithField("uuid", uuid).Debug("Running check via root helper")
conn, err := net.Dial("unix", SocketPath)
if err != nil {
log.WithError(err).Warn("Failed to connect to root helper")
return &CheckStatus{}, errors.New("failed to connect to root helper")
}
defer conn.Close()
// Send UUID
input := map[string]string{"uuid": uuid}
encoder := json.NewEncoder(conn)
log.WithField("input", input).Debug("Sending input to helper")
if err := encoder.Encode(input); err != nil {
log.WithError(err).Warn("Failed to encode JSON")
return &CheckStatus{}, errors.New("failed to encode JSON")
}
// Read response
decoder := json.NewDecoder(conn)
var status = &CheckStatus{}
if err := decoder.Decode(status); err != nil {
log.WithError(err).Warn("Failed to decode JSON")
return &CheckStatus{}, errors.New("failed to decode JSON")
}
log.WithField("status", status).Debug("Received status from helper")
return status, nil
}
package runner
import (
"github.com/ParetoSecurity/agent/shared"
"go.uber.org/ratelimit"
)
var SocketPath = "/run/paretosecurity.sock"
var rateLimitCall = ratelimit.New(1)
func IsSocketServicePresent() bool {
_, err := shared.RunCommand("systemctl", "is-enabled", "--quiet", "paretosecurity.socket")
return err == nil
}
package shared
import (
"sync"
)
// Broadcaster structure
type Broadcaster struct {
mu sync.RWMutex
consumers map[chan string]struct{}
input chan string
}
// NewBroadcaster creates a new Broadcaster
func NewBroadcaster() *Broadcaster {
b := &Broadcaster{
consumers: make(map[chan string]struct{}),
input: make(chan string),
}
go b.startBroadcasting()
return b
}
// Register adds a new consumer channel
func (b *Broadcaster) Register() chan string {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan string)
b.consumers[ch] = struct{}{}
return ch
}
// Unregister removes a consumer channel
func (b *Broadcaster) Unregister(ch chan string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.consumers, ch)
close(ch)
}
// Send sends a message to the broadcaster's input channel
func (b *Broadcaster) Send() {
b.input <- "update"
}
// startBroadcasting listens to the input channel and broadcasts messages
func (b *Broadcaster) startBroadcasting() {
for msg := range b.input {
b.mu.RLock()
for ch := range b.consumers {
select {
case ch <- msg:
default:
// Skip slow consumers
}
}
b.mu.RUnlock()
}
}
package shared
import (
"sync"
"time"
)
type cacheItem struct {
data string
expires time.Time
}
var (
cache = make(map[string]cacheItem)
cacheMutex sync.RWMutex
)
func GetCache(key string) (string, bool) {
cacheMutex.RLock()
defer cacheMutex.RUnlock()
if item, exists := cache[key]; exists {
if time.Now().After(item.expires) {
return "", false
}
return item.data, true
}
return "", false
}
func SetCache(key string, value string, ttlSeconds int) {
cacheMutex.Lock()
defer cacheMutex.Unlock()
cache[key] = cacheItem{
data: value,
expires: time.Now().Add(time.Duration(ttlSeconds) * time.Second),
}
}
//go:build unix
// +build unix
package shared
import (
"errors"
"os/exec"
"strings"
"testing"
"github.com/caarlos0/log"
)
// RunCommandMock represents a mock command with its arguments, output, and error
type RunCommandMock struct {
Command string
Args []string
Out string
Err error
}
// RunCommandMocks is a slice that stores mock command outputs.
var RunCommandMocks []RunCommandMock
// RunCommand executes a command with the given name and arguments, and returns
// the combined standard output and standard error as a string. If testing is
// enabled, it returns a predefined fixture instead of executing the command.
func RunCommand(name string, arg ...string) (string, error) {
// Check if testing is enabled and enable harnessing
if testing.Testing() {
for _, mock := range RunCommandMocks {
isCmd := mock.Command == name
isArg := strings.TrimSpace(strings.Join(mock.Args, " ")) == strings.TrimSpace(strings.Join(arg, " "))
if isCmd && isArg {
return mock.Out, mock.Err
}
}
return "", errors.New("RunCommand fixture not found: " + name + " " + strings.TrimSpace(strings.Join(arg, " ")))
}
cmd := exec.Command(name, arg...)
output, err := cmd.CombinedOutput()
log.WithField("cmd", string(name+" "+strings.TrimSpace(strings.Join(arg, " ")))).WithError(err).Debug(string(output))
return string(output), err
}
package shared
import (
"os"
"path/filepath"
"runtime"
"github.com/caarlos0/log"
"github.com/pelletier/go-toml"
)
var Config ParetoConfig
var ConfigPath string
type ParetoConfig struct {
TeamID string
AuthToken string
DisableChecks []string
}
// init initializes the configuration path based on the user's operating system
func init() {
homeDir, err := os.UserHomeDir()
if err != nil {
log.WithError(err).Warn("failed to get user home directory, using current directory instead")
homeDir = "."
}
ConfigPath = filepath.Join(homeDir, ".config", "pareto.toml")
if runtime.GOOS == "windows" {
ConfigPath = filepath.Join(homeDir, "pareto.toml")
}
log.Debugf("configPath: %s", ConfigPath)
}
// SaveConfig writes the current configuration to the config file
func SaveConfig() error {
file, err := os.Create(ConfigPath)
if err != nil {
return err
}
defer file.Close()
encoder := toml.NewEncoder(file)
return encoder.Encode(Config)
}
// LoadConfig reads configuration from disk into the Config variable
func LoadConfig() error {
if _, err := os.Stat(ConfigPath); os.IsNotExist(err) {
if err := SaveConfig(); err != nil {
return err
}
return nil
}
file, err := os.Open(ConfigPath)
if err != nil {
return err
}
defer file.Close()
decoder := toml.NewDecoder(file)
err = decoder.Decode(&Config)
if err != nil {
return err
}
return nil
}
// ResetConfig clears all configuration values to defaults
func ResetConfig() {
Config = ParetoConfig{
TeamID: "",
AuthToken: "",
DisableChecks: []string{},
}
SaveConfig()
}
// EnableCheck removes a check from the disabled checks list
func EnableCheck(checkUUID string) error {
for i, check := range Config.DisableChecks {
if check == checkUUID {
Config.DisableChecks = append(Config.DisableChecks[:i], Config.DisableChecks[i+1:]...)
return SaveConfig()
}
}
return nil
}
// DisableCheck adds a check to the disabled checks list
func DisableCheck(checkUUID string) error {
for _, check := range Config.DisableChecks {
if check == checkUUID {
return nil
}
}
Config.DisableChecks = append(Config.DisableChecks, checkUUID)
return SaveConfig()
}
// IsCheckDisabled checks if a given check UUID is present in the list of disabled checks
func IsCheckDisabled(checkUUID string) bool {
if len(Config.DisableChecks) == 0 {
return false
}
for _, check := range Config.DisableChecks {
if check == checkUUID {
return true
}
}
return false
}
package shared
import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"github.com/caarlos0/log"
"github.com/elastic/go-sysinfo"
"github.com/google/uuid"
"github.com/samber/lo"
)
func CurrentReportingDevice() ReportingDevice {
device, err := NewLinkingDevice()
if err != nil {
log.WithError(err).Fatal("Failed to get device information")
}
osVersion := device.OS
osVersion = Sanitize(fmt.Sprintf("%s %s", osVersion, device.OSVersion))
if runtime.GOOS == "windows" {
productName, err := RunCommand("powershell", "-Command", `(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").ProductName`)
if err != nil {
log.WithError(err).Warn("Failed to get Windows product name")
}
displayVersion, err := RunCommand("powershell", "-Command", `(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").DisplayVersion`)
if err != nil {
log.WithError(err).Warn("Failed to get Windows version")
}
if lo.IsNotEmpty(productName) && lo.IsNotEmpty(displayVersion) {
osVersion = Sanitize(strings.TrimSpace(productName + " " + displayVersion))
}
}
return ReportingDevice{
MachineUUID: device.UUID,
MachineName: Sanitize(device.Hostname),
Auth: Config.AuthToken,
OSVersion: osVersion,
ModelName: func() string {
modelName, err := SystemDevice()
if err != nil || modelName == "" {
return "Unknown"
}
return Sanitize(modelName)
}(),
ModelSerial: func() string {
serial, err := SystemSerial()
if err != nil || serial == "" {
return "Unknown"
}
return Sanitize(serial)
}(),
}
}
type LinkingDevice struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
OSVersion string `json:"osVersion"`
Kernel string `json:"kernel"`
UUID string `json:"uuid"`
Ticket string `json:"ticket"`
Version string `json:"version"`
}
// NewLinkingDevice creates a new instance of LinkingDevice with system information.
// It retrieves the system UUID and device ticket, and populates the LinkingDevice struct
// with the hostname, OS name, OS version, kernel version, UUID, and ticket.
// Returns a pointer to the LinkingDevice and an error if any occurs during the process.
func NewLinkingDevice() (*LinkingDevice, error) {
if testing.Testing() {
return &LinkingDevice{
Hostname: "test-hostname",
OS: "test-os",
OSVersion: "test-os-version",
Kernel: "test-kernel",
UUID: "test-uuid",
Ticket: "test-ticket",
}, nil
}
hostInfo, err := sysinfo.Host()
if err != nil {
log.Warn("Failed to get process information")
return nil, err
}
envInfo := hostInfo.Info()
systemUUID, err := SystemUUID()
if err != nil {
log.Warn("Failed to get system UUID")
return nil, err
}
ticket, err := uuid.NewRandom()
if err != nil {
log.Warn("Failed to generate ticket")
return nil, err
}
hostname, err := os.Hostname()
if err != nil {
log.Warn("Failed to get hostname")
return nil, err
}
return &LinkingDevice{
Hostname: hostname,
OS: envInfo.OS.Name,
OSVersion: envInfo.OS.Version,
Kernel: envInfo.OS.Build,
UUID: systemUUID,
Ticket: ticket.String(),
}, nil
}
//go:build linux
// +build linux
package shared
type ReportingDevice struct {
MachineUUID string `json:"machineUUID"` // e.g. 123e4567-e89b-12d3-a456-426614174000
MachineName string `json:"machineName"` // e.g. MacBook-Pro.local
Auth string `json:"auth"`
OSVersion string `json:"linuxOSVersion"` // e.g. Ubuntu 20.04
ModelName string `json:"modelName"` // e.g. MacBook Pro
ModelSerial string `json:"modelSerial"` // e.g. C02C1234
}
// SystemSerial retrieves the system's serial number by reading the contents of
// "/sys/class/dmi/id/product_serial" using the RunCommand helper function.
// It returns the serial number as a string, or an error if the command fails.
func SystemSerial() (string, error) {
serial, err := RunCommand("cat", "/sys/class/dmi/id/product_serial")
if err != nil {
return "", err
}
return serial, nil
}
// SystemDevice retrieves the system's device name by reading the contents of
// "/sys/class/dmi/id/product_name" using the RunCommand function. It returns
// the device name as a string, or an error if the command fails.
func SystemDevice() (string, error) {
device, err := RunCommand("cat", "/sys/class/dmi/id/product_name")
if err != nil {
return "", err
}
return device, nil
}
package shared
import (
"fmt"
"runtime"
)
func UserAgent() string {
platform := runtime.GOOS
if runtime.GOOS == "darwin" {
platform = "macos"
}
return fmt.Sprintf("ParetoSecurity/agent %s/%s", platform, Version)
}
package shared
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/ParetoSecurity/agent/check"
"github.com/caarlos0/log"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/pelletier/go-toml"
)
type LastState struct {
Name string `json:"name"`
UUID string `json:"uuid"`
Passed bool `json:"state"`
HasError bool `json:"hasError"`
Details string `json:"details"`
}
var (
mutex sync.RWMutex
states = make(map[string]LastState)
lastModTime time.Time
StatePath string
)
func init() {
states = make(map[string]LastState)
homeDir, err := os.UserHomeDir()
if err != nil {
log.WithError(err).Warn("failed to get user home directory, using current directory instead")
homeDir = "."
}
StatePath = filepath.Join(homeDir, ".paretosecurity.state")
}
// Commit writes the current state map to the TOML file.
func CommitLastState() error {
mutex.Lock()
defer mutex.Unlock()
file, err := os.Create(StatePath)
if err != nil {
return err
}
defer file.Close()
lastModTime = time.Now()
encoder := toml.NewEncoder(file)
return encoder.Encode(states)
}
// AllChecksPassed returns true if all checks have passed.
func AllChecksPassed() bool {
mutex.RLock()
defer mutex.RUnlock()
for _, state := range states {
if !state.Passed {
return false
}
}
return true
}
// GetFailedChecks returns a slice of failed checks.
func GetFailedChecks() []LastState {
mutex.RLock()
defer mutex.RUnlock()
var failedChecks []LastState
for _, state := range states {
if !state.Passed {
failedChecks = append(failedChecks, state)
}
}
return failedChecks
}
// PrintStates loads and prints all stored states with their UUIDs, state values, and details.
func PrintStates() {
loadStates()
fmt.Printf("Loaded %d states from %s\n", len(states), StatePath)
fmt.Printf("Last modified time: %s\n\n", lastModTime.Format(time.RFC3339))
data := [][]string{}
for uuid, state := range states {
stateStr := check.CheckStatePassed
if !state.Passed {
stateStr = check.CheckStateFailed
}
data = append(data, []string{uuid, state.Name, string(stateStr), state.Details})
}
table := tablewriter.NewTable(os.Stdout,
tablewriter.WithRenderer(renderer.NewMarkdown()),
)
table.Header([]string{"UUID", "Name", "State", "Details"})
table.Bulk(data)
table.Render()
}
// UpdateState updates the LastState struct in the in-memory map and commits to the TOML file.
func UpdateLastState(newState LastState) {
mutex.Lock()
defer mutex.Unlock()
lastModTime = time.Now()
states[newState.UUID] = newState
}
// GetState retrieves the LastState struct by UUID.
func GetLastState(uuid string) (LastState, bool, error) {
mutex.RLock()
defer mutex.RUnlock()
loadStates()
state, exists := states[uuid]
return state, exists, nil
}
func GetLastStates() map[string]LastState {
mutex.RLock()
defer mutex.RUnlock()
loadStates()
return states
}
// GetModifiedTime returns the last modified time of the state file.
func GetModifiedTime() time.Time {
mutex.RLock()
defer mutex.RUnlock()
loadStates()
return lastModTime
}
// loadStates loads the states from the TOML file if it has been modified since the last load.
func loadStates() {
fileInfo, err := os.Stat(StatePath)
if err != nil {
return
}
if fileInfo.ModTime().After(lastModTime) {
file, err := os.Open(StatePath)
if err != nil {
return
}
defer file.Close()
decoder := toml.NewDecoder(file)
if err := decoder.Decode(&states); err != nil {
return
}
lastModTime = fileInfo.ModTime()
}
}
// SetModifiedTime sets the last modified time of the state file.
func SetModifiedTime(t time.Time) {
mutex.Lock()
defer mutex.Unlock()
lastModTime = t
}
package shared
import (
"os"
"sync"
"testing"
"github.com/caarlos0/log"
)
var isNixOSOnce sync.Once
var isNixOS bool
// IsNixOS checks if the current system is NixOS by attempting to run the
// `nixos-version` command. It returns true if the command executes without
// error, indicating that NixOS is likely the operating system.
func IsNixOS() bool {
if testing.Testing() {
return false
}
isNixOSOnce.Do(func() {
_, err := os.Stat("/run/current-system/sw")
isNixOS = err == nil
log.WithField("isNixOS", isNixOS).Debug("Checking if system is NixOS")
})
return isNixOS
}
package shared
import (
"os"
"testing"
)
// ReadFileMocks is a map that simulates file reading operations by mapping
// file paths (as keys) to their corresponding file contents (as values).
// This can be used for testing purposes to mock the behavior of reading files
// without actually accessing the file system.
var ReadFileMock func(name string) ([]byte, error)
// ReadFile reads the content of the file specified by the given name.
// If the code is running in a testing environment, it will return the content
// from the ReadFileMocks map instead of reading from the actual file system.
// If the file name is not found in the ReadFileMocks map, it returns an error.
// Otherwise, it reads the file content from the file system.
func ReadFile(name string) ([]byte, error) {
if testing.Testing() {
return ReadFileMock(name)
}
return os.ReadFile(name)
}
var UserHomeDirMock func() (string, error)
// UserHomeDir returns the current user's home directory.
//
// On Unix, including macOS, it returns the $HOME environment variable.
// On Windows, it returns %USERPROFILE%.
// On Plan 9, it returns the $home environment variable.
//
// If the expected variable is not set in the environment, UserHomeDir
// returns either a platform-specific default value or a non-nil error.
func UserHomeDir() (string, error) {
if testing.Testing() {
// In tests, return a mock home directory
return UserHomeDirMock()
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return homeDir, nil
}
package shared
// IsLinked checks if the team is linked by verifying that both the TeamID and AuthToken
// in the shared configuration are not empty strings.
// It returns true if both values are present, indicating that the team is linked;
// otherwise, it returns false.
func IsLinked() bool {
return Config.TeamID != "" && Config.AuthToken != ""
}
package shared
// Sanitize takes a string and returns a sanitized version containing only ASCII characters.
// It converts non-ASCII characters to underscores and keeps only alphanumeric characters
// and select punctuation marks (., !, -, ', ", _, ,).
func Sanitize(s string) string {
// Convert to ASCII
ascii := make([]byte, len(s))
for i, r := range s {
if r < 128 {
ascii[i] = byte(r)
} else {
ascii[i] = '_'
}
}
// Filter allowed characters
allowed := func(r byte) bool {
return (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '.' || r == '!' || r == '-' ||
r == '\'' || r == '"' || r == '_' ||
r == ',' || r == ' '
}
// Remove special characters like newline, carriage return but keep spaces
result := make([]byte, 0, len(ascii))
for _, c := range ascii {
if allowed(c) {
result = append(result, c)
}
}
return string(result)
}
package shared
import (
"fmt"
"net"
"os"
"testing"
"strings"
"github.com/google/uuid"
)
func SystemUUID() (string, error) {
interfaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range interfaces {
// Skip loopback interfaces
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if len(iface.HardwareAddr) >= 6 {
hwAddr := iface.HardwareAddr
// Create a namespace UUID from hardware address
nsUUID := uuid.NewSHA1(uuid.NameSpaceOID, hwAddr)
return nsUUID.String(), nil
}
}
return "", fmt.Errorf("no network interface found")
}
func IsRoot() bool {
if testing.Testing() {
return true
}
return os.Geteuid() == 0
}
func SelfExe() string {
exePath, err := os.Executable()
if err != nil {
return "paretosecurity"
}
// Remove the -tray suffix from the executable name (WIN, standalone)
return strings.Replace(exePath, "-tray", "", -1) // Remove -tray from the path)
}
package systemd
import (
"strings"
"github.com/ParetoSecurity/agent/shared"
)
func isEnabled(service string) bool {
state, err := shared.RunCommand("systemctl", "--user", "is-enabled", service)
if strings.TrimSpace(state) == "enabled" && err == nil {
return true
}
return false
}
func enable(service string) error {
_, err := shared.RunCommand("systemctl", "--user", "enable", service)
return err
}
func disable(service string) error {
_, err := shared.RunCommand("systemctl", "--user", "disable", service)
return err
}
package systemd
func IsTimerEnabled() bool {
return isEnabled("paretosecurity-user.timer") && isEnabled("paretosecurity-user.service")
}
func EnableTimer() error {
if err := enable("paretosecurity-user.timer"); err != nil {
return err
}
return enable("paretosecurity-user.service")
}
func DisableTimer() error {
if err := disable("paretosecurity-user.timer"); err != nil {
return err
}
return disable("paretosecurity-user.service")
}
package systemd
func IsTrayIconEnabled() bool {
return isEnabled("paretosecurity-trayicon.service")
}
func EnableTrayIcon() error {
return enable("paretosecurity-trayicon.service")
}
func DisableTrayIcon() error {
return disable("paretosecurity-trayicon.service")
}
package team
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"github.com/caarlos0/log"
"github.com/carlmjohnson/requests"
"github.com/davecgh/go-spew/spew"
"github.com/ParetoSecurity/agent/check"
"github.com/ParetoSecurity/agent/claims"
shared "github.com/ParetoSecurity/agent/shared"
)
const reportURL = "https://cloud.paretosecurity.com"
type Report struct {
PassedCount int `json:"passedCount"`
FailedCount int `json:"failedCount"`
DisabledCount int `json:"disabledCount"`
Device shared.ReportingDevice `json:"device"`
Version string `json:"version"`
LastCheck string `json:"lastCheck"`
SignificantChange string `json:"significantChange"`
State map[string]check.CheckState `json:"state"`
}
// NowReport compiles and returns a Report that summarizes the results of all runnable checks.
func NowReport(all []claims.Claim) Report {
passed := 0
failed := 0
disabled := 0
disabledSeed, _ := shared.SystemUUID()
failedSeed, _ := shared.SystemUUID()
checkStates := make(map[string]check.CheckState)
lastCheckStates := shared.GetLastStates()
for _, claim := range all {
for _, checkS := range claim.Checks {
lastState, found := lastCheckStates[checkS.UUID()]
if checkS.IsRunnable() && found {
if lastState.HasError {
failed++
failedSeed += checkS.UUID()
checkStates[checkS.UUID()] = check.CheckStateError
} else {
if lastState.Passed {
passed++
checkStates[checkS.UUID()] = check.CheckStatePassed
} else {
failed++
failedSeed += checkS.UUID()
checkStates[checkS.UUID()] = check.CheckStateFailed
}
}
} else {
disabled++
disabledSeed += checkS.UUID()
checkStates[checkS.UUID()] = check.CheckStateDisabled
}
}
}
significantChange := sha256.Sum256([]byte(disabledSeed + "." + failedSeed))
return Report{
PassedCount: passed,
FailedCount: failed,
DisabledCount: disabled,
Device: shared.CurrentReportingDevice(),
Version: shared.Version,
LastCheck: time.Now().Format(time.RFC3339),
SignificantChange: hex.EncodeToString(significantChange[:]),
State: checkStates,
}
}
// ReportAndSave generates a report and saves it to the configuration file.
func ReportToTeam(initial bool) error {
var report interface{}
res := ""
errRes := ""
method := http.MethodPatch
if initial {
method = http.MethodPut
report = shared.CurrentReportingDevice()
} else {
report = NowReport(claims.All)
}
// Create a context with a timeout for the request
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
log.WithField("report", spew.Sdump(report)).
WithField("method", method).
WithField("teamID", shared.Config.TeamID).
Debug("Reporting to team")
err := requests.URL(reportURL).
Pathf("/api/v1/team/%s/device", shared.Config.TeamID).
Method(method).
Header("X-Device-Auth", "Bearer "+shared.Config.AuthToken).
Header("User-Agent", shared.UserAgent()).
BodyJSON(&report).
ToString(&res).
AddValidator(
requests.ValidatorHandler(
requests.DefaultValidator,
requests.ToString(&errRes),
)).
Fetch(ctx)
if err != nil {
log.WithField("response", errRes).
WithError(err).
Warnf("Failed to report to team: %s", shared.Config.TeamID)
return err
}
log.WithField("response", res).Debug("API Response")
return nil
}
package trayapp
import (
"fmt"
"time"
"github.com/ParetoSecurity/agent/shared"
)
// lastUpdated calculates and returns a human-readable string representing the time elapsed since the last modification.
func lastUpdated() string {
if shared.GetModifiedTime().IsZero() {
return "never"
}
t := time.Since(shared.GetModifiedTime())
switch {
case t < time.Minute:
return "just now"
case t < time.Hour:
// Less than an hour, show minutes
minutes := int(t.Minutes())
if minutes == 1 {
return "1m ago"
}
return fmt.Sprintf("%dm ago", minutes)
case t < time.Hour*24:
// Less than a day, show hours and minutes
hours := int(t.Hours())
minutes := int(t.Minutes()) % 60
if minutes == 0 {
return fmt.Sprintf("%dh ago", hours)
}
return fmt.Sprintf("%dh %dm ago", hours, minutes)
case t < time.Hour*24*7:
// Less than a week, show days and hours
days := int(t.Hours() / 24)
hours := int(t.Hours()) % 24
if hours == 0 {
return fmt.Sprintf("%dd ago", days)
}
return fmt.Sprintf("%dd %dh ago", days, hours)
default:
// More than a week, show in weeks
days := int(t.Hours() / 24)
weeks := days / 7
remainingDays := days % 7
if remainingDays == 0 {
return fmt.Sprintf("%dw ago", weeks)
}
return fmt.Sprintf("%dw %dd ago", weeks, remainingDays)
}
}
package trayapp
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"runtime"
"sync/atomic"
"time"
"fyne.io/systray"
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/fyne-io/image/ico"
)
// SetTemplateIcon sets the system tray icon based on the operating system.
type IconBadge string
const (
BadgeNone IconBadge = "none"
BadgeOrange IconBadge = "orange"
BadgeGreen IconBadge = "green"
BadgeRunning IconBadge = "running" // New badge type for running state
)
// blinkCancelChan is used to signal when to stop blinking
var blinkCancelChan = make(chan struct{})
// isBlinking tracks if the icon is currently blinking
var isBlinking atomic.Bool
// setIcon sets the system tray icon based on the OS and theme.
// setIcon sets the system tray icon based on the OS and theme.
func setIcon() {
state := BadgeNone
if !shared.GetModifiedTime().IsZero() {
if shared.AllChecksPassed() {
state = BadgeGreen
} else {
state = BadgeOrange
}
}
if runtime.GOOS == "windows" {
// Try to detect Windows theme (light/dark) and set icon accordingly
icon := shared.IconBlack // fallback
if IsDarkTheme() {
icon = shared.IconWhite
}
SetTemplateIcon(icon, state)
return
}
log.Debug("Setting icon for non-Windows OS")
SetTemplateIcon(shared.IconWhite, state)
}
// startBlinkingIcon starts blinking the icon to indicate checks are running
// It will continue blinking until stopBlinkingIcon is called
func startBlinkingIcon() {
// If already blinking, don't start another goroutine
if isBlinking.Load() {
return
}
isBlinking.Store(true)
// Create a new channel first
newCancelChan := make(chan struct{})
// Store the old channel for closing
oldCancelChan := blinkCancelChan
// Update the global variable
blinkCancelChan = newCancelChan
if oldCancelChan != nil {
select {
case <-oldCancelChan:
// Channel already closed, do nothing
default:
close(oldCancelChan)
}
}
go func(cancelCh chan struct{}) {
blinkTicker := time.NewTicker(300 * time.Millisecond)
defer blinkTicker.Stop()
showBadge := true
for {
select {
case <-cancelCh:
// When canceled, make sure we update the icon to the current state
setIcon()
return
case <-blinkTicker.C:
// Toggle between showing the running badge and no badge
if runtime.GOOS == "windows" {
icon := shared.IconBlack
if IsDarkTheme() {
icon = shared.IconWhite
}
if showBadge {
SetTemplateIcon(icon, BadgeRunning)
} else {
SetTemplateIcon(icon, BadgeNone)
}
} else {
if showBadge {
SetTemplateIcon(shared.IconWhite, BadgeRunning)
} else {
SetTemplateIcon(shared.IconWhite, BadgeNone)
}
}
showBadge = !showBadge
}
}
}(blinkCancelChan)
}
// stopBlinkingIcon stops the blinking effect and reverts to the normal icon
func stopBlinkingIcon() {
if !isBlinking.Load() {
return
}
isBlinking.Store(false)
// Signal blinking to stop
if blinkCancelChan != nil {
close(blinkCancelChan)
blinkCancelChan = nil
}
// Restore the normal icon
setIcon()
}
// renderBadge overlays a colored dot (badge) onto the icon PNG bytes.
// Only supports orange and green for now.
func renderBadge(icon []byte, badge IconBadge) []byte {
if badge == BadgeNone {
return icon
}
img, err := png.Decode(bytes.NewReader(icon))
if err != nil {
log.WithError(err).Error("failed to decode PNG for badge rendering")
return icon
}
bounds := img.Bounds()
// Draw a small circle in the bottom right corner
dotSize := bounds.Dx() / 2
centerX := bounds.Max.X - dotSize
centerY := bounds.Max.Y - dotSize
// Create a new RGBA image to draw on
rgba := image.NewRGBA(bounds)
draw.Draw(rgba, bounds, img, image.Point{}, draw.Src)
var dotColor color.Color
switch badge {
case BadgeOrange:
dotColor = color.RGBA{R: 255, G: 140, B: 0, A: 255} // orange
case BadgeGreen:
dotColor = color.RGBA{R: 0, G: 200, B: 0, A: 255} // green
case BadgeRunning:
dotColor = color.RGBA{R: 255, G: 165, B: 0, A: 255} // bright orange
default:
return icon
}
// Draw the dot
for y := 0; y < dotSize; y++ {
for x := 0; x < dotSize; x++ {
dx := x - dotSize/2
dy := y - dotSize/2
if dx*dx+dy*dy <= (dotSize/2)*(dotSize/2) {
rgba.Set(centerX+x, centerY+y, dotColor)
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, rgba); err != nil {
log.WithError(err).Error("failed to encode PNG with badge")
return icon
}
return buf.Bytes()
}
func SetTemplateIcon(icon []byte, badge IconBadge) {
iconWithBadge := renderBadge(icon, badge)
if runtime.GOOS == "windows" {
var icoBuffer bytes.Buffer
pngImage, err := png.Decode(bytes.NewReader(iconWithBadge))
if err != nil {
log.WithError(err).Error("failed to decode PNG image")
}
if err := ico.Encode(&icoBuffer, pngImage); err != nil {
log.WithError(err).Error("failed to encode ICO image")
}
systray.SetTemplateIcon(icoBuffer.Bytes(), icoBuffer.Bytes())
return
}
log.Info("Setting icon for non-Windows OS")
systray.SetTemplateIcon(iconWithBadge, iconWithBadge)
}
package trayapp
func SubscribeToThemeChanges(themeChangeChan chan<- bool) {
// This function is a placeholder for Linux theme change subscription
// Linux does not have a standard way to detect theme changes across all distributions
}
func IsDarkTheme() bool {
// Check if the system is using a dark theme
// This is a placeholder implementation and should be replaced with actual logic
return false
}
package trayapp
import (
"fmt"
"net/url"
"runtime"
"fyne.io/systray"
"github.com/ParetoSecurity/agent/check"
"github.com/ParetoSecurity/agent/claims"
"github.com/ParetoSecurity/agent/notify"
"github.com/ParetoSecurity/agent/shared"
"github.com/ParetoSecurity/agent/systemd"
"github.com/caarlos0/log"
"github.com/pkg/browser"
)
// addQuitItem adds a "Quit" menu item to the system tray.
func addQuitItem() {
mQuit := systray.AddMenuItem("Quit", "Quit the Pareto Security")
mQuit.Enable()
go func() {
<-mQuit.ClickedCh
systray.Quit()
}()
}
// checkStatusToIcon converts a boolean status to an icon string.
func checkStatusToIcon(status, withError bool) string {
if withError {
return "⚠️"
}
if status {
return "✅"
}
return "❌"
}
// updateCheck updates the status of a specific check in the menu.
func updateCheck(chk check.Check, mCheck *systray.MenuItem) {
checkStatus, found, _ := shared.GetLastState(chk.UUID())
if !chk.IsRunnable() || !found {
mCheck.Disable()
mCheck.SetTitle(fmt.Sprintf("🚫 %s", chk.Name()))
return
}
if found {
mCheck.Enable()
mCheck.SetTitle(fmt.Sprintf("%s %s", checkStatusToIcon(checkStatus.Passed, checkStatus.HasError), chk.Name()))
}
}
// updateClaim updates the status of a claim in the menu.
func updateClaim(claim claims.Claim, mClaim *systray.MenuItem) {
mClaim.SetTitle(fmt.Sprintf("❌ %s", claim.Title))
for _, chk := range claim.Checks {
checkStatus, found, _ := shared.GetLastState(chk.UUID())
if found && !checkStatus.Passed && chk.IsRunnable() {
return
}
}
mClaim.SetTitle(fmt.Sprintf("✅ %s", claim.Title))
}
// addOptions adds various options to the system tray menu.
func addOptions() {
mOptions := systray.AddMenuItem("Options", "Settings")
mlink := mOptions.AddSubMenuItemCheckbox("Send reports to the dashboard", "Configure sending device reports to the team", shared.IsLinked())
go func() {
for range mlink.ClickedCh {
if !shared.IsLinked() {
//open browser with help link
if err := browser.OpenURL("https://paretosecurity.com/docs/" + runtime.GOOS + "/link"); err != nil {
log.WithError(err).Error("failed to open help URL")
}
} else {
// execute the command in the system terminal
_, err := shared.RunCommand(shared.SelfExe(), "unlink")
if err != nil {
log.WithError(err).Error("failed to run unlink command")
}
}
if shared.IsLinked() {
mlink.Check()
} else {
mlink.Uncheck()
}
}
}()
if runtime.GOOS != "windows" {
mrun := mOptions.AddSubMenuItemCheckbox("Run checks in the background", "Run checks periodically in the background while the user is logged in.", systemd.IsTimerEnabled())
go func() {
for range mrun.ClickedCh {
if !systemd.IsTimerEnabled() {
if err := systemd.EnableTimer(); err != nil {
log.WithError(err).Error("failed to enable timer")
notify.Toast("Failed to enable timer, please check the logs for more information.")
}
} else {
if err := systemd.DisableTimer(); err != nil {
log.WithError(err).Error("failed to enable timer")
notify.Toast("Failed to enable timer, please check the logs for more information.")
}
}
if systemd.IsTimerEnabled() {
mrun.Check()
} else {
mrun.Uncheck()
}
}
}()
mshow := mOptions.AddSubMenuItemCheckbox("Run the tray icon at startup", "Show tray icon", systemd.IsTrayIconEnabled())
go func() {
for range mshow.ClickedCh {
if !systemd.IsTrayIconEnabled() {
if err := systemd.EnableTrayIcon(); err != nil {
log.WithError(err).Error("failed to enable tray icon")
notify.Toast("Failed to enable tray icon, please check the logs for more information.")
}
} else {
if err := systemd.DisableTrayIcon(); err != nil {
log.WithError(err).Error("failed to disable tray icon")
notify.Toast("Failed to disable tray icon, please check the logs for more information.")
}
}
if systemd.IsTrayIconEnabled() {
mshow.Check()
} else {
mshow.Uncheck()
}
}
}()
}
}
// OnReady initializes the system tray and its menu items.
func OnReady() {
systray.SetTitle("Pareto Security")
log.Info("Starting Pareto Security tray application")
broadcaster := shared.NewBroadcaster()
log.Info("Setting up system tray icon")
setIcon()
if runtime.GOOS == "windows" {
themeCh := make(chan bool)
go SubscribeToThemeChanges(themeCh)
go func() {
for isDark := range themeCh {
icon := shared.IconBlack
if isDark {
icon = shared.IconWhite
}
systray.SetTemplateIcon(icon, icon)
}
}()
}
log.Info("Setting up system tray")
systray.AddMenuItem(fmt.Sprintf("Pareto Security - %s", shared.Version), "").Disable()
addOptions()
systray.AddSeparator()
rcheck := systray.AddMenuItem("Run Checks", "")
go func(rcheck *systray.MenuItem) {
for range rcheck.ClickedCh {
rcheck.Disable()
rcheck.SetTitle("Checking...")
log.Info("Running checks...")
startBlinkingIcon() // Start icon blinking immediately
_, err := shared.RunCommand(shared.SelfExe(), "check")
if err != nil {
log.WithError(err).Error("failed to run check command")
stopBlinkingIcon() // Stop blinking if command failed
}
log.Info("Checks completed")
rcheck.SetTitle("Run Checks")
rcheck.Enable()
stopBlinkingIcon() // Stop icon blinking when done
broadcaster.Send()
}
}(rcheck)
lCheck := systray.AddMenuItem(fmt.Sprintf("Last check: %s", lastUpdated()), "")
lCheck.Disable()
go func() {
for range broadcaster.Register() {
lCheck.SetTitle(fmt.Sprintf("Last check: %s", lastUpdated()))
}
}()
go func() {
for range systray.TrayOpenedCh {
setIcon()
}
}()
systray.AddSeparator()
for _, claim := range claims.All {
mClaim := systray.AddMenuItem(claim.Title, "")
updateClaim(claim, mClaim)
go func(mClaim *systray.MenuItem) {
for range broadcaster.Register() {
log.WithField("claim", claim.Title).Info("Updating claim status")
updateClaim(claim, mClaim)
}
}(mClaim)
for _, chk := range claim.Checks {
mCheck := mClaim.AddSubMenuItem(chk.Name(), "")
updateCheck(chk, mCheck)
go func(chk check.Check, mCheck *systray.MenuItem) {
for range broadcaster.Register() {
log.WithField("check", chk.Name()).Info("Updating check status")
updateCheck(chk, mCheck)
}
}(chk, mCheck)
go func(chk check.Check, mCheck *systray.MenuItem) {
for range mCheck.ClickedCh {
log.WithField("check", chk.Name()).Info("Opening check URL")
arch := "check-linux"
if runtime.GOOS == "windows" {
arch = "check-windows"
}
url := fmt.Sprintf("https://paretosecurity.com/%s/%s?details=%s", arch, chk.UUID(), url.QueryEscape(chk.Status()))
if err := browser.OpenURL(url); err != nil {
log.WithError(err).Error("failed to open check URL")
}
}
}(chk, mCheck)
}
}
systray.AddSeparator()
addQuitItem()
log.Info("System tray setup complete")
// watch for changes in the state file
go watch(broadcaster)
}
package trayapp
import (
"github.com/ParetoSecurity/agent/shared"
"github.com/caarlos0/log"
"github.com/fsnotify/fsnotify"
)
func watch(broadcaster *shared.Broadcaster) {
go func() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.WithError(err).Error("Failed to create file watcher")
return
}
defer watcher.Close()
err = watcher.Add(shared.StatePath)
if err != nil {
log.WithError(err).WithField("path", shared.StatePath).Error("Failed to add state file to watcher")
return
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write {
log.Info("State file modified, updating...")
broadcaster.Send()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.WithError(err).Error("File watcher error")
}
}
}()
}