Add probo-agent CLI and deviceagent library
Introduce the standalone device agent binary and shared library for enrollment, posture checks, self-update, and OS service integration. Include build targets, module deps, and release workflow so the agent can ship independently of server changes. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
34
pkg/deviceagent/checks/check.go
Normal file
34
pkg/deviceagent/checks/check.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is the outcome of one posture check on the current host.
|
||||
type Result struct {
|
||||
CheckKey string
|
||||
Status Status
|
||||
Evidence map[string]any
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
// Check runs a single posture check.
|
||||
type Check interface {
|
||||
Key() string
|
||||
Run(ctx context.Context) Result
|
||||
}
|
||||
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, darwinDiskEncryption)
|
||||
Register(KeyScreenLock, darwinScreenLock)
|
||||
Register(KeyFirewallEnabled, darwinFirewall)
|
||||
Register(KeyTimeSync, darwinTimeSync)
|
||||
Register(KeyOSVersion, darwinOSVersion)
|
||||
Register(KeyAutoUpdate, darwinAutoUpdate)
|
||||
Register(KeyPasswordPolicy, darwinPasswordPolicy)
|
||||
Register(KeyRemoteLogin, darwinRemoteLogin)
|
||||
Register(KeyMalwareProtection, darwinMalwareProtection)
|
||||
}
|
||||
|
||||
func darwinDiskEncryption(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "fdesetup", "status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("sysadminctl") {
|
||||
status := RunCommand(ctx, "sysadminctl", "-screenLock", "status", "-password", "-")
|
||||
rawCombined := strings.TrimSpace(status.Stdout + "\n" + status.Stderr)
|
||||
ev := map[string]any{
|
||||
"backend": "sysadminctl",
|
||||
"raw": rawCombined,
|
||||
"raw_stdout": status.Stdout,
|
||||
"raw_stderr": status.Stderr,
|
||||
}
|
||||
mode, seconds, ok := darwinScreenLockMode(rawCombined)
|
||||
if ok {
|
||||
ev["mode"] = mode
|
||||
if mode == "seconds" && seconds >= 0 {
|
||||
ev["seconds"] = seconds
|
||||
}
|
||||
if mode == "immediate" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if status.Err != nil {
|
||||
ev["error"] = status.Err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword")
|
||||
ev := map[string]any{}
|
||||
if askSource != "" {
|
||||
ev["source"] = askSource
|
||||
}
|
||||
if ask.Err != nil {
|
||||
if darwinDefaultsMissing(ask) {
|
||||
ev["ask_for_password"] = "0"
|
||||
ev["note"] = "askForPassword is unset or unavailable"
|
||||
return fail(ev)
|
||||
}
|
||||
ev["error"] = ask.Err.Error()
|
||||
ev["stderr"] = ask.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
enabled := strings.TrimSpace(ask.Stdout) == "1"
|
||||
|
||||
delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay")
|
||||
ev["ask_for_password"] = ask.Stdout
|
||||
if delayCmd.Err == nil {
|
||||
ev["ask_for_password_delay"] = delayCmd.Stdout
|
||||
if delaySource != "" && delaySource != askSource {
|
||||
ev["delay_source"] = delaySource
|
||||
}
|
||||
}
|
||||
if enabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLockMode(raw string) (string, int, bool) {
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, "immediate") {
|
||||
return "immediate", 0, true
|
||||
}
|
||||
if strings.Contains(lower, "off") {
|
||||
return "off", -1, true
|
||||
}
|
||||
if idx := strings.Index(lower, "seconds"); idx >= 0 {
|
||||
prefix := strings.Fields(lower[:idx])
|
||||
if len(prefix) == 0 {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
n, err := strconv.Atoi(prefix[len(prefix)-1])
|
||||
if err != nil {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
return "seconds", n, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func darwinFirewall(ctx context.Context) Result {
|
||||
out := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.alf",
|
||||
"globalstate",
|
||||
)
|
||||
if out.Err == nil {
|
||||
state := strings.TrimSpace(out.Stdout)
|
||||
ev := map[string]any{"backend": "defaults", "global_state": state}
|
||||
if state == "1" || state == "2" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
ev := map[string]any{
|
||||
"backend": "socketfilterfw",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(out.Err),
|
||||
"defaults_stderr": out.Stderr,
|
||||
}
|
||||
if fallback.Err != nil {
|
||||
ev["error"] = fallback.Err.Error()
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
if darwinStateIndicatesEnabled(fallback.Stdout) {
|
||||
return pass(ev)
|
||||
}
|
||||
if darwinStateIndicatesDisabled(fallback.Stdout) {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// darwinReadScreenSaverDefault prefers console-user settings when running as root.
|
||||
func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, string) {
|
||||
consoleUser := darwinConsoleUser(ctx)
|
||||
if os.Geteuid() == 0 && consoleUser != "" {
|
||||
var consoleMissing CmdResult
|
||||
consoleMissingSource := ""
|
||||
|
||||
if CommandExists("sudo") {
|
||||
consoleUserCurrentHost := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"-currentHost",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserCurrentHost.Err == nil {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserCurrentHost) {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserCurrentHost
|
||||
consoleMissingSource = "console_user_current_host:" + consoleUser
|
||||
}
|
||||
|
||||
consoleUserDomain := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserDomain.Err == nil {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserDomain) {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserDomain
|
||||
consoleMissingSource = "console_user:" + consoleUser
|
||||
}
|
||||
}
|
||||
|
||||
plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist"
|
||||
consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key)
|
||||
if consoleUserOut.Err == nil {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserOut) {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserOut
|
||||
consoleMissingSource = "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource != "" {
|
||||
return consoleMissing, consoleMissingSource
|
||||
}
|
||||
}
|
||||
|
||||
currentHost := RunCommand(ctx, "defaults", "-currentHost", "read", "com.apple.screensaver", key)
|
||||
if currentHost.Err == nil {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
|
||||
currentUser := RunCommand(ctx, "defaults", "read", "com.apple.screensaver", key)
|
||||
if currentUser.Err == nil {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
if !darwinDefaultsMissing(currentUser) {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
if !darwinDefaultsMissing(currentHost) {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
func darwinDefaultsMissing(out CmdResult) bool {
|
||||
lower := strings.ToLower(out.Stderr + "\n" + out.Stdout)
|
||||
return strings.Contains(lower, "does not exist") ||
|
||||
strings.Contains(lower, "could not find") ||
|
||||
strings.Contains(lower, "does not exist in domain")
|
||||
}
|
||||
|
||||
func darwinConsoleUser(ctx context.Context) string {
|
||||
if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" {
|
||||
return sudoUser
|
||||
}
|
||||
out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console")
|
||||
if out.Err != nil {
|
||||
return ""
|
||||
}
|
||||
user := strings.TrimSpace(out.Stdout)
|
||||
if user == "" || user == "root" || user == "loginwindow" {
|
||||
return ""
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func darwinStateIndicatesEnabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "enabled") ||
|
||||
strings.Contains(lower, "state = 1") ||
|
||||
strings.Contains(lower, "state = 2")
|
||||
}
|
||||
|
||||
func darwinStateIndicatesDisabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "disabled") || strings.Contains(lower, "state = 0")
|
||||
}
|
||||
|
||||
func darwinTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getusingnetworktime")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "sw_vers", "-productVersion")
|
||||
if out.Err != nil || out.Stdout == "" {
|
||||
return unknown(map[string]any{"error": "sw_vers failed"})
|
||||
}
|
||||
build := RunCommand(ctx, "sw_vers", "-buildVersion")
|
||||
ev := map[string]any{
|
||||
"product_version": out.Stdout,
|
||||
"build_version": build.Stdout,
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinAutoUpdate(ctx context.Context) Result {
|
||||
primary := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.SoftwareUpdate",
|
||||
"AutomaticCheckEnabled",
|
||||
)
|
||||
if primary.Err == nil {
|
||||
ev := map[string]any{
|
||||
"backend": "defaults",
|
||||
"automatic_check_enabled": primary.Stdout,
|
||||
}
|
||||
if strings.TrimSpace(primary.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "softwareupdate", "--schedule")
|
||||
ev := map[string]any{
|
||||
"backend": "softwareupdate",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(primary.Err),
|
||||
"defaults_stderr": primary.Stderr,
|
||||
}
|
||||
if fallback.Err != nil ||
|
||||
needsAdmin(fallback.Stdout) ||
|
||||
needsAdmin(fallback.Stderr) {
|
||||
ev["error"] = errString(fallback.Err)
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(fallback.Stdout)
|
||||
switch {
|
||||
case strings.Contains(lower, "is turned on"),
|
||||
strings.Contains(lower, "automatic check is on"):
|
||||
return pass(ev)
|
||||
case strings.Contains(lower, "is turned off"),
|
||||
strings.Contains(lower, "automatic check is off"):
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func darwinPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "pwpolicy", "-getaccountpolicies")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(lower, "no account policies") || lower == "" {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getremotelogin")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
off := strings.Contains(strings.ToLower(out.Stdout), "off")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if off {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []string{
|
||||
"/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
"/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
ev := map[string]any{"engine": "XProtect", "plist": path}
|
||||
version := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
strings.TrimSuffix(path, ".plist"),
|
||||
"Version",
|
||||
)
|
||||
if version.Err == nil {
|
||||
ev["version"] = version.Stdout
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(
|
||||
map[string]any{
|
||||
"engine": "XProtect",
|
||||
"note": "XProtect.meta.plist not found in expected locations",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// needsAdmin checks systemsetup's stdout for privilege errors.
|
||||
func needsAdmin(stdout string) bool {
|
||||
return strings.Contains(strings.ToLower(stdout), "administrator access")
|
||||
}
|
||||
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, freebsdDiskEncryption)
|
||||
Register(KeyScreenLock, freebsdScreenLock)
|
||||
Register(KeyFirewallEnabled, freebsdFirewall)
|
||||
Register(KeyTimeSync, freebsdTimeSync)
|
||||
Register(KeyOSVersion, freebsdOSVersion)
|
||||
Register(KeyAutoUpdate, freebsdAutoUpdate)
|
||||
Register(KeyPasswordPolicy, freebsdPasswordPolicy)
|
||||
Register(KeyRemoteLogin, freebsdRemoteLogin)
|
||||
Register(KeyMalwareProtection, freebsdMalwareProtection)
|
||||
}
|
||||
|
||||
func freebsdDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("geli") {
|
||||
return unknown(map[string]any{"note": "geli command not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "geli", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "ACTIVE") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("xscreensaver-command") {
|
||||
out := RunCommand(ctx, "xscreensaver-command", "-version")
|
||||
if out.Err == nil {
|
||||
return pass(map[string]any{"raw": out.Stdout})
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD does not have a unified screen lock policy",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdFirewall(ctx context.Context) Result {
|
||||
if !CommandExists("pfctl") {
|
||||
return unknown(map[string]any{"note": "pfctl not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "pfctl", "-si")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "Status: Enabled") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "ntpd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return fail(ev)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "uname", "-r")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
return pass(map[string]any{"release": out.Stdout})
|
||||
}
|
||||
|
||||
func freebsdAutoUpdate(ctx context.Context) Result {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD relies on operator-driven freebsd-update",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.conf")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
hasPolicy := strings.Contains(body, "minpasswordlen=") ||
|
||||
strings.Contains(body, "passwordtime=")
|
||||
ev := map[string]any{
|
||||
"login_conf_snippet": truncate(body, 400),
|
||||
}
|
||||
if hasPolicy {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdMalwareProtection(ctx context.Context) Result {
|
||||
if !CommandExists("clamd") && !CommandExists("clamdscan") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "clamav not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "service", "clamav_clamd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "sshd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
399
pkg/deviceagent/checks/checks_linux.go
Normal file
399
pkg/deviceagent/checks/checks_linux.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, linuxDiskEncryption)
|
||||
Register(KeyScreenLock, linuxScreenLock)
|
||||
Register(KeyFirewallEnabled, linuxFirewall)
|
||||
Register(KeyTimeSync, linuxTimeSync)
|
||||
Register(KeyOSVersion, linuxOSVersion)
|
||||
Register(KeyAutoUpdate, linuxAutoUpdate)
|
||||
Register(KeyPasswordPolicy, linuxPasswordPolicy)
|
||||
Register(KeyRemoteLogin, linuxRemoteLogin)
|
||||
Register(KeyMalwareProtection, linuxMalwareProtection)
|
||||
}
|
||||
|
||||
func linuxDiskEncryption(ctx context.Context) Result {
|
||||
ev := map[string]any{}
|
||||
|
||||
if data, err := os.ReadFile("/etc/crypttab"); err == nil {
|
||||
body := strings.TrimSpace(string(data))
|
||||
ev["crypttab_present"] = true
|
||||
ev["crypttab_lines"] = nonCommentLines(body)
|
||||
if len(nonCommentLines(body)) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
} else {
|
||||
ev["crypttab_present"] = false
|
||||
}
|
||||
|
||||
lsblk := RunCommand(ctx, "lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-r")
|
||||
if lsblk.Err == nil {
|
||||
ev["lsblk"] = truncate(lsblk.Stdout, 800)
|
||||
lines := strings.Split(lsblk.Stdout, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "crypt" {
|
||||
return pass(ev)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ev["lsblk_error"] = lsblk.Err.Error()
|
||||
}
|
||||
|
||||
if lsblk.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxScreenLock(ctx context.Context) Result {
|
||||
if !CommandExists("gsettings") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "gsettings not installed (likely headless host)",
|
||||
},
|
||||
)
|
||||
}
|
||||
idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled")
|
||||
if idle.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": idle.Err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.TrimSpace(idle.Stdout) == "true"
|
||||
ev := map[string]any{"lock_enabled": idle.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxFirewall(ctx context.Context) Result {
|
||||
if CommandExists("ufw") {
|
||||
out := RunCommand(ctx, "ufw", "status")
|
||||
if out.Err == nil {
|
||||
active := strings.Contains(strings.ToLower(out.Stdout), "status: active")
|
||||
ev := map[string]any{"backend": "ufw", "raw": out.Stdout}
|
||||
if active {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
if CommandExists("firewall-cmd") {
|
||||
out := RunCommand(ctx, "firewall-cmd", "--state")
|
||||
ev := map[string]any{"backend": "firewalld", "raw": out.Stdout}
|
||||
if out.Err == nil && strings.Contains(strings.ToLower(out.Stdout), "running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("nft") {
|
||||
out := RunCommand(ctx, "nft", "list", "ruleset")
|
||||
ev := map[string]any{
|
||||
"backend": "nftables",
|
||||
"rules_excerpt": truncate(out.Stdout, 400),
|
||||
}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "chain ") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("iptables") {
|
||||
out := RunCommand(ctx, "iptables", "-S", "INPUT")
|
||||
ev := map[string]any{"backend": "iptables"}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
policy, rules := parseIptablesInput(out.Stdout)
|
||||
ev["input_policy"] = policy
|
||||
ev["input_rules"] = rules
|
||||
if policy == "DROP" || policy == "REJECT" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
if rules == 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
// ACCEPT policy with some rules means the operator is filtering,
|
||||
// but we cannot tell from -S whether the rules are restrictive
|
||||
// or permissive without modelling the chain.
|
||||
return unknown(ev)
|
||||
}
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "no known firewall tool found",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// parseIptablesInput extracts the INPUT chain policy and rule count from
|
||||
// `iptables -S INPUT` output.
|
||||
func parseIptablesInput(s string) (string, int) {
|
||||
var (
|
||||
policy string
|
||||
rules int
|
||||
)
|
||||
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-P INPUT"):
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
policy = strings.ToUpper(fields[2])
|
||||
}
|
||||
case strings.HasPrefix(line, "-A INPUT"):
|
||||
rules++
|
||||
}
|
||||
}
|
||||
|
||||
return policy, rules
|
||||
}
|
||||
|
||||
func linuxTimeSync(ctx context.Context) Result {
|
||||
if !CommandExists("timedatectl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "timedatectl not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "timedatectl", "show")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(out.Stdout, "NTPSynchronized=yes") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxOSVersion(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"pretty_name": kvLookup(body, "PRETTY_NAME"),
|
||||
"version_id": kvLookup(body, "VERSION_ID"),
|
||||
"id": kvLookup(body, "ID"),
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func linuxAutoUpdate(ctx context.Context) Result {
|
||||
if _, err := os.Stat("/etc/apt/apt.conf.d/20auto-upgrades"); err == nil {
|
||||
data, _ := os.ReadFile("/etc/apt/apt.conf.d/20auto-upgrades")
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"backend": "unattended-upgrades",
|
||||
"raw": body,
|
||||
}
|
||||
if strings.Contains(body, `"1"`) {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if CommandExists("systemctl") {
|
||||
out := RunCommand(ctx, "systemctl", "is-enabled", "dnf-automatic.timer")
|
||||
if out.Err == nil {
|
||||
ev := map[string]any{"backend": "dnf-automatic", "state": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "enabled" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "no known auto-update mechanism",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func linuxPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.defs")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
minLen := loginDefsLookup(body, "PASS_MIN_LEN")
|
||||
maxDays := loginDefsLookup(body, "PASS_MAX_DAYS")
|
||||
ev := map[string]any{
|
||||
"pass_min_len": minLen,
|
||||
"pass_max_days": maxDays,
|
||||
}
|
||||
if minLen == "" {
|
||||
ev["parse_error"] = "PASS_MIN_LEN not set"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
minLenValue, err := strconv.Atoi(minLen)
|
||||
if err != nil {
|
||||
ev["parse_error"] = "invalid PASS_MIN_LEN value"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if minLenValue >= 8 {
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxRemoteLogin(ctx context.Context) Result {
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(map[string]any{"note": "systemctl unavailable"})
|
||||
}
|
||||
state := RunCommand(ctx, "systemctl", "is-active", "ssh.service")
|
||||
stateAlt := RunCommand(ctx, "systemctl", "is-active", "sshd.service")
|
||||
merged := strings.TrimSpace(state.Stdout)
|
||||
if merged == "" {
|
||||
merged = strings.TrimSpace(stateAlt.Stdout)
|
||||
}
|
||||
ev := map[string]any{"is_active": merged}
|
||||
switch merged {
|
||||
case "active":
|
||||
return fail(ev)
|
||||
case "inactive", "failed":
|
||||
return pass(ev)
|
||||
case "":
|
||||
return notApplicable(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// linuxMalwareProtection tracks AV/EDR agent services, not MAC frameworks.
|
||||
func linuxMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []struct {
|
||||
unit string
|
||||
name string
|
||||
}{
|
||||
{"clamav-daemon.service", "ClamAV"},
|
||||
{"clamd.service", "ClamAV"},
|
||||
{"clamd@scan.service", "ClamAV"},
|
||||
{"falcon-sensor.service", "CrowdStrike Falcon"},
|
||||
{"sentinelone.service", "SentinelOne"},
|
||||
{"sentineld.service", "SentinelOne"},
|
||||
{"sav-protect.service", "Sophos"},
|
||||
{"sophos-spl.service", "Sophos"},
|
||||
{"esets.service", "ESET"},
|
||||
{"mdatp.service", "Microsoft Defender for Endpoint"},
|
||||
{"wazuh-agent.service", "Wazuh"},
|
||||
{"ossec.service", "OSSEC"},
|
||||
{"elastic-agent.service", "Elastic Agent"},
|
||||
{"osqueryd.service", "osquery"},
|
||||
}
|
||||
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "systemctl not available; cannot enumerate endpoint agents",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var active, installed []string
|
||||
for _, c := range candidates {
|
||||
state := strings.TrimSpace(
|
||||
RunCommand(ctx, "systemctl", "is-active", c.unit).Stdout)
|
||||
switch state {
|
||||
case "active":
|
||||
active = append(active, c.name)
|
||||
case "inactive", "failed", "activating", "deactivating":
|
||||
installed = append(installed, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"active": active,
|
||||
"installed": installed,
|
||||
}
|
||||
if len(active) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
if len(installed) > 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func nonCommentLines(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kvLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line[:eq]) == key {
|
||||
v := strings.TrimSpace(line[eq+1:])
|
||||
v = strings.Trim(v, `"`)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loginDefsLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == key {
|
||||
return fields[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
421
pkg/deviceagent/checks/checks_windows.go
Normal file
421
pkg/deviceagent/checks/checks_windows.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, windowsDiskEncryption)
|
||||
Register(KeyScreenLock, windowsScreenLock)
|
||||
Register(KeyFirewallEnabled, windowsFirewall)
|
||||
Register(KeyTimeSync, windowsTimeSync)
|
||||
Register(KeyOSVersion, windowsOSVersion)
|
||||
Register(KeyAutoUpdate, windowsAutoUpdate)
|
||||
Register(KeyPasswordPolicy, windowsPasswordPolicy)
|
||||
Register(KeyRemoteLogin, windowsRemoteLogin)
|
||||
Register(KeyMalwareProtection, windowsMalwareProtection)
|
||||
}
|
||||
|
||||
const psNoProfile = "-NoProfile"
|
||||
|
||||
func powershell(ctx context.Context, script string) CmdResult {
|
||||
return RunCommand(ctx, "powershell.exe", psNoProfile, "-Command", script)
|
||||
}
|
||||
|
||||
func windowsDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("manage-bde.exe") && !CommandExists("manage-bde") {
|
||||
return unknown(map[string]any{"note": "manage-bde not found"})
|
||||
}
|
||||
|
||||
out := RunCommand(ctx, "manage-bde", "-status")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 600)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "percentage encrypted: 100") ||
|
||||
strings.Contains(lower, "fully encrypted") ||
|
||||
strings.Contains(lower, "protection on") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsScreenLock(ctx context.Context) Result {
|
||||
// HKCU resolves to the SYSTEM hive when the agent runs as LocalSystem,
|
||||
// so we first look for a machine-wide policy and then enumerate every
|
||||
// loaded interactive user hive under HKU.
|
||||
machine := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop' `+
|
||||
`-ErrorAction SilentlyContinue).ScreenSaverIsSecure`,
|
||||
)
|
||||
if machine.Err == nil {
|
||||
v := strings.TrimSpace(machine.Stdout)
|
||||
if v != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "machine_policy",
|
||||
"screen_saver_is_secure": v,
|
||||
}
|
||||
if v == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
|
||||
users := powershell(
|
||||
ctx,
|
||||
`Get-ChildItem 'Registry::HKEY_USERS' | `+
|
||||
`Where-Object { $_.PSChildName -match '^S-1-5-21-' } | `+
|
||||
`ForEach-Object { `+
|
||||
` $path = "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop"; `+
|
||||
` $key = Get-ItemProperty $path -ErrorAction SilentlyContinue; `+
|
||||
` "$($_.PSChildName)=$($key.ScreenSaverIsSecure)" `+
|
||||
`}`,
|
||||
)
|
||||
if users.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"error": users.Err.Error(),
|
||||
"stderr": users.Stderr,
|
||||
"machine_policy_error": errString(machine.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"raw": truncate(users.Stdout, 400),
|
||||
}
|
||||
users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout)
|
||||
ev["users"] = users_
|
||||
if len(users_) == 0 {
|
||||
ev["note"] = "no interactive user hives loaded"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if anyEnabled && !anyDisabled {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsUserScreenLock parses one "SID=<value>" line per user from
|
||||
// the registry enumeration and reports whether each user has screen
|
||||
// saver locking enabled.
|
||||
func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) {
|
||||
users := map[string]string{}
|
||||
var anyEnabled, anyDisabled bool
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(line, "=")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sid := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
users[sid] = value
|
||||
switch value {
|
||||
case "1":
|
||||
anyEnabled = true
|
||||
default:
|
||||
anyDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
return users, anyDisabled, anyEnabled
|
||||
}
|
||||
|
||||
func windowsFirewall(ctx context.Context) Result {
|
||||
primary := powershell(
|
||||
ctx,
|
||||
`(Get-NetFirewallProfile -PolicyStore ActiveStore | `+
|
||||
`Sort-Object Name | `+
|
||||
`ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join ";"`,
|
||||
)
|
||||
if primary.Err == nil && strings.TrimSpace(primary.Stdout) != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "Get-NetFirewallProfile",
|
||||
"raw": primary.Stdout,
|
||||
}
|
||||
profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout)
|
||||
ev["profiles"] = profiles
|
||||
if allEnabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "netsh", "advfirewall", "show", "allprofiles", "state")
|
||||
if fallback.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": errString(fallback.Err),
|
||||
"stderr": fallback.Stderr,
|
||||
"powershell_error": errString(primary.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
ev := map[string]any{
|
||||
"backend": "netsh",
|
||||
"raw": truncate(fallback.Stdout, 600),
|
||||
}
|
||||
stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout)
|
||||
ev["state_lines"] = stateLines
|
||||
if len(stateLines) > 0 && !anyOff {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsFirewallProfiles parses "Domain=True;Private=True;Public=True"
|
||||
// from Get-NetFirewallProfile output, returning per-profile state and
|
||||
// whether every profile is enabled.
|
||||
func parseWindowsFirewallProfiles(s string) (map[string]string, bool) {
|
||||
profiles := map[string]string{}
|
||||
allEnabled := true
|
||||
any := false
|
||||
for _, profile := range strings.Split(s, ";") {
|
||||
parts := strings.SplitN(strings.TrimSpace(profile), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
profiles[name] = value
|
||||
any = true
|
||||
if !strings.EqualFold(value, "true") {
|
||||
allEnabled = false
|
||||
}
|
||||
}
|
||||
return profiles, any && allEnabled
|
||||
}
|
||||
|
||||
// parseNetshFirewallStates extracts per-profile "State <ON|OFF>" lines
|
||||
// from `netsh advfirewall show allprofiles state`. It is whitespace- and
|
||||
// case-insensitive.
|
||||
func parseNetshFirewallStates(s string) ([]string, bool) {
|
||||
var states []string
|
||||
anyOff := false
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if !strings.HasPrefix(lower, "state") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(lower)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
value := fields[len(fields)-1]
|
||||
states = append(states, value)
|
||||
if value != "on" {
|
||||
anyOff = true
|
||||
}
|
||||
}
|
||||
|
||||
return states, anyOff
|
||||
}
|
||||
|
||||
func windowsTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "w32tm", "/query", "/status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsOSVersion(ctx context.Context) Result {
|
||||
out := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Version`)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`)
|
||||
return pass(
|
||||
map[string]any{
|
||||
"version": out.Stdout,
|
||||
"caption": caption.Stdout,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func windowsAutoUpdate(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$au = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `+
|
||||
`-ErrorAction SilentlyContinue; `+
|
||||
`"$($au.NoAutoUpdate);$($au.AUOptions)"`,
|
||||
)
|
||||
ev := map[string]any{}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
ev["stderr"] = out.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2)
|
||||
var noAutoUpdate, auOptions string
|
||||
if len(parts) >= 1 {
|
||||
noAutoUpdate = strings.TrimSpace(parts[0])
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
auOptions = strings.TrimSpace(parts[1])
|
||||
}
|
||||
ev["no_auto_update"] = noAutoUpdate
|
||||
ev["au_options"] = auOptions
|
||||
|
||||
// NoAutoUpdate=1 explicitly disables automatic updates via policy.
|
||||
if noAutoUpdate == "1" {
|
||||
return fail(ev)
|
||||
}
|
||||
// AUOptions semantics:
|
||||
// 2 — notify before download (no auto-install)
|
||||
// 3 — auto download, prompt to install
|
||||
// 4 — auto download + auto install (target SOC posture)
|
||||
// 5 — managed by local administrators
|
||||
switch auOptions {
|
||||
case "3", "4", "5":
|
||||
return pass(ev)
|
||||
case "2":
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// No managed policy. The Windows Update service must at least be
|
||||
// running for the OS default of auto-install to take effect.
|
||||
svc := RunCommand(ctx, "sc.exe", "query", "wuauserv")
|
||||
if svc.Err != nil {
|
||||
ev["wuauserv_error"] = svc.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if strings.Contains(svc.Stdout, "RUNNING") {
|
||||
ev["wuauserv"] = "running"
|
||||
return pass(ev)
|
||||
}
|
||||
ev["wuauserv"] = "stopped"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "net", "accounts")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsMalwareProtection(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$s = Get-MpComputerStatus; `+
|
||||
`"$($s.AntivirusEnabled);$($s.RealTimeProtectionEnabled);`+
|
||||
`$($s.AMServiceEnabled);$($s.AntivirusSignatureLastUpdated)"`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
parts := strings.Split(out.Stdout, ";")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if len(parts) < 3 {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True")
|
||||
realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True")
|
||||
serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True")
|
||||
ev["antivirus_enabled"] = antivirusOn
|
||||
ev["real_time_protection"] = realtimeOn
|
||||
ev["am_service_enabled"] = serviceOn
|
||||
if len(parts) >= 4 {
|
||||
ev["signatures_last_updated"] = strings.TrimSpace(parts[3])
|
||||
}
|
||||
|
||||
if antivirusOn && (realtimeOn || serviceOn) {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsRemoteLogin(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server').fDenyTSConnections`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
ev := map[string]any{"fdeny_ts_connections": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
31
pkg/deviceagent/checks/evidence.go
Normal file
31
pkg/deviceagent/checks/evidence.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// EvidenceJSON encodes evidence for transport to the agent API.
|
||||
func EvidenceJSON(ev map[string]any) json.RawMessage {
|
||||
if len(ev) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
55
pkg/deviceagent/checks/registry.go
Normal file
55
pkg/deviceagent/checks/registry.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
registryMu sync.Mutex
|
||||
registry []Check
|
||||
)
|
||||
|
||||
// Register adds a check implementation to the process registry.
|
||||
func Register(key string, run func(context.Context) Result) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(
|
||||
registry,
|
||||
funcCheck{
|
||||
key: key,
|
||||
run: run,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// All returns a stable snapshot of registered checks.
|
||||
func All() []Check {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
out := make([]Check, len(registry))
|
||||
copy(out, registry)
|
||||
sort.SliceStable(
|
||||
out,
|
||||
func(i, j int) bool {
|
||||
return out[i].Key() < out[j].Key()
|
||||
},
|
||||
)
|
||||
return out
|
||||
}
|
||||
101
pkg/deviceagent/checks/runcmd.go
Normal file
101
pkg/deviceagent/checks/runcmd.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultCommandTimeout = 5 * time.Second
|
||||
|
||||
var commandExistsCache sync.Map
|
||||
|
||||
// CmdResult captures the basic outcome of an OS subcommand.
|
||||
type CmdResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// RunCommand executes a command and returns trimmed stdout/stderr.
|
||||
func RunCommand(ctx context.Context, name string, args ...string) CmdResult {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, defaultCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
resolved, ok := resolveCommandPath(name)
|
||||
if !ok {
|
||||
return CmdResult{
|
||||
Err: fmt.Errorf("command %q not available at expected absolute path", name),
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, resolved, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return CmdResult{
|
||||
Stdout: strings.TrimSpace(stdout.String()),
|
||||
Stderr: strings.TrimSpace(stderr.String()),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// CommandExists reports whether `cmd` exists at expected absolute path(s).
|
||||
func CommandExists(cmd string) bool {
|
||||
if cached, ok := commandExistsCache.Load(cmd); ok {
|
||||
return cached.(bool)
|
||||
}
|
||||
|
||||
_, exists := resolveCommandPath(cmd)
|
||||
commandExistsCache.Store(cmd, exists)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func resolveCommandPath(cmd string) (string, bool) {
|
||||
if filepath.IsAbs(cmd) {
|
||||
return cmd, isExecutableFile(cmd)
|
||||
}
|
||||
|
||||
for _, candidate := range commandCandidates(cmd) {
|
||||
if isExecutableFile(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
|
||||
return info.Mode().Perm()&0o111 != 0
|
||||
}
|
||||
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var darwinCommandPaths = map[string][]string{
|
||||
"defaults": {"/usr/bin/defaults"},
|
||||
"fdesetup": {"/usr/bin/fdesetup"},
|
||||
"pwpolicy": {"/usr/bin/pwpolicy"},
|
||||
"softwareupdate": {"/usr/sbin/softwareupdate"},
|
||||
"stat": {"/usr/bin/stat"},
|
||||
"sudo": {"/usr/bin/sudo"},
|
||||
"sw_vers": {"/usr/bin/sw_vers"},
|
||||
"sysadminctl": {"/usr/sbin/sysadminctl"},
|
||||
"systemsetup": {"/usr/sbin/systemsetup"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return darwinCommandPaths[cmd]
|
||||
}
|
||||
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var freeBSDCommandPaths = map[string][]string{
|
||||
"clamd": {"/usr/local/sbin/clamd", "/usr/sbin/clamd"},
|
||||
"clamdscan": {"/usr/local/bin/clamdscan", "/usr/bin/clamdscan"},
|
||||
"geli": {"/sbin/geli"},
|
||||
"pfctl": {"/sbin/pfctl"},
|
||||
"service": {"/usr/sbin/service"},
|
||||
"uname": {"/usr/bin/uname"},
|
||||
"xscreensaver-command": {"/usr/local/bin/xscreensaver-command"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return freeBSDCommandPaths[cmd]
|
||||
}
|
||||
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var linuxCommandPaths = map[string][]string{
|
||||
"firewall-cmd": {"/usr/bin/firewall-cmd"},
|
||||
"gsettings": {"/usr/bin/gsettings"},
|
||||
"iptables": {"/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables"},
|
||||
"lsblk": {"/usr/bin/lsblk", "/bin/lsblk"},
|
||||
"nft": {"/usr/sbin/nft", "/sbin/nft"},
|
||||
"systemctl": {"/usr/bin/systemctl", "/bin/systemctl"},
|
||||
"timedatectl": {"/usr/bin/timedatectl", "/bin/timedatectl"},
|
||||
"ufw": {"/usr/sbin/ufw", "/sbin/ufw"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return linuxCommandPaths[cmd]
|
||||
}
|
||||
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build !darwin && !linux && !freebsd && !windows
|
||||
|
||||
package checks
|
||||
|
||||
func commandCandidates(_ string) []string {
|
||||
return nil
|
||||
}
|
||||
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
system32 := filepath.Join(systemRoot, "System32")
|
||||
|
||||
switch strings.ToLower(cmd) {
|
||||
case "powershell", "powershell.exe":
|
||||
return []string{
|
||||
filepath.Join(system32, "WindowsPowerShell", "v1.0", "powershell.exe"),
|
||||
}
|
||||
case "manage-bde", "manage-bde.exe":
|
||||
return []string{filepath.Join(system32, "manage-bde.exe")}
|
||||
case "netsh", "netsh.exe":
|
||||
return []string{filepath.Join(system32, "netsh.exe")}
|
||||
case "w32tm", "w32tm.exe":
|
||||
return []string{filepath.Join(system32, "w32tm.exe")}
|
||||
case "sc", "sc.exe":
|
||||
return []string{filepath.Join(system32, "sc.exe")}
|
||||
case "net", "net.exe":
|
||||
return []string{filepath.Join(system32, "net.exe")}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
84
pkg/deviceagent/checks/shared.go
Normal file
84
pkg/deviceagent/checks/shared.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check keys shared across OS implementations.
|
||||
const (
|
||||
KeyDiskEncryption = "DISK_ENCRYPTION"
|
||||
KeyScreenLock = "SCREEN_LOCK"
|
||||
KeyFirewallEnabled = "FIREWALL_ENABLED"
|
||||
KeyTimeSync = "TIME_SYNC"
|
||||
KeyOSVersion = "OS_VERSION"
|
||||
KeyAutoUpdate = "AUTO_UPDATE"
|
||||
KeyPasswordPolicy = "PASSWORD_POLICY"
|
||||
KeyRemoteLogin = "REMOTE_LOGIN"
|
||||
KeyMalwareProtection = "MALWARE_PROTECTION"
|
||||
)
|
||||
|
||||
type funcCheck struct {
|
||||
key string
|
||||
run func(ctx context.Context) Result
|
||||
}
|
||||
|
||||
func (c funcCheck) Key() string { return c.key }
|
||||
|
||||
func (c funcCheck) Run(ctx context.Context) Result {
|
||||
r := c.run(ctx)
|
||||
if r.CheckKey == "" {
|
||||
r.CheckKey = c.key
|
||||
}
|
||||
if r.ObservedAt.IsZero() {
|
||||
r.ObservedAt = time.Now().UTC()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func pass(ev map[string]any) Result {
|
||||
return Result{Status: StatusPass, Evidence: ev}
|
||||
}
|
||||
|
||||
func fail(ev map[string]any) Result {
|
||||
return Result{Status: StatusFail, Evidence: ev}
|
||||
}
|
||||
|
||||
func unknown(ev map[string]any) Result {
|
||||
return Result{Status: StatusUnknown, Evidence: ev}
|
||||
}
|
||||
|
||||
func notApplicable(ev map[string]any) Result {
|
||||
return Result{Status: StatusNotApplicable, Evidence: ev}
|
||||
}
|
||||
|
||||
// truncate limits oversized evidence values.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// errString returns "" for a nil error.
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
24
pkg/deviceagent/checks/status.go
Normal file
24
pkg/deviceagent/checks/status.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
package checks
|
||||
|
||||
// Status is the posture status sent to the agent API.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "PASS"
|
||||
StatusFail Status = "FAIL"
|
||||
StatusUnknown Status = "UNKNOWN"
|
||||
StatusNotApplicable Status = "NOT_APPLICABLE"
|
||||
)
|
||||
Reference in New Issue
Block a user