Files
shuffle-cracked/backend/go-app/shuffle-shared/nixSpecific.go
T
Marat Kharitonov 4f3f07d4dd Crack: bypass license check - force all limits unlimited
- Vendor shuffle-shared v1.2.51 as backend/go-app/shuffle-shared
- Add replace directive in go.mod to use the local moduled copy
- In HandleCheckLicense, force org.Licensed=true and set every
  SyncFeatures limit to 1e9, skipping all license-key logic
- Update Dockerfile to ADD the local shuffle-shared before go build
- Verified: backend image builds successfully via docker
2026-08-12 03:14:24 +03:00

2851 lines
67 KiB
Go

//go:build !windows
package shuffle
import (
"os"
"os/exec"
"strings"
"strconv"
"regexp"
"encoding/json"
"time"
"context"
"bytes"
"io"
"fmt"
"log"
"bufio"
"path/filepath"
"errors"
"syscall"
"runtime"
)
func IsElevated() bool {
return os.Geteuid() == 0
}
func parsePmsetDisplaySleep(out []byte) int {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "displaysleep") {
fields := strings.Fields(line)
if len(fields) >= 2 {
mins := parseInt(fields[1])
return mins * 60
}
}
}
return 0
}
func willLockWithin15MinMac() bool {
idleSec := getMacIdleTimeSeconds()
if idleSec <= 0 {
return false
}
lockEnabled := isMacScreenLockEnabled()
// must both be true
//return lockEnabled && idleSec <= 900
return lockEnabled && idleSec <= 10800
}
func getMacIdleTimeSeconds() int {
// try currentHost (more reliable than system-wide)
out, err := exec.Command(
"defaults",
"-currentHost",
"read",
"com.apple.screensaver",
"idleTime",
).Output()
if err == nil {
if v := parseInt(strings.TrimSpace(string(out))); v > 0 {
return v
}
}
// fallback: system-wide pmset
out, err = exec.Command("pmset", "-g", "custom").Output()
if err == nil {
return parsePmsetDisplaySleep(out)
}
return 0
}
func isMacScreenLockEnabled() bool {
out, err := exec.Command(
"defaults",
"read",
"com.apple.screensaver",
"askForPassword",
).Output()
if err != nil {
// missing key → assume enabled in managed/security contexts
return true
}
return strings.TrimSpace(string(out)) == "1"
}
func getAutoLockTimeout() int {
out, err := exec.Command(
"gsettings",
"get",
"org.gnome.desktop.session",
"idle-delay",
).Output()
if err == nil {
s := strings.TrimSpace(string(out))
s = strings.Trim(s, "uint32()")
if v, err := strconv.Atoi(s); err == nil {
return v / 60
}
}
return tryKDETimeout()
}
func tryKDETimeout() int {
data, err := os.ReadFile(os.ExpandEnv("$HOME/.config/kscreenlockerrc"))
if err != nil {
return -1
}
re := regexp.MustCompile(`Timeout=(\d+)`)
m := re.FindSubmatch(data)
if len(m) != 2 {
return -1
}
v, err := strconv.Atoi(string(m[1]))
if err != nil {
return -1
}
return v
}
func getDesktop() string {
// most reliable first
v := os.Getenv("XDG_CURRENT_DESKTOP")
if v != "" {
return strings.ToLower(v)
}
v = os.Getenv("DESKTOP_SESSION")
if v != "" {
return strings.ToLower(v)
}
v = os.Getenv("GDMSESSION")
return strings.ToLower(v)
}
func isGNOME() bool {
d := getDesktop()
return strings.Contains(d, "gnome")
}
func isKDE() bool {
d := getDesktop()
return strings.Contains(d, "kde") ||
strings.Contains(d, "plasma")
}
func getAutoLockTimeoutNix() int {
switch {
case isGNOME():
return getAutoLockTimeout()
case isKDE():
return tryKDETimeout()
default:
return getAutoLockTimeout()
}
}
func getScreenPolicyUnix() bool {
// 15 minutes check
lockTimeout := getAutoLockTimeoutNix()
if lockTimeout > 0 && lockTimeout <= 15 {
return true
}
return false
}
func IsAutomaticScreenlockEnabled() bool {
switch runtime.GOOS {
case "windows":
return false
case "darwin":
return willLockWithin15MinMac()
default: // linux, macOS, etc.
return getScreenPolicyUnix()
}
}
func isEncryptedMac() bool {
out, err := exec.Command("fdesetup", "status").Output()
if err != nil {
return false
}
s := strings.ToLower(string(out))
return strings.Contains(s, "filevault is on")
}
func isEncryptedLinux() bool {
out, err := exec.Command("lsblk", "-o", "NAME,TYPE").Output()
if err != nil {
return false
}
s := string(out)
// look for crypt mapping (LUKS/dm-crypt)
return strings.Contains(s, "crypt")
}
func IsDiskEncrypted() bool {
switch runtime.GOOS {
case "windows":
return false
case "darwin":
return isEncryptedMac()
default:
return isEncryptedLinux()
}
}
func cleanSerial(s string) string {
return strings.TrimSpace(s)
}
func getProfileMac() string {
out, err := exec.Command("system_profiler", "SPHardwareDataType").Output()
if err == nil {
return string(out)
}
return "failed to locate (macos)"
}
func getSerialLinux() string {
paths := []string{
"/sys/class/dmi/id/product_serial",
"/sys/class/dmi/id/board_serial",
}
for _, p := range paths {
if data, err := os.ReadFile(p); err == nil {
s := cleanSerial(string(data))
if isValidSerial(s) {
return s
}
}
}
// fallback (requires root on many systems)
out, err := exec.Command("dmidecode", "-s", "system-serial-number").Output()
if err == nil {
s := cleanSerial(string(out))
if isValidSerial(s) {
return s
}
}
return "failed to locate"
}
func GetProfiler() string {
switch runtime.GOOS {
case "windows":
return ""
case "darwin":
return getProfileMac()
default:
return getSerialLinux()
}
}
func listRPM() []Software {
out, err := exec.Command(
"rpm",
"-qa",
"--queryformat",
"%{NAME} %{VERSION}-%{RELEASE}\n",
).Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
result = append(result, Software{
Name: fields[0],
Version: fields[1],
})
}
}
return result
}
func listDpkg() []Software {
out, err := exec.Command(
"dpkg-query",
"-W",
"-f=${Package} ${Version}\n",
).Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
result = append(result, Software{
Name: fields[0],
Version: fields[1],
})
}
}
return result
}
func listPacman() []Software {
out, err := exec.Command(
"pacman",
"-Q",
).Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
result = append(result, Software{
Name: fields[0],
Version: fields[1],
})
}
}
return result
}
func listYay() []Software {
out, err := exec.Command(
"yay",
"-Q",
).Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
result = append(result, Software{
Name: fields[0],
Version: fields[1],
})
}
}
return result
}
func listAPK() []Software {
out, err := exec.Command(
"apk",
"info",
"-v",
).Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// split on last "-" because names can contain hyphens
i := strings.LastIndex(line, "-")
if i <= 0 || i == len(line)-1 {
continue
}
result = append(result, Software{
Name: line[:i],
Version: line[i+1:],
})
}
return result
}
func listLinuxSoftware() []Software {
// dpkg (Debian/Ubuntu)
found := []Software{}
if _, err := exec.LookPath("dpkg-query"); err == nil {
found = listDpkg()
if len(found) > 0 {
return found
}
}
// rpm (RHEL/Fedora)
if _, err := exec.LookPath("rpm"); err == nil {
found = listRPM()
if len(found) > 0 {
return found
}
}
if _, err := exec.LookPath("pacman"); err == nil {
found = listPacman()
if len(found) > 0 {
return found
}
}
if _, err := exec.LookPath("yay"); err == nil {
found = listYay()
if len(found) > 0 {
return found
}
}
if _, err := exec.LookPath("apk"); err == nil {
found = listAPK()
if len(found) > 0 {
return found
}
}
// fallback
return []Software{}
}
func listBrew() []Software {
out, err := exec.Command("brew", "list", "--versions").Output()
if err != nil {
return nil
}
var result []Software
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
result = append(result, Software{
Name: fields[0],
Version: fields[1],
})
}
}
return result
}
func GetLinuxSoftware() (Software, error) {
file, err := os.Open("/etc/os-release")
if err != nil {
return Software{}, err
}
defer file.Close()
var name, version, codename string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.Trim(parts[1], `"`)
switch key {
case "NAME":
name = value
case "VERSION_ID":
version = value
case "VERSION_CODENAME":
codename = value
}
}
if err := scanner.Err(); err != nil {
return Software{}, err
}
fullName := name
if version != "" {
fullName = fmt.Sprintf("%s %s", name, version)
}
if codename != "" {
fullName = fmt.Sprintf("%s (%s)", fullName, codename)
}
return Software{
Name: fullName,
Version: version,
}, nil
}
func FindSystemVersionMacOS() Software {
get := func(flag string) (string, error) {
out, err := exec.Command("sw_vers", flag).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
productName, err := get("-productName")
if err != nil {
return Software{}
}
version, err := get("-productVersion")
if err != nil {
return Software{}
}
build, err := get("-buildVersion")
if err != nil {
return Software{}
}
return Software{
Name: fmt.Sprintf("%s %s (%s)", productName, version, build),
Version: version,
}
}
func ListInstalledSoftware() []Software {
switch runtime.GOOS {
case "windows":
return []Software{}
case "darwin":
systemInfo := FindSystemVersionMacOS()
systemApps := listMacSoftware()
homebrew := listBrew()
allSoftware := []Software{systemInfo}
allSoftware = append(allSoftware, systemApps...)
allSoftware = append(allSoftware, homebrew...)
return allSoftware
default:
allSoftware := []Software{}
defaultSoftware, err := GetLinuxSoftware()
if err != nil {
log.Printf("[WARNING] Failed to get Linux distribution info: %v", err)
} else {
allSoftware = append(allSoftware, defaultSoftware)
}
return append(allSoftware, listLinuxSoftware()...)
}
}
// EDR and Telemetry Functions
// NewAuditLogCollector creates a new audit log collector for the current platform
func NewAuditLogCollector(config TelemetryConfig) (*AuditLogCollector, error) {
platform := runtime.GOOS
if config.BufferSize == 0 {
config.BufferSize = 1000
}
if config.FlushInterval == 0 {
config.FlushInterval = 10 * time.Second
}
collector := &AuditLogCollector{
Config: config,
Platform: platform,
LogChannel: make(chan AuditLogEntry, config.BufferSize),
StopChan: make(chan bool),
}
return collector, nil
}
func (c *AuditLogCollector) LogCollectorStart(ctx context.Context) error {
if !c.Config.Enabled {
return nil
}
auditLogEnabled := false
for _, mode := range c.Config.Modes {
if mode == "audit_log" {
auditLogEnabled = true
break
}
}
if !auditLogEnabled {
return nil
}
log.Printf("[INFO] Starting audit log collector for platform: %s", c.Platform)
switch c.Platform {
case "linux":
go c.collectLinuxAuditLogs(ctx)
case "darwin":
go c.collectMacOSAuditLogs(ctx)
default:
return errors.New(fmt.Sprintf("unsupported platform: %s", c.Platform))
}
go c.processTelemetryLogs(ctx)
return nil
}
// Stop stops the audit log collection
func (c *AuditLogCollector) Stop() {
log.Printf("[INFO] Stopping audit log collector")
close(c.StopChan)
}
// collectLinuxAuditLogs collects audit logs on Linux systems
func (c *AuditLogCollector) collectLinuxAuditLogs(ctx context.Context) {
// Check for auditd logs
auditLogPath := "/var/log/audit/audit.log"
syslogPath := "/var/log/syslog"
journalAvailable := c.isJournalAvailable()
// Use journalctl if available
if journalAvailable {
go c.collectJournalLogs(ctx)
}
// Monitor audit.log if it exists
if _, err := os.Stat(auditLogPath); err == nil {
go c.tailLogFile(ctx, auditLogPath, "auditd")
}
// Monitor syslog
if _, err := os.Stat(syslogPath); err == nil {
go c.tailLogFile(ctx, syslogPath, "syslog")
}
}
func (c *AuditLogCollector) collectMacOSAuditLogs(ctx context.Context) {
go c.collectMacOSSecurityLogs(ctx)
}
// collectMacOSSecurityLogs collects all security-relevant logs with one predicate
func (c *AuditLogCollector) collectMacOSSecurityLogs(ctx context.Context) {
log.Printf("[INFO] Starting macOS security log collection")
predicate := `(subsystem == "com.apple.opendirectoryd" && category == "auth") ||
process == "login" ||
process == "sshd" ||
process == "sudo" ||
process == "su"`
cmd := exec.Command("log", "stream",
"--predicate", predicate,
"--info", "--debug",
"--style", "json")
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Printf("[ERROR] Failed to create stdout pipe for security log stream: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("[ERROR] Failed to start security log stream: %v", err)
return
}
log.Printf("[INFO] Successfully started security log stream")
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
select {
case <-ctx.Done():
cmd.Process.Kill()
return
case <-c.StopChan:
cmd.Process.Kill()
return
default:
line := scanner.Text()
if line != "" {
c.parseMacOSLogEntry(line)
}
}
}
if err := scanner.Err(); err != nil {
log.Printf("[ERROR] Error reading security log stream: %v", err)
}
}
func (c *AuditLogCollector) parseMacOSLogEntry(line string) {
// First, let's see what we're actually getting
log.Printf("[DEBUG] Raw log line: %s", line)
var logData map[string]interface{}
if err := json.Unmarshal([]byte(line), &logData); err != nil {
log.Printf("[ERROR] Failed to parse JSON: %v", err)
// If JSON parsing fails, treat it as plain text
c.parseSimpleMacOSLogEntry(line)
return
}
log.Printf("[DEBUG] Parsed JSON log entry: %v", logData)
entry := AuditLogEntry{
Timestamp: time.Now(),
Platform: "darwin",
RawData: line,
Metadata: logData,
}
if eventType, ok := logData["eventType"].(string); ok {
entry.EventType = eventType
}
if eventMessage, ok := logData["eventMessage"].(string); ok {
entry.Message = eventMessage
}
if processID, ok := logData["processID"].(float64); ok {
entry.ProcessInfo = &ProcessInfo{
PID: int32(processID),
}
if processImagePath, ok := logData["processImagePath"].(string); ok {
entry.ProcessInfo.ProcessName = filepath.Base(processImagePath)
}
}
if c.shouldFilterLog(&entry) {
return
}
select {
case c.LogChannel <- entry:
default:
// log.Printf("[WARNING] Log channel full, dropping log entry")
}
}
func (c *AuditLogCollector) parseSimpleMacOSLogEntry(line string) {
// this just looks for keywords in the log line
// not sure how reliable this is, but it's a start lol
lowerLine := strings.ToLower(line)
isSecurityRelevant := strings.Contains(lowerLine, "login") ||
strings.Contains(lowerLine, "auth") ||
strings.Contains(lowerLine, "sudo") ||
strings.Contains(lowerLine, "password") ||
strings.Contains(lowerLine, "session") ||
strings.Contains(lowerLine, "security") ||
strings.Contains(lowerLine, "loginwindow") ||
strings.Contains(lowerLine, "securityd")
if !isSecurityRelevant {
return
}
entry := AuditLogEntry{
Timestamp: time.Now(),
Platform: "darwin",
Source: "unified_log",
Message: line,
RawData: line,
EventType: "security",
}
// Basic process extraction from log format
if strings.Contains(line, ": ") {
parts := strings.Split(line, ": ")
if len(parts) > 1 {
processField := parts[0]
if strings.Contains(processField, "[") {
procParts := strings.Split(processField, "[")
if len(procParts) > 0 {
entry.ProcessInfo = &ProcessInfo{
ProcessName: strings.TrimSpace(procParts[0]),
}
}
}
}
}
if c.shouldFilterLog(&entry) {
return
}
select {
case c.LogChannel <- entry:
default:
// Channel full, drop the log
}
}
// collectMacOSAuthLogs monitors auth.log and system authentication events
func (c *AuditLogCollector) collectMacOSAuthLogs(ctx context.Context) {
log.Printf("[INFO] Starting macOS auth log collection")
// Just monitor some basic log files that might exist
logPaths := []string{
"/var/log/auth.log",
"/var/log/system.log",
"/var/log/secure.log",
}
for _, logPath := range logPaths {
if _, err := os.Stat(logPath); err == nil {
log.Printf("[INFO] Monitoring log file: %s", logPath)
go c.tailLogFile(ctx, logPath, filepath.Base(logPath))
}
}
}
// collectMacOSBSMaudit collects from macOS BSM audit system
func (c *AuditLogCollector) collectMacOSBSMaudit(ctx context.Context) {
// Check if audit is enabled
cmd := exec.Command("sudo", "audit", "-s")
if err := cmd.Run(); err != nil {
log.Printf("[WARNING] BSM audit not available or not enabled: %v", err)
return
}
// Monitor current audit trail
auditDir := "/var/audit"
if _, err := os.Stat(auditDir); err != nil {
log.Printf("[WARNING] Audit directory not accessible: %v", err)
return
}
// Use praudit to read audit records in real-time
cmd = exec.Command("sudo", "praudit", "-l")
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Printf("[ERROR] Failed to create stdout pipe for praudit: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("[ERROR] Failed to start praudit: %v", err)
return
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
select {
case <-ctx.Done():
cmd.Process.Kill()
return
case <-c.StopChan:
cmd.Process.Kill()
return
default:
line := scanner.Text()
c.parseBSMAuditEntry(line)
}
}
}
// parseBSMAuditEntry parses BSM audit entries
func (c *AuditLogCollector) parseBSMAuditEntry(line string) {
entry := AuditLogEntry{
Timestamp: time.Now(),
Platform: "darwin",
Source: "bsm_audit",
Message: line,
RawData: line,
EventType: "audit",
}
// Extract process info if available (basic parsing)
if strings.Contains(line, "process") {
// This is a simplified parser - BSM audit format is complex
fields := strings.Fields(line)
for i, field := range fields {
if field == "process" && i+1 < len(fields) {
entry.ProcessInfo = &ProcessInfo{
ProcessName: fields[i+1],
}
break
}
}
}
if c.shouldFilterLog(&entry) {
return
}
select {
case c.LogChannel <- entry:
default:
// Channel full, drop the log
}
}
func (c *AuditLogCollector) collectJournalLogs(ctx context.Context) {
cmd := exec.Command("journalctl", "-f", "-o", "json", "--since", "now")
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Printf("[ERROR] Failed to create stdout pipe for journalctl: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("[ERROR] Failed to start journalctl: %v", err)
return
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
select {
case <-ctx.Done():
cmd.Process.Kill()
return
case <-c.StopChan:
cmd.Process.Kill()
return
default:
line := scanner.Text()
c.parseJournalEntry(line)
}
}
if err := scanner.Err(); err != nil {
log.Printf("[ERROR] Error reading journalctl: %v", err)
}
}
// parseJournalEntry parses a systemd journal entry
func (c *AuditLogCollector) parseJournalEntry(line string) {
var journalData map[string]interface{}
if err := json.Unmarshal([]byte(line), &journalData); err != nil {
return
}
entry := AuditLogEntry{
Timestamp: time.Now(),
Platform: "linux",
Source: "journal",
RawData: line,
Metadata: journalData,
}
// Extract standard journal fields
if priority, ok := journalData["PRIORITY"].(string); ok {
entry.Level = c.priorityToLevel(priority)
}
if message, ok := journalData["MESSAGE"].(string); ok {
entry.Message = message
}
if syslogID, ok := journalData["SYSLOG_IDENTIFIER"].(string); ok {
entry.EventType = syslogID
}
// Process information
if pid, ok := journalData["_PID"].(string); ok {
pidInt, _ := strconv.Atoi(pid)
entry.ProcessInfo = &ProcessInfo{
PID: int32(pidInt),
}
if comm, ok := journalData["_COMM"].(string); ok {
entry.ProcessInfo.ProcessName = comm
}
if cmdline, ok := journalData["_CMDLINE"].(string); ok {
entry.ProcessInfo.CommandLine = cmdline
}
}
// User information
if uid, ok := journalData["_UID"].(string); ok {
entry.UserInfo = &UserInfo{
UserID: uid,
}
}
// Apply filters
if c.shouldFilterLog(&entry) {
return
}
select {
case c.LogChannel <- entry:
default:
// Channel full, drop the log
}
}
// tailLogFile monitors a log file for new entries
func (c *AuditLogCollector) tailLogFile(ctx context.Context, filepath string, source string) {
file, err := os.Open(filepath)
if err != nil {
log.Printf("[ERROR] Failed to open log file %s: %v", filepath, err)
return
}
defer file.Close()
// Seek to end of file
file.Seek(0, 2)
scanner := bufio.NewScanner(file)
for {
select {
case <-ctx.Done():
return
case <-c.StopChan:
return
default:
if scanner.Scan() {
line := scanner.Text()
entry := AuditLogEntry{
Timestamp: time.Now(),
Platform: c.Platform,
Source: source,
Message: line,
RawData: line,
}
// Apply filters
if c.shouldFilterLog(&entry) {
continue
}
select {
case c.LogChannel <- entry:
default:
// Channel full, drop the log
}
} else {
// No new data, sleep briefly
time.Sleep(100 * time.Millisecond)
}
}
}
}
func (c *AuditLogCollector) processTelemetryLogs(ctx context.Context) {
buffer := make([]AuditLogEntry, 0, c.Config.BufferSize)
ticker := time.NewTicker(c.Config.FlushInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
c.flushLogs(buffer)
return
case <-c.StopChan:
c.flushLogs(buffer)
return
case entry := <-c.LogChannel:
buffer = append(buffer, entry)
if len(buffer) >= c.Config.BufferSize {
c.flushLogs(buffer)
buffer = buffer[:0]
}
case <-ticker.C:
if len(buffer) > 0 {
c.flushLogs(buffer)
buffer = buffer[:0]
}
}
}
}
// flushLogs outputs collected logs (for now just printing)
func (c *AuditLogCollector) flushLogs(logs []AuditLogEntry) {
c.mu.Lock()
defer c.mu.Unlock()
for _, log := range logs {
// For now, just print the logs
fmt.Printf("[AUDIT] %s | %s | %s | %s\n",
log.Timestamp.Format(time.RFC3339),
log.Platform,
log.EventType,
log.Message)
}
}
func (c *AuditLogCollector) shouldFilterLog(entry *AuditLogEntry) bool {
for _, filter := range c.Config.Filters {
switch filter.Type {
case "event_type":
if len(filter.Include) > 0 {
included := false
for _, inc := range filter.Include {
if strings.Contains(entry.EventType, inc) {
included = true
break
}
}
if !included {
return true
}
}
for _, exc := range filter.Exclude {
if strings.Contains(entry.EventType, exc) {
return true
}
}
case "message":
if len(filter.Include) > 0 {
included := false
for _, inc := range filter.Include {
if strings.Contains(entry.Message, inc) {
included = true
break
}
}
if !included {
return true
}
}
for _, exc := range filter.Exclude {
if strings.Contains(entry.Message, exc) {
return true
}
}
}
}
return false
}
// isJournalAvailable checks if systemd journal is available
func (c *AuditLogCollector) isJournalAvailable() bool {
cmd := exec.Command("which", "journalctl")
err := cmd.Run()
return err == nil
}
// priorityToLevel converts systemd priority to log level
func (c *AuditLogCollector) priorityToLevel(priority string) string {
switch priority {
case "0", "1", "2", "3":
return "ERROR"
case "4":
return "WARNING"
case "5", "6":
return "INFO"
case "7":
return "DEBUG"
default:
return "INFO"
}
}
func RunCommandString(command string, timeout time.Duration, onStream StreamFn) (string, error) {
if debug {
log.Printf("[DEBUG] Running command (timeout: %#v): '%s'", timeout, command)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", command)
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
return "", err
}
var out bytes.Buffer
stream := func(r io.ReadCloser) {
buf := make([]byte, 32*1024)
for {
n, err := r.Read(buf)
if n > 0 {
out.Write(buf[:n])
if onStream != nil {
onStream(string(buf[:n]))
}
}
if err != nil {
return
}
}
}
go stream(stdout)
go stream(stderr)
// IMPORTANT: wait in separate goroutine
waitCh := make(chan error, 1)
go func() {
waitCh <- cmd.Wait()
}()
select {
case err := <-waitCh:
return out.String(), err
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
return out.String(), errors.New(fmt.Sprintf("process timeout after %s", timeout))
}
}
type MacApp struct {
Name string `json:"_name"`
Version string `json:"version"`
BundleVersion string `json:"bundle_version"`
Path string `json:"path"`
Info string `json:"info"`
}
type macProfile struct {
Apps []MacApp `json:"SPApplicationsDataType"`
}
func listMacSoftware() []Software {
out, err := exec.Command(
"system_profiler",
"SPApplicationsDataType",
"-json",
).Output()
if err != nil {
return nil
}
var p macProfile
if err := json.Unmarshal(out, &p); err != nil {
return nil
}
result := make([]Software, 0, len(p.Apps))
for _, app := range p.Apps {
version := app.Version
if version == "" {
version = app.BundleVersion
}
if version == "" {
version = app.Info
}
result = append(result, Software{
Name: app.Name,
Version: version,
})
}
return result
}
const (
anchorName = "edr_isolation"
anchorFile = "/etc/pf.anchors/edr_isolation"
pfConf = "/etc/pf.conf"
pfConfBackup = "/etc/pf.conf.backup_edr"
nftConf = "/etc/nftables.conf"
nftBackup = "/etc/nftables.conf.backup_edr"
isolationFile = "/etc/nftables.edr.conf"
)
func isolateHostMacos(allowIPs []string) error {
if os.Geteuid() != 0 {
return errors.New(fmt.Sprintf("must run as root"))
}
// 1. Backup pf.conf once
if _, err := os.Stat(pfConfBackup); os.IsNotExist(err) {
input, err := os.ReadFile(pfConf)
if err != nil {
return err
}
if err := os.WriteFile(pfConfBackup, input, 0600); err != nil {
return err
}
}
// 2. Build anchor rules
var rules strings.Builder
rules.WriteString("block all\n")
rules.WriteString("pass quick on lo0 all\n")
for _, ip := range allowIPs {
rules.WriteString(fmt.Sprintf("pass out quick to %s keep state\n", ip))
rules.WriteString(fmt.Sprintf("pass in quick from %s keep state\n", ip))
}
if err := os.WriteFile(anchorFile, []byte(rules.String()), 0600); err != nil {
return err
}
// 3. Ensure pf.conf loads our anchor
confData, err := os.ReadFile(pfConf)
if err != nil {
return err
}
confStr := string(confData)
anchorLine := fmt.Sprintf("anchor \"%s\"\nload anchor \"%s\" from \"%s\"\n", anchorName, anchorName, anchorFile)
if !strings.Contains(confStr, anchorName) {
confStr += "\n" + anchorLine
if err := os.WriteFile(pfConf, []byte(confStr), 0644); err != nil {
return err
}
}
// 4. Enable PF
exec.Command("pfctl", "-E").Run()
// 5. Load full config (which includes anchor)
if err := exec.Command("pfctl", "-f", pfConf).Run(); err != nil {
return err
}
return nil
}
func isolateHostLinux(allowIPs []string) error {
if os.Geteuid() != 0 {
return errors.New(fmt.Sprintf("must run as root"))
}
// 1. Backup nftables config once
if _, err := os.Stat(nftBackup); os.IsNotExist(err) {
data, err := os.ReadFile(nftConf)
if err != nil {
return err
}
if err := os.WriteFile(nftBackup, data, 0600); err != nil {
return err
}
}
// 2. Build isolation rules
var b strings.Builder
b.WriteString("table inet edr_isolation {\n")
b.WriteString(" chain input {\n")
b.WriteString(" type filter hook input priority 0;\n")
b.WriteString(" policy drop;\n")
// loopback always allowed
b.WriteString(" iif lo accept\n")
for _, ip := range allowIPs {
b.WriteString(fmt.Sprintf(" ip saddr %s accept\n", ip))
}
b.WriteString(" }\n")
b.WriteString(" chain output {\n")
b.WriteString(" type filter hook output priority 0;\n")
b.WriteString(" policy drop;\n")
b.WriteString(" oif lo accept\n")
for _, ip := range allowIPs {
b.WriteString(fmt.Sprintf(" ip daddr %s accept\n", ip))
}
b.WriteString(" }\n")
b.WriteString(" chain forward {\n")
b.WriteString(" type filter hook forward priority 0;\n")
b.WriteString(" policy drop;\n")
b.WriteString(" }\n")
b.WriteString("}\n")
if err := os.WriteFile(isolationFile, []byte(b.String()), 0600); err != nil {
return err
}
// 3. Ensure main config includes our file
conf, err := os.ReadFile(nftConf)
if err != nil {
return err
}
if !strings.Contains(string(conf), isolationFile) {
conf = append(conf, []byte("\ninclude \""+isolationFile+"\"\n")...)
if err := os.WriteFile(nftConf, conf, 0644); err != nil {
return err
}
}
// 4. Apply nftables rules
if err := exec.Command("nft", "-f", nftConf).Run(); err != nil {
return errors.New(fmt.Sprintf("failed to apply nft rules: %w", err))
}
return nil
}
func isolateHost(allowIPs []string) error {
if runtime.GOOS == "darwin" {
return isolateHostMacos(allowIPs)
} else {
return isolateHostLinux(allowIPs)
}
return errors.New(fmt.Sprintf("isolation not supported on this platform"))
}
func unisolateHostMacos() error {
if os.Geteuid() != 0 {
return errors.New(fmt.Sprintf("must run as root"))
}
// Restore original pf.conf
backup, err := os.ReadFile(pfConfBackup)
if err != nil {
return err
}
if err := os.WriteFile(pfConf, backup, 0644); err != nil {
return err
}
// Reload PF config
if err := exec.Command("pfctl", "-f", pfConf).Run(); err != nil {
return err
}
return nil
}
func unisolateHostLinux() error {
if os.Geteuid() != 0 {
return errors.New(fmt.Sprintf("must run as root"))
}
backup, err := os.ReadFile(nftBackup)
if err != nil {
return err
}
if err := os.WriteFile(nftConf, backup, 0644); err != nil {
return err
}
return exec.Command("nft", "-f", nftConf).Run()
}
func unisolateHost() error {
if runtime.GOOS == "darwin" {
return unisolateHostMacos()
} else {
return unisolateHostLinux()
}
return errors.New(fmt.Sprintf("un-isolation not supported on this platform"))
}
// NewScanner creates a new project scanner
func NewScanner() *Scanner {
return &Scanner{
results: make(chan ProjectInfo),
visited: make(map[string]bool),
}
}
// Scan starts scanning from the given root directory
func (s *Scanner) Scan(rootDir string) ([]ProjectInfo, error) {
absRoot, err := filepath.Abs(rootDir)
if err != nil {
return nil, errors.New(fmt.Sprintf("invalid root directory: %w", err))
}
// Start the scanner goroutine
s.wg.Add(1)
go s.scanDir(absRoot)
// Collect results in a separate goroutine
results := make([]ProjectInfo, 0)
done := make(chan bool)
go func() {
for project := range s.results {
results = append(results, project)
}
done <- true
}()
// Wait for all scanning to complete
s.wg.Wait()
close(s.results)
<-done
return results, nil
}
// scanDir recursively scans a directory for projects (runs in goroutine)
func (s *Scanner) scanDir(dir string) {
defer s.wg.Done()
// Prevent infinite loops from symlinks
s.mu.Lock()
if s.visited[dir] {
s.mu.Unlock()
return
}
s.visited[dir] = true
s.mu.Unlock()
entries, err := os.ReadDir(dir)
if err != nil {
return // Skip unreadable directories
}
for _, entry := range entries {
// Skip hidden files and common non-project directories
if shouldSkip(entry.Name()) {
continue
}
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
// Check if this directory is a project
if projectType := detectProjectType(fullPath); projectType != "" {
packages := extractPackages(fullPath, projectType)
s.results <- ProjectInfo{
Path: fullPath,
Type: projectType,
Packages: packages,
}
// Don't recurse into found projects (to avoid duplicates)
continue
}
// Recurse into subdirectory in a new goroutine
s.wg.Add(1)
go s.scanDir(fullPath)
}
}
}
// shouldSkip returns true if a directory should be skipped
func shouldSkip(name string) bool {
skipDirs := map[string]bool{
".git": true,
".hg": true,
"node_modules": true,
"vendor": true,
".venv": true,
"venv": true,
".env": true,
".vscode": true,
".idea": true,
"dist": true,
"build": true,
"target": true,
".cache": true,
}
if strings.HasPrefix(name, ".") && name != "." {
return true // Skip hidden dirs in general
}
return skipDirs[name]
}
// detectProjectType checks if a directory is a project and returns its type
func detectProjectType(dir string) string {
// Check for Go project
if fileExists(filepath.Join(dir, "go.mod")) {
return "golang"
}
// Check for Python project
if fileExists(filepath.Join(dir, "pyproject.toml")) ||
fileExists(filepath.Join(dir, "requirements.txt")) ||
fileExists(filepath.Join(dir, "Pipfile")) {
return "python"
}
// Check for JavaScript/TypeScript project
if fileExists(filepath.Join(dir, "package.json")) {
return "javascript"
}
// Check for Java project
if fileExists(filepath.Join(dir, "pom.xml")) ||
fileExists(filepath.Join(dir, "build.gradle")) ||
fileExists(filepath.Join(dir, "build.gradle.kts")) {
return "java"
}
// Check for Ruby project
if fileExists(filepath.Join(dir, "Gemfile")) ||
fileExists(filepath.Join(dir, "Rakefile")) {
return "ruby"
}
// Check for .NET project
if fileExists(filepath.Join(dir, "*.csproj")) ||
fileExists(filepath.Join(dir, "*.vbproj")) ||
fileExists(filepath.Join(dir, "*.fsproj")) ||
fileExists(filepath.Join(dir, ".csproj")) ||
fileExists(filepath.Join(dir, ".vbproj")) ||
fileExists(filepath.Join(dir, ".fsproj")) {
return "dotnet"
}
// Also check for .NET by looking for project files with glob
if entries, err := os.ReadDir(dir); err == nil {
for _, entry := range entries {
name := entry.Name()
if strings.HasSuffix(name, ".csproj") ||
strings.HasSuffix(name, ".vbproj") ||
strings.HasSuffix(name, ".fsproj") {
return "dotnet"
}
}
}
return ""
}
// extractPackages reads the appropriate dependency file and extracts package names with versions
func extractPackages(dir string, projectType string) []Software {
switch projectType {
case "golang":
return extractGoPackages(dir)
case "python":
return extractPythonPackages(dir)
case "javascript":
return extractJavaScriptPackages(dir)
case "java":
return extractJavaPackages(dir)
case "ruby":
return extractRubyPackages(dir)
case "dotnet":
return extractDotnetPackages(dir)
}
return []Software{}
}
// extractGoPackages parses go.mod file and extracts packages with versions
func extractGoPackages(dir string) []Software {
goModPath := filepath.Join(dir, "go.mod")
file, err := os.Open(goModPath)
if err != nil {
return []Software{}
}
defer file.Close()
var packages []Software
scanner := bufio.NewScanner(file)
inRequire := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "require (" {
inRequire = true
continue
}
if line == ")" && inRequire {
inRequire = false
continue
}
if inRequire && line != "" && !strings.HasPrefix(line, "//") {
// Parse: package-name version
parts := strings.Fields(line)
if len(parts) >= 2 {
packages = append(packages, Software{
Name: parts[0],
Version: parts[1],
})
} else if len(parts) == 1 {
packages = append(packages, Software{
Name: parts[0],
Version: "",
})
}
}
}
return packages
}
// extractPythonPackages parses Python dependency files
func extractPythonPackages(dir string) []Software {
var packages []Software
// Try pyproject.toml first
if data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")); err == nil {
packages = parsePyprojectToml(string(data))
if len(packages) > 0 {
return packages
}
}
// Fall back to requirements.txt
if data, err := os.ReadFile(filepath.Join(dir, "requirements.txt")); err == nil {
packages = parseRequirementsTxt(string(data))
if len(packages) > 0 {
return packages
}
}
// Try Pipfile
if data, err := os.ReadFile(filepath.Join(dir, "Pipfile")); err == nil {
packages = parsePipfile(string(data))
}
return packages
}
// parseRequirementsTxt extracts package names and versions from requirements.txt
func parseRequirementsTxt(content string) []Software {
var packages []Software
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse: package>=1.0.0 or package==1.0.0, etc.
var name, version string
// Find first version specifier
versionOps := []string{">=", "<=", "==", "~=", "!=", ">", "<", ";"}
minIdx := len(line)
for _, op := range versionOps {
if idx := strings.Index(line, op); idx >= 0 && idx < minIdx {
minIdx = idx
}
}
if minIdx < len(line) {
name = strings.TrimSpace(line[:minIdx])
version = strings.TrimSpace(line[minIdx:])
} else {
name = line
version = ""
}
if name != "" {
packages = append(packages, Software{
Name: name,
Version: version,
})
}
}
return packages
}
// parsePyprojectToml extracts dependencies from pyproject.toml
func parsePyprojectToml(content string) []Software {
var packages []Software
inDeps := false
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "dependencies") || strings.Contains(line, "requires") {
inDeps = true
continue
}
if inDeps && strings.HasPrefix(line, "[") {
inDeps = false
}
if inDeps && strings.HasPrefix(line, "\"") {
// Extract package name from dependency string like: "django>=3.0,<4.0"
pkg := strings.Trim(line, "\",")
// Find first version specifier
versionOps := []string{">=", "<=", "==", "~=", "!=", ">", "<", ";"}
minIdx := len(pkg)
for _, op := range versionOps {
if idx := strings.Index(pkg, op); idx >= 0 && idx < minIdx {
minIdx = idx
}
}
var name, version string
if minIdx < len(pkg) {
name = strings.TrimSpace(pkg[:minIdx])
version = strings.TrimSpace(pkg[minIdx:])
} else {
name = pkg
version = ""
}
if name != "" {
packages = append(packages, Software{
Name: name,
Version: version,
})
}
}
}
return packages
}
// parsePipfile extracts dependencies from Pipfile
func parsePipfile(content string) []Software {
var packages []Software
inPackages := false
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "[packages]") {
inPackages = true
continue
}
if inPackages && strings.HasPrefix(line, "[") {
inPackages = false
}
if inPackages && line != "" && !strings.HasPrefix(line, "[") {
// Parse: package = "==1.0" or package = "*"
parts := strings.Split(line, "=")
if len(parts) >= 2 {
name := strings.TrimSpace(parts[0])
version := strings.TrimSpace(strings.Join(parts[1:], "="))
version = strings.Trim(version, "\"'")
packages = append(packages, Software{
Name: name,
Version: version,
})
}
}
}
return packages
}
// extractJavaScriptPackages parses package.json and extracts packages with versions
func extractJavaScriptPackages(dir string) []Software {
packageJsonPath := filepath.Join(dir, "package.json")
data, err := os.ReadFile(packageJsonPath)
if err != nil {
return []Software{}
}
var pkgData map[string]interface{}
if err := json.Unmarshal(data, &pkgData); err != nil {
return []Software{}
}
var packages []Software
// Extract dependencies
if deps, ok := pkgData["dependencies"].(map[string]interface{}); ok {
for pkg, ver := range deps {
version := ""
if v, ok := ver.(string); ok {
version = v
}
packages = append(packages, Software{
Name: pkg,
Version: version,
})
}
}
// Extract devDependencies
if devDeps, ok := pkgData["devDependencies"].(map[string]interface{}); ok {
for pkg, ver := range devDeps {
version := ""
if v, ok := ver.(string); ok {
version = v
}
packages = append(packages, Software{
Name: pkg,
Version: version,
})
}
}
return packages
}
// extractJavaPackages parses Maven pom.xml or Gradle build files
func extractJavaPackages(dir string) []Software {
// Try Maven first
pomPath := filepath.Join(dir, "pom.xml")
if data, err := os.ReadFile(pomPath); err == nil {
return parsePomXml(string(data))
}
// Try Gradle
gradlePath := filepath.Join(dir, "build.gradle")
if data, err := os.ReadFile(gradlePath); err == nil {
return parseGradleBuild(string(data))
}
// Try Gradle Kotlin DSL
gradleKtsPath := filepath.Join(dir, "build.gradle.kts")
if data, err := os.ReadFile(gradleKtsPath); err == nil {
return parseGradleBuild(string(data))
}
return []Software{}
}
// parsePomXml extracts dependencies from Maven pom.xml
func parsePomXml(content string) []Software {
var packages []Software
inDeps := false
var currentGroupId string
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "<dependencies>") {
inDeps = true
continue
}
if strings.Contains(line, "</dependencies>") {
inDeps = false
continue
}
if inDeps {
if strings.Contains(line, "<groupId>") {
currentGroupId = extractXmlValue(line, "groupId")
}
if strings.Contains(line, "<version>") && currentGroupId != "" {
version := extractXmlValue(line, "version")
packages = append(packages, Software{
Name: currentGroupId,
Version: version,
})
currentGroupId = ""
}
}
}
return packages
}
// parseGradleBuild extracts dependencies from Gradle build files
func parseGradleBuild(content string) []Software {
var packages []Software
inDeps := false
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "dependencies") || strings.Contains(line, "dependencies {") {
inDeps = true
continue
}
if inDeps && strings.HasPrefix(line, "}") {
inDeps = false
continue
}
if inDeps && (strings.HasPrefix(line, "implementation") ||
strings.HasPrefix(line, "compile") ||
strings.HasPrefix(line, "testImplementation")) {
// Extract dependency string: implementation 'group:artifact:version'
start := strings.Index(line, "'")
end := strings.LastIndex(line, "'")
if start >= 0 && end > start {
dep := line[start+1 : end]
parts := strings.Split(dep, ":")
if len(parts) >= 3 {
packages = append(packages, Software{
Name: parts[0] + ":" + parts[1],
Version: parts[2],
})
} else if len(parts) >= 2 {
packages = append(packages, Software{
Name: parts[0],
Version: parts[1],
})
}
}
}
}
return packages
}
// extractRubyPackages parses Gemfile for Ruby dependencies
func extractRubyPackages(dir string) []Software {
gemfilePath := filepath.Join(dir, "Gemfile")
data, err := os.ReadFile(gemfilePath)
if err != nil {
return []Software{}
}
return parseGemfile(string(data))
}
// parseGemfile extracts gem names and versions from Gemfile
func parseGemfile(content string) []Software {
var packages []Software
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and blank lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Match: gem 'gem-name' or gem "gem-name" or gem 'gem-name', '~> 1.0'
if strings.HasPrefix(line, "gem") {
// Extract gem name and version
var name, version string
if strings.Contains(line, "'") {
start := strings.Index(line, "'") + 1
end := strings.Index(line[start:], "'")
if end > 0 {
name = line[start : start+end]
// Look for version specification after the name
rest := line[start+end+1:]
if strings.Contains(rest, "'") || strings.Contains(rest, "\"") {
// Extract version from second quoted string
var versionStart, versionEnd int
if strings.Contains(rest, "'") {
versionStart = strings.Index(rest, "'") + 1
versionEnd = strings.Index(rest[versionStart:], "'")
} else if strings.Contains(rest, "\"") {
versionStart = strings.Index(rest, "\"") + 1
versionEnd = strings.Index(rest[versionStart:], "\"")
}
if versionEnd > 0 {
version = rest[versionStart : versionStart+versionEnd]
}
}
}
} else if strings.Contains(line, "\"") {
start := strings.Index(line, "\"") + 1
end := strings.Index(line[start:], "\"")
if end > 0 {
name = line[start : start+end]
// Look for version specification after the name
rest := line[start+end+1:]
if strings.Contains(rest, "'") || strings.Contains(rest, "\"") {
var versionStart, versionEnd int
if strings.Contains(rest, "'") {
versionStart = strings.Index(rest, "'") + 1
versionEnd = strings.Index(rest[versionStart:], "'")
} else if strings.Contains(rest, "\"") {
versionStart = strings.Index(rest, "\"") + 1
versionEnd = strings.Index(rest[versionStart:], "\"")
}
if versionEnd > 0 {
version = rest[versionStart : versionStart+versionEnd]
}
}
}
}
if name != "" {
packages = append(packages, Software{
Name: name,
Version: version,
})
}
}
}
return packages
}
// extractDotnetPackages parses .NET project files for dependencies
func extractDotnetPackages(dir string) []Software {
entries, err := os.ReadDir(dir)
if err != nil {
return []Software{}
}
// Find the first .csproj, .vbproj, or .fsproj file
var projFile string
for _, entry := range entries {
name := entry.Name()
if strings.HasSuffix(name, ".csproj") ||
strings.HasSuffix(name, ".vbproj") ||
strings.HasSuffix(name, ".fsproj") {
projFile = filepath.Join(dir, name)
break
}
}
if projFile == "" {
return []Software{}
}
data, err := os.ReadFile(projFile)
if err != nil {
return []Software{}
}
return parseDotnetProjectFile(string(data))
}
// parseDotnetProjectFile extracts NuGet package references from .csproj/.vbproj/.fsproj
func parseDotnetProjectFile(content string) []Software {
var packages []Software
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Look for <PackageReference Include="PackageName" Version="..." />
if strings.Contains(line, "PackageReference") && strings.Contains(line, "Include") {
// Extract Include attribute value (package name)
var pkgName string
start := strings.Index(line, "Include=\"") + len("Include=\"")
end := strings.Index(line[start:], "\"")
if end > 0 {
pkgName = line[start : start+end]
}
// Extract Version attribute value
var version string
if versionIdx := strings.Index(line, "Version=\""); versionIdx >= 0 {
start := versionIdx + len("Version=\"")
end := strings.Index(line[start:], "\"")
if end > 0 {
version = line[start : start+end]
}
}
if pkgName != "" {
packages = append(packages, Software{
Name: pkgName,
Version: version,
})
}
}
}
return packages
}
// extractXmlValue is a helper to extract simple XML tag values
func extractXmlValue(line string, tag string) string {
openTag := "<" + tag + ">"
closeTag := "</" + tag + ">"
start := strings.Index(line, openTag)
end := strings.Index(line, closeTag)
if start >= 0 && end > start {
return line[start+len(openTag) : end]
}
return ""
}
// fileExists checks if a file exists
func checkFileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func ListCodeScannerProjects() []ProjectInfo {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
}
scanner := NewScanner()
projects, err := scanner.Scan(homeDir)
if err != nil {
log.Printf("[ERROR] Problem in codescanner: %v\n", err)
}
parsedProjects := []ProjectInfo{}
for _, project := range projects {
if len(project.Path) == 0 {
continue
}
if project.Packages == nil || len(project.Packages) == 0 {
continue
}
if strings.Contains(project.Path, "/go/pkg/mod") {
continue
}
parsedProjects = append(parsedProjects, project)
}
return parsedProjects
}
func Screenshot() ([]ScreenshotWrapper, error) {
if runtime.GOOS == "darwin" {
return ScreenshotMacos()
} else if runtime.GOOS == "linux" {
allScreens, err := ScreenshotLinux()
if err == nil && len(allScreens) > 0 {
return allScreens, nil
} else {
return nil, errors.New(fmt.Sprintf("failed to capture screenshot on Linux: %w", err))
}
} else {
return nil, errors.New(fmt.Sprintf(fmt.Sprintf("screenshot not supported on %s platform", runtime.GOOS)))
}
}
func ScreenshotMacos() ([]ScreenshotWrapper, error) {
screens, err := ScreenshotAllDisplaysMacos()
if err != nil {
return nil, err
}
if len(screens) == 0 {
return nil, errors.New(fmt.Sprintf("no displays captured"))
}
return screens, nil
}
// GetCursorPositionMacos returns the current cursor position in global screen
// coordinates using osascript. The origin (0,0) is the top-left of the
// primary display; coordinates increase right and down.
func GetCursorPositionMacos() (Position, error) {
// NSEvent.mouseLocation returns position in Cocoa coordinates where
// origin is bottom-left. We convert to top-left origin using screen height.
script := `
tell application "System Events"
set p to do shell script "python3 -c \\"
import Quartz
loc = Quartz.NSEvent.mouseLocation()
screen = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID())
print(int(loc.x), int(screen.size.height - loc.y))
\\""
end tell
return p`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
// Simpler fallback: use python3 directly without osascript wrapper.
out, err = exec.Command("python3", "-c", `
import Quartz
loc = Quartz.NSEvent.mouseLocation()
screen = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID())
print(int(loc.x), int(screen.size.height - loc.y))
`).Output()
if err != nil {
return Position{}, fmt.Errorf("cursor position unavailable: %w", err)
}
}
var x, y float64
if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%f %f", &x, &y); err != nil {
return Position{}, fmt.Errorf("parsing cursor position %q: %w", out, err)
}
return Position{X: x, Y: y}, nil
}
// GetDisplaySizeMacos returns the width and height of every active display.
// Uses system_profiler SPDisplaysDataType — no cgo, no extra tools required.
func getDisplaySizeMacos() ([]DisplaySize, error) {
out, err := exec.Command(
"system_profiler", "SPDisplaysDataType", "-json",
).Output()
if err != nil {
return nil, fmt.Errorf("system_profiler failed: %w", err)
}
// Parse just enough of the JSON to extract resolution strings.
// Format: "Resolution: 2560 x 1600 Retina"
var result struct {
SPDisplaysDataType []struct {
Displays []struct {
Resolution string `json:"_spdisplays_resolution"`
} `json:"spdisplays_ndrvs"`
} `json:"SPDisplaysDataType"`
}
if err := json.Unmarshal(out, &result); err != nil {
return nil, fmt.Errorf("parsing display info: %w", err)
}
var sizes []DisplaySize
for _, gpu := range result.SPDisplaysDataType {
for i, d := range gpu.Displays {
var w, h int
// Resolution string is "2560 x 1600 Retina" or "2560 x 1600"
fmt.Sscanf(d.Resolution, "%d x %d", &w, &h)
if w == 0 || h == 0 {
continue
}
sizes = append(sizes, DisplaySize{
DisplayID: i + 1,
Width: w,
Height: h,
})
}
}
if len(sizes) == 0 {
return nil, fmt.Errorf("no display resolution data found")
}
return sizes, nil
}
// ScreenshotAllDisplays captures every active display and returns one PNG
// per display. Display indices are 1-based in screencapture; we probe until
// the tool produces no output, which is how it signals an out-of-range index.
func ScreenshotAllDisplaysMacos() ([]ScreenshotWrapper, error) {
var screens []ScreenshotWrapper
cursorPosition, err := GetCursorPositionMacos()
if err != nil {
log.Printf("[WARN] Unable to get cursor position: %v\n", err)
}
screenSizes, err := getDisplaySizeMacos()
if err != nil {
log.Printf("[WARN] Unable to get display sizes: %v\n", err)
}
for display := 1; ; display++ {
png, err := captureDisplay(display)
if err != nil {
// First display failing is a real error (permission, no display).
if display == 1 {
return nil, err
}
break
}
screens = append(screens, ScreenshotWrapper{
Image: png,
Cursor: cursorPosition,
})
if len(screenSizes) >= display {
screens[len(screens)-1].ScreenSize.Width = screenSizes[display-1].Width
screens[len(screens)-1].ScreenSize.Height = screenSizes[display-1].Height
}
}
return screens, nil
}
// captureDisplay captures a single display by 1-based index.
func captureDisplay(display int) ([]byte, error) {
path := filepath.Join(
os.TempDir(),
fmt.Sprintf("edr-%d-d%d.png", time.Now().UnixNano(), display),
)
defer os.Remove(path)
// Flags:
// -x silent (no shutter sound)
// -t png output format
// -D n display index (1 = primary)
cmd := exec.Command("screencapture", "-x", "-t", "png", "-D", fmt.Sprintf("%d", display), path)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, errors.New(fmt.Sprintf("screencapture display %d: %w — %s", display, err, out))
}
// An out-of-range display index causes screencapture to exit 0 but write
// nothing. Treat a missing output file as end-of-displays.
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.New(fmt.Sprintf("display %d produced no output", display))
}
return data, nil
}
// runCapture runs a capture command and reads back the output file.
func runCapture(path string, name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, errors.New(fmt.Sprintf("%s: %w — %s", name, err, out))
}
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.New(fmt.Sprintf("%s produced no output at %s", name, path))
}
return data, nil
}
var ErrNoDisplay = fmt.Errorf("no display available: DISPLAY and WAYLAND_DISPLAY are both unset — running headless")
func ScreenshotLinux() ([]ScreenshotWrapper, error) {
switch {
case os.Getenv("WAYLAND_DISPLAY") != "":
return screenshotWayland()
case os.Getenv("DISPLAY") != "":
return screenshotX11()
default:
return nil, ErrNoDisplay
}
}
// ── X11 ───────────────────────────────────────────────────────────────────────
// screenshotX11 captures each connected display by:
// 1. Parsing xrandr for per-display geometry (size + offset).
// 2. Capturing the full root window once with import or scrot.
// 3. Cropping each display's region from the root capture using convert.
// 4. Reading cursor position once with xdotool.
//
// This means one capture process regardless of display count, which is faster
// and avoids flickering artefacts from multiple sequential captures.
func screenshotX11() ([]ScreenshotWrapper, error) {
displays, err := displaySizeX11()
if err != nil {
return nil, err
}
// Capture the full root window — covers all monitors in one shot.
rootPath := tempPathLinux()
defer os.Remove(rootPath)
if err := captureRootX11(rootPath); err != nil {
return nil, err
}
// Cursor position is best-effort — zero if xdotool is not installed.
cursor, _ := cursorPositionX11()
wrappers := make([]ScreenshotWrapper, 0, len(displays))
for _, d := range displays {
png, err := cropX11(rootPath, d)
if err != nil {
// Fall back to the full root image for this display rather than
// failing the entire call.
data, readErr := os.ReadFile(rootPath)
if readErr != nil {
return nil, fmt.Errorf("display %d: crop failed and root image unreadable: %w", d.DisplayID, err)
}
png = data
}
wrappers = append(wrappers, ScreenshotWrapper{
Image: png,
ScreenSize: d,
Cursor: cursor,
})
}
return wrappers, nil
}
// captureRootX11 captures the full X11 root window into path.
// Tries import (ImageMagick) first, falls back to scrot.
func captureRootX11(path string) error {
if err := runTool(path, "import", "-window", "root", path); err == nil {
return nil
}
if err := runTool(path, "scrot", "--silent", path); err == nil {
return nil
}
return fmt.Errorf(
"X11 capture failed: neither 'import' (ImageMagick) nor 'scrot' is installed — " +
"install one: apt install imagemagick OR apt install scrot",
)
}
// cropX11 uses ImageMagick's convert to crop a display's region from the root image.
// Geometry string format: WxH+X+Y (e.g. "1920x1080+1920+0" for the right monitor).
func cropX11(rootPath string, d DisplaySize) ([]byte, error) {
outPath := tempPathLinux()
defer os.Remove(outPath)
geometry := fmt.Sprintf("%dx%d+%d+%d", d.Width, d.Height, d.OffsetX, d.OffsetY)
if err := runTool(outPath, "convert", rootPath, "-crop", geometry, "+repage", outPath); err != nil {
return nil, fmt.Errorf("convert crop failed for display %d (%s): %w", d.DisplayID, geometry, err)
}
data, err := os.ReadFile(outPath)
if err != nil {
return nil, fmt.Errorf("reading cropped image for display %d: %w", d.DisplayID, err)
}
return data, nil
}
// displaySizeX11 parses xrandr --current for all connected displays,
// returning size AND offset so we can crop the root image correctly.
func displaySizeX11() ([]DisplaySize, error) {
out, err := exec.Command("xrandr", "--current").Output()
if err != nil {
return nil, fmt.Errorf("xrandr failed: %w — install: apt install x11-xserver-utils", err)
}
var sizes []DisplaySize
sc := bufio.NewScanner(strings.NewReader(string(out)))
id := 1
for sc.Scan() {
line := sc.Text()
if !strings.Contains(line, " connected ") {
continue
}
var w, h, x, y int
for _, f := range strings.Fields(line) {
// geometry token: 1920x1080+0+0
if n, _ := fmt.Sscanf(f, "%dx%d+%d+%d", &w, &h, &x, &y); n == 4 {
break
}
}
if w == 0 || h == 0 {
continue
}
sizes = append(sizes, DisplaySize{
DisplayID: id,
Width: w,
Height: h,
OffsetX: x,
OffsetY: y,
})
id++
}
if len(sizes) == 0 {
return nil, fmt.Errorf("xrandr returned no connected displays")
}
return sizes, nil
}
// cursorPositionX11 reads the cursor position using xdotool.
// Returns a zero Position if xdotool is not installed — callers treat cursor
// as best-effort and should not fail on this.
// Install: apt install xdotool OR pacman -S xdotool
func cursorPositionX11() (Position, error) {
out, err := exec.Command("xdotool", "getmouselocation", "--shell").Output()
if err != nil {
return Position{}, fmt.Errorf(
"xdotool failed: %w — install: apt install xdotool OR pacman -S xdotool", err,
)
}
// Output:
// X=123
// Y=456
// SCREEN=0
// WINDOW=12345678
var pos Position
sc := bufio.NewScanner(strings.NewReader(string(out)))
for sc.Scan() {
line := sc.Text()
var v float64
if n, _ := fmt.Sscanf(line, "X=%f", &v); n == 1 {
pos.X = v
}
if n, _ := fmt.Sscanf(line, "Y=%f", &v); n == 1 {
pos.Y = v
}
}
return pos, nil
}
// ── Wayland ───────────────────────────────────────────────────────────────────
// screenshotWayland tries wlroots-style capture first (grim -o per output),
// then falls back to GNOME-style (single combined image via grim without -o).
// Cursor is always zero — Wayland does not expose cursor position to clients.
func screenshotWayland() ([]ScreenshotWrapper, error) {
if wrappers, err := screenshotWlroots(); err == nil {
return wrappers, nil
}
return screenshotGnomeWayland()
}
// screenshotWlroots captures each wlr output individually using grim -o.
// Requires: grim (apt install grim / pacman -S grim)
// Supported compositors: sway, river, Hyprland, and other wlroots-based ones.
func screenshotWlroots() ([]ScreenshotWrapper, error) {
displays, err := displaySizeWlrRandr()
if err != nil {
return nil, err
}
wrappers := make([]ScreenshotWrapper, 0, len(displays))
for _, d := range displays {
path := tempPathLinux()
if err := runTool(path, "grim", "-t", "png", "-o", d.OutputName, path); err != nil {
os.Remove(path)
return nil, fmt.Errorf(
"grim failed for output %q: %w — install: apt install grim OR pacman -S grim", d.OutputName, err,
)
}
data, err := os.ReadFile(path)
os.Remove(path)
if err != nil {
return nil, fmt.Errorf("reading screenshot for output %q: %w", d.OutputName, err)
}
wrappers = append(wrappers, ScreenshotWrapper{
Image: data,
ScreenSize: d.DisplaySize,
Cursor: Position{}, // not available on Wayland
})
}
return wrappers, nil
}
// screenshotGnomeWayland captures all displays as one combined image using
// grim without the -o flag, then pairs it with sizes from gdbus.
// GNOME requires xdg-desktop-portal-gnome and may show a permission prompt.
func screenshotGnomeWayland() ([]ScreenshotWrapper, error) {
path := tempPathLinux()
defer os.Remove(path)
if err := runTool(path, "grim", "-t", "png", path); err != nil {
return nil, fmt.Errorf(
"Wayland capture failed: grim not found or compositor does not support "+
"wlr-screencopy — install: apt install grim OR pacman -S grim. "+
"Note: GNOME requires xdg-desktop-portal-gnome and may prompt for permission: %w", err,
)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading Wayland screenshot: %w", err)
}
// Best-effort sizes — if gdbus fails we still return the image with zero size.
sizes, err := displaySizeGnomeWayland()
if err != nil || len(sizes) == 0 {
sizes = []DisplaySize{{DisplayID: 1}}
}
// We have one combined image but potentially multiple display size entries.
// Return one wrapper per display with the same combined image — the caller
// can use ScreenSize to understand the logical layout.
wrappers := make([]ScreenshotWrapper, len(sizes))
for i, s := range sizes {
wrappers[i] = ScreenshotWrapper{
Image: data,
ScreenSize: s,
Cursor: Position{},
}
}
return wrappers, nil
}
// wlrDisplay extends DisplaySize with the output name grim needs for -o.
type wlrDisplay struct {
DisplaySize
OutputName string
}
// displaySizeWlrRandr parses wlr-randr output for output names and current
// resolution. OutputName is used by grim -o to target a specific output.
func displaySizeWlrRandr() ([]wlrDisplay, error) {
out, err := exec.Command("wlr-randr").Output()
if err != nil {
return nil, fmt.Errorf("wlr-randr: %w", err)
}
// wlr-randr output format:
// HDMI-A-1 "Dell U2722D" (...)
// ...
// 1920x1080 px, 60.000000 Hz (current)
var displays []wlrDisplay
var current wlrDisplay
sc := bufio.NewScanner(strings.NewReader(string(out)))
id := 1
for sc.Scan() {
line := sc.Text()
// Output header: first character is non-space (not indented).
if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
// Save previous output if it had a valid current mode.
if current.OutputName != "" && current.Width > 0 {
current.DisplayID = id
displays = append(displays, current)
id++
}
current = wlrDisplay{OutputName: strings.Fields(line)[0]}
continue
}
// Resolution line (indented, contains "current").
trimmed := strings.TrimSpace(line)
var w, h int
if n, _ := fmt.Sscanf(trimmed, "%dx%d px", &w, &h); n == 2 && strings.Contains(trimmed, "current") {
current.Width = w
current.Height = h
}
}
// Flush the last output.
if current.OutputName != "" && current.Width > 0 {
current.DisplayID = id
displays = append(displays, current)
}
if len(displays) == 0 {
return nil, fmt.Errorf("no current mode found in wlr-randr output")
}
return displays, nil
}
// displaySizeGnomeWayland queries display sizes from GNOME's Mutter via gdbus.
func displaySizeGnomeWayland() ([]DisplaySize, error) {
out, err := exec.Command("gdbus", "call", "--session",
"--dest", "org.gnome.Mutter.DisplayConfig",
"--object-path", "/org/gnome/Mutter/DisplayConfig",
"--method", "org.gnome.Mutter.DisplayConfig.GetCurrentState",
).Output()
if err != nil {
return nil, fmt.Errorf(
"GNOME DisplayConfig gdbus query failed — "+
"install wlr-randr as alternative: apt install wlr-randr: %w", err,
)
}
// GVariant output — best-effort scan for WxH pairs that look like resolutions.
var sizes []DisplaySize
sc := bufio.NewScanner(strings.NewReader(string(out)))
id := 1
for sc.Scan() {
var w, h int
if n, _ := fmt.Sscanf(strings.TrimSpace(sc.Text()), "%d, %d,", &w, &h); n == 2 && w > 100 && h > 100 {
sizes = append(sizes, DisplaySize{DisplayID: id, Width: w, Height: h})
id++
}
}
if len(sizes) == 0 {
return nil, fmt.Errorf("could not parse display sizes from GNOME DisplayConfig output")
}
return sizes, nil
}
// ── Standalone accessors ──────────────────────────────────────────────────────
// GetDisplaySizeLinux returns display sizes without capturing images.
// Prefer Screenshot() if you need both.
func GetDisplaySizeLinux() ([]DisplaySize, error) {
switch {
case os.Getenv("WAYLAND_DISPLAY") != "":
return displaySizeWayland()
case os.Getenv("DISPLAY") != "":
sizes, err := displaySizeX11()
if err != nil {
return nil, err
}
// Strip the DisplaySize from the extended X11 type.
out := make([]DisplaySize, len(sizes))
for i, s := range sizes {
out[i] = s
}
return out, nil
default:
return nil, ErrNoDisplay
}
}
func displaySizeWayland() ([]DisplaySize, error) {
if displays, err := displaySizeWlrRandr(); err == nil {
sizes := make([]DisplaySize, len(displays))
for i, d := range displays {
sizes[i] = d.DisplaySize
}
return sizes, nil
}
return displaySizeGnomeWayland()
}
// GetCursorPositionLinux returns cursor position on X11.
// Always returns a zero Position on Wayland with an explanatory error.
func GetCursorPositionLinux() (Position, error) {
switch {
case os.Getenv("WAYLAND_DISPLAY") != "":
return Position{}, fmt.Errorf(
"cursor position unavailable on Wayland: the protocol does not expose " +
"cursor coordinates by design — no workaround exists without a compositor-specific extension",
)
case os.Getenv("DISPLAY") != "":
return cursorPositionX11()
default:
return Position{}, ErrNoDisplay
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// runTool runs a command and returns an error if it exits non-zero.
// outPath is not written by this function — it is passed as an arg to the tool.
func runTool(outPath, name string, args ...string) error {
if out, err := exec.Command(name, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w — %s", name, err, strings.TrimSpace(string(out)))
}
return nil
}
func tempPathLinux() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("edr-%d.png", time.Now().UnixNano()))
}
func remoteControlBatch(batch RemoteControlActionBatch) error {
return errors.New(fmt.Sprintf("remote control not implemented for %s. Verified for Windows only.", runtime.GOOS))
}