projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,93 @@
package scanner
import (
"bufio"
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/installer"
)
// APTScanner scans for APT package updates
type APTScanner struct{}
// NewAPTScanner creates a new APT scanner
func NewAPTScanner() *APTScanner {
return &APTScanner{}
}
func (s *APTScanner) Name() string { return "APT Update Scanner" }
// IsAvailable checks if APT is available on this system
func (s *APTScanner) IsAvailable() bool {
_, err := exec.LookPath("apt")
return err == nil
}
// Scan scans for available APT updates
func (s *APTScanner) Scan() ([]client.UpdateReportItem, error) {
runner, err := installer.NewDiscoveryRunner("apt")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [apt] discovery_runner_create error=%w", err)
}
// apt-get update refreshes the cache (best effort)
runner.Run(context.Background(), "update")
result, err := runner.Run(context.Background(), "list", "--upgradable")
if err != nil {
return nil, fmt.Errorf("failed to run apt list --upgradable: %w", err)
}
return parseAPTOutput([]byte(result.Stdout))
}
func parseAPTOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse apt output:
// package/repo version arch [upgradable from: old_version]
re := regexp.MustCompile(`^([^\s/]+)/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[upgradable from:\s+([^\]]+)\]`)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Listing...") {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) < 6 {
continue
}
packageName := matches[1]
repository := matches[2]
newVersion := matches[3]
oldVersion := matches[5]
// Determine severity (simplified - in production, query Ubuntu Security Advisories)
severity := "moderate"
if strings.Contains(repository, "security") {
severity = "important"
}
update := client.UpdateReportItem{
PackageType: "apt",
PackageName: packageName,
CurrentVersion: oldVersion,
AvailableVersion: newVersion,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": matches[4],
},
}
updates = append(updates, update)
}
return updates, nil
}

View file

@ -0,0 +1,38 @@
package scanner
import (
"os/exec"
)
// DetectAvailable returns the package-manager / Docker scanners that are
// usable on the current host. The check is stateless and side-effect free
// (no config writes, no network) so it is safe to call from both the
// registration path and every poll of the agent loop (ARC-001).
//
// The returned identifiers match the strings the server uses for
// agent_subsystems.subsystem (apt, dnf, winget, windows, docker).
func DetectAvailable() []string {
scanners := []string{}
if NewAPTScanner().IsAvailable() {
scanners = append(scanners, "apt")
}
if NewDNFScanner().IsAvailable() {
scanners = append(scanners, "dnf")
}
if NewWingetScanner().IsAvailable() {
scanners = append(scanners, "winget")
}
if NewWindowsUpdateScanner().IsAvailable() {
scanners = append(scanners, "windows")
}
if NewPacmanScanner().IsAvailable() {
scanners = append(scanners, "pacman")
}
// Inline Docker availability check — avoids importing orchestrator (circular).
if _, err := exec.LookPath("docker"); err == nil {
scanners = append(scanners, "docker")
}
return scanners
}

View file

@ -0,0 +1,328 @@
package scanner
import (
"bufio"
"bytes"
"context"
"fmt"
"log"
"os/exec"
"regexp"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/installer"
)
// DNFScanner scans for DNF/RPM package updates
type DNFScanner struct{}
// NewDNFScanner creates a new DNF scanner
func NewDNFScanner() *DNFScanner {
return &DNFScanner{}
}
func (s *DNFScanner) Name() string { return "DNF Update Scanner" }
// IsAvailable checks if DNF is available on this system
func (s *DNFScanner) IsAvailable() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// Scan scans for available DNF updates
func (s *DNFScanner) Scan() ([]client.UpdateReportItem, error) {
runner, err := installer.NewDiscoveryRunner("dnf")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [dnf] discovery_runner_create error=%w", err)
}
result, err := runner.Run(context.Background(), "check-update")
if err != nil && (result == nil || result.ExitCode != 100) {
return nil, fmt.Errorf("failed to run dnf check-update: %w", err)
}
if result == nil {
return nil, fmt.Errorf("failed to run dnf check-update: no result")
}
output := []byte(result.Stdout)
return parseDNFOutput(output)
}
func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse dnf check-update output.
// Format: pkgname.arch version repo (three columns, verified on dnf5)
re := regexp.MustCompile(`^([^\s]+)\.([^\s]+)\s+([^\s]+)\s+([^\s]+)$`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and header/footer
if line == "" ||
strings.HasPrefix(line, "Last metadata") ||
strings.HasPrefix(line, "Dependencies") ||
strings.HasPrefix(line, "Obsoleting") ||
strings.Contains(line, "Upgraded") {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) < 5 {
log.Printf("[WARNING] [scanner] [dnf] unparseable_line line=%q", line)
continue
}
packageName := matches[1]
arch := matches[2]
version := matches[3]
repository := matches[4]
// Get currently installed version via rpm
currentVersion := getInstalledVersion(packageName)
// Determine severity based on repository and update type
severity := determineSeverity(repository, packageName, version)
update := client.UpdateReportItem{
PackageType: "dnf",
PackageName: packageName,
CurrentVersion: currentVersion,
AvailableVersion: version,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": arch,
},
}
updates = append(updates, update)
}
return updates, nil
}
// getInstalledVersion gets the currently installed version of a package.
// Packages can have multiple installed instances — kernel keeps several,
// multilib ships i686+x86_64 — and rpm -q emits one record per instance.
// The queryformat newline keeps records from concatenating into an
// unparseable blob; the newest EVR is what threat checks care about.
func getInstalledVersion(packageName string) string {
cmd := exec.Command("rpm", "-q", "--queryformat", "%{EPOCHNUM}|%{VERSION}|%{RELEASE}\n", packageName)
output, err := cmd.Output()
if err != nil {
return "unknown"
}
version := newestInstalledEVR(string(output))
if version == "" {
return "unknown"
}
return version
}
// newestInstalledEVR picks the newest epoch|version|release record from
// multi-line rpm -q output and formats it. Returns "" on no usable records.
func newestInstalledEVR(output string) string {
var newest string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if newest == "" || rawEVRCompare(line, newest) > 0 {
newest = line
}
}
if newest == "" {
return ""
}
return formatRPMEVR(newest)
}
func formatRPMEVR(raw string) string {
parts := strings.Split(raw, "|")
if len(parts) != 3 {
return strings.TrimSpace(raw)
}
epoch := strings.TrimSpace(parts[0])
version := strings.TrimSpace(parts[1])
release := strings.TrimSpace(parts[2])
if version == "" {
return ""
}
evr := version
if release != "" {
evr += "-" + release
}
if epoch != "" && epoch != "0" && epoch != "(none)" {
evr = epoch + ":" + evr
}
return evr
}
// rawEVRCompare orders two raw "epoch|version|release" records per RPM
// semantics: epoch numerically, then version, then release via rpmvercmp.
// Malformed records fall back to string comparison for a stable order.
func rawEVRCompare(a, b string) int {
pa, pb := strings.SplitN(a, "|", 3), strings.SplitN(b, "|", 3)
if len(pa) != 3 || len(pb) != 3 {
return strings.Compare(a, b)
}
ea, _ := strconv.Atoi(strings.TrimSpace(pa[0]))
eb, _ := strconv.Atoi(strings.TrimSpace(pb[0]))
if ea != eb {
if ea > eb {
return 1
}
return -1
}
if c := rpmvercmp(pa[1], pb[1]); c != 0 {
return c
}
return rpmvercmp(pa[2], pb[2])
}
// rpmvercmp implements RPM's version comparison: alternating numeric/alpha
// segments split on separators, digits beat alphas, tilde sorts before
// everything (pre-release), caret sorts after end-of-string but before any
// other segment.
func rpmvercmp(a, b string) int {
if a == b {
return 0
}
isDigit := func(c byte) bool { return c >= '0' && c <= '9' }
isAlpha := func(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
isAlnum := func(c byte) bool { return isDigit(c) || isAlpha(c) }
i, j := 0, 0
for i < len(a) || j < len(b) {
for i < len(a) && !isAlnum(a[i]) && a[i] != '~' && a[i] != '^' {
i++
}
for j < len(b) && !isAlnum(b[j]) && b[j] != '~' && b[j] != '^' {
j++
}
ta, tb := i < len(a) && a[i] == '~', j < len(b) && b[j] == '~'
if ta || tb {
if ta && tb {
i++
j++
continue
}
if ta {
return -1
}
return 1
}
ca, cb := i < len(a) && a[i] == '^', j < len(b) && b[j] == '^'
if ca || cb {
if ca && cb {
i++
j++
continue
}
if ca {
if j >= len(b) {
return 1
}
return -1
}
if i >= len(a) {
return -1
}
return 1
}
if i >= len(a) || j >= len(b) {
if i < len(a) {
return 1
}
if j < len(b) {
return -1
}
return 0
}
if isDigit(a[i]) {
if !isDigit(b[j]) {
return 1 // numeric segment beats alpha
}
si, sj := i, j
for i < len(a) && isDigit(a[i]) {
i++
}
for j < len(b) && isDigit(b[j]) {
j++
}
sa, sb := strings.TrimLeft(a[si:i], "0"), strings.TrimLeft(b[sj:j], "0")
if len(sa) != len(sb) {
if len(sa) > len(sb) {
return 1
}
return -1
}
if c := strings.Compare(sa, sb); c != 0 {
return c
}
} else {
if isDigit(b[j]) {
return -1
}
si, sj := i, j
for i < len(a) && isAlpha(a[i]) {
i++
}
for j < len(b) && isAlpha(b[j]) {
j++
}
if c := strings.Compare(a[si:i], b[sj:j]); c != 0 {
return c
}
}
}
return 0
}
// determineSeverity determines the severity of an update based on repository and package information
func determineSeverity(repository, packageName, newVersion string) string {
// Security updates
if strings.Contains(strings.ToLower(repository), "security") ||
strings.Contains(strings.ToLower(packageName), "security") ||
strings.Contains(strings.ToLower(packageName), "selinux") ||
strings.Contains(strings.ToLower(packageName), "crypto") ||
strings.Contains(strings.ToLower(packageName), "openssl") ||
strings.Contains(strings.ToLower(packageName), "gnutls") {
return "critical"
}
// Kernel updates are important
if strings.Contains(strings.ToLower(packageName), "kernel") {
return "important"
}
// Core system packages
if strings.Contains(strings.ToLower(packageName), "glibc") ||
strings.Contains(strings.ToLower(packageName), "systemd") ||
strings.Contains(strings.ToLower(packageName), "bash") ||
strings.Contains(strings.ToLower(packageName), "coreutils") {
return "important"
}
// Development tools
if strings.Contains(strings.ToLower(packageName), "gcc") ||
strings.Contains(strings.ToLower(packageName), "python") ||
strings.Contains(strings.ToLower(packageName), "nodejs") ||
strings.Contains(strings.ToLower(packageName), "java") ||
strings.Contains(strings.ToLower(packageName), "go") {
return "moderate"
}
// Default severity
return "low"
}

View file

@ -0,0 +1,287 @@
package scanner
import (
"testing"
)
// Real `dnf check-update` output captured from dnf5 v5.2.18 on Fedora 43.
// Format: pkgname.arch version repo (three columns).
func TestParseDNFOutput_StandardRepos(t *testing.T) {
output := []byte("SDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.PackageName != "SDL3" {
t.Errorf("PackageName = %q, want %q", u.PackageName, "SDL3")
}
if u.AvailableVersion != "3.4.8-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3.4.8-1.fc43")
}
if u.RepositorySource != "updates" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "updates")
}
if u.PackageType != "dnf" {
t.Errorf("PackageType = %q, want %q", u.PackageType, "dnf")
}
}
func TestParseDNFOutput_CoprRepo(t *testing.T) {
output := []byte("7zip.x86_64 26.01-1.fc43 copr:copr.fedorainfracloud.org:errornointernet:packages\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.PackageName != "7zip" {
t.Errorf("PackageName = %q, want %q", u.PackageName, "7zip")
}
if u.AvailableVersion != "26.01-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "26.01-1.fc43")
}
if u.RepositorySource != "copr:copr.fedorainfracloud.org:errornointernet:packages" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:errornointernet:packages")
}
}
func TestParseDNFOutput_DockerRepo(t *testing.T) {
output := []byte("docker-ce.x86_64 3:29.5.2-1.fc43 docker-ce-stable\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.AvailableVersion != "3:29.5.2-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3:29.5.2-1.fc43")
}
if u.RepositorySource != "docker-ce-stable" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "docker-ce-stable")
}
}
func TestParseDNFOutput_CoprRepoWithColons(t *testing.T) {
output := []byte("coolercontrol.x86_64 4.3.1-1.fc43 copr:copr.fedorainfracloud.org:codifryed:CoolerControl\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.AvailableVersion != "4.3.1-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "4.3.1-1.fc43")
}
if u.RepositorySource != "copr:copr.fedorainfracloud.org:codifryed:CoolerControl" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:codifryed:CoolerControl")
}
}
func TestParseDNFOutput_SkipsHeader(t *testing.T) {
output := []byte("Last metadata expiration check: 0:05:23 ago on Wed 2026-05-28.\n\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update (skip header+blank), got %d", len(updates))
}
}
func TestParseDNFOutput_SkipsObsoleting(t *testing.T) {
output := []byte("Obsoleting Packages:\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update (skip Obsoleting), got %d", len(updates))
}
}
func TestParseDNFOutput_Empty(t *testing.T) {
updates, err := parseDNFOutput([]byte{})
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 0 {
t.Fatalf("expected 0 updates from empty input, got %d", len(updates))
}
}
func TestParseDNFOutput_ArchitectureInMetadata(t *testing.T) {
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
arch, ok := u.Metadata["architecture"].(string)
if !ok {
t.Fatal("metadata.architecture missing or not a string")
}
if arch != "x86_64" {
t.Errorf("architecture = %q, want %q", arch, "x86_64")
}
}
func TestFormatRPMEVR(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "no epoch",
raw: "0|3.4.8|1.fc43",
want: "3.4.8-1.fc43",
},
{
name: "epoch",
raw: "3|29.5.2|1.fc43",
want: "3:29.5.2-1.fc43",
},
{
name: "none epoch",
raw: "(none)|8.15.0|7.fc43",
want: "8.15.0-7.fc43",
},
{
name: "legacy raw fallback",
raw: "3.4.8",
want: "3.4.8",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatRPMEVR(tt.raw); got != tt.want {
t.Fatalf("formatRPMEVR(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestNewestInstalledEVR(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "single install",
raw: "0|3.4.8|1.fc43\n",
want: "3.4.8-1.fc43",
},
{
name: "kernel keeps three installs",
raw: "0|6.19.14|200.fc43\n0|7.0.10|101.fc43\n0|7.0.11|100.fc43\n",
want: "7.0.11-100.fc43",
},
{
name: "install order not version order",
raw: "0|7.0.11|100.fc43\n0|6.19.14|200.fc43\n",
want: "7.0.11-100.fc43",
},
{
name: "multilib identical EVRs",
raw: "1|2.4.18|1.fc43\n1|2.4.18|1.fc43\n",
want: "1:2.4.18-1.fc43",
},
{
name: "higher epoch wins over higher version",
raw: "0|9.0.0|1.fc43\n2|1.0.0|1.fc43\n",
want: "2:1.0.0-1.fc43",
},
{
name: "empty output",
raw: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := newestInstalledEVR(tt.raw); got != tt.want {
t.Fatalf("newestInstalledEVR(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestRpmvercmp(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"1.0", "1.0", 0},
{"1.0", "2.0", -1},
{"2.0.1", "2.0", 1},
{"7.0.11", "6.19.14", 1},
{"1.05", "1.5", 0}, // leading zeros stripped
{"10", "9", 1}, // numeric, not lexical
{"1.0a", "1.0", 1}, // extra segment wins
{"1.0~rc1", "1.0", -1}, // tilde is pre-release
{"1.0~rc1", "1.0~rc2", -1},
{"1.0^git1", "1.0", 1}, // caret is post-release
{"1.0^git1", "1.0.1", -1},
{"2.92rel2", "2.92rel1", 1},
{"1a", "12", -1}, // digit segment beats alpha
}
for _, tt := range tests {
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
if got := rpmvercmp(tt.a, tt.b); got != tt.want {
t.Fatalf("rpmvercmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
if got := rpmvercmp(tt.b, tt.a); got != -tt.want {
t.Fatalf("rpmvercmp(%q, %q) = %d, want %d (symmetry)", tt.b, tt.a, got, -tt.want)
}
})
}
}
func TestParseDNFOutput_MultipleLines(t *testing.T) {
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\ndnsmasq.x86_64 2.92rel2-2.fc43 updates\nbind-libs.x86_64 32:9.18.49-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 3 {
t.Fatalf("expected 3 updates, got %d", len(updates))
}
names := []string{"curl", "dnsmasq", "bind-libs"}
for i, name := range names {
if updates[i].PackageName != name {
t.Errorf("updates[%d].PackageName = %q, want %q", i, updates[i].PackageName, name)
}
}
}
// CurrentVersion is populated via `rpm -q` which won't work in test
// environments. Verify it returns "unknown" rather than crashing.
func TestParseDNFOutput_CurrentVersionUnknown(t *testing.T) {
output := []byte("nonexistent-package-12345.x86_64 1.0-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
// getInstalledVersion fails for fake packages → returns "unknown"
if updates[0].CurrentVersion != "unknown" {
t.Errorf("CurrentVersion = %q, want %q", updates[0].CurrentVersion, "unknown")
}
}

View file

@ -0,0 +1,200 @@
package scanner
import (
"bufio"
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/installer"
)
// PacmanScanner scans for Arch Linux pacman package updates.
//
// Detection uses `checkupdates` (pacman-contrib), which syncs a private copy
// of the repo databases into a temp dir and runs `pacman -Qu` against it.
// This means:
// - No root required (unprivileged discovery, same as dnf/apt)
// - No mutation of the live pacman database
// - checkupdates handles its own sandboxing internally
//
// checkupdates output format:
// pkgname oldver -> newver
// fakeroot 1:1.37.2-1 -> 1:1.37.2-2
//
// The current (installed) version is embedded in the output, so no secondary
// query is needed (unlike dnf which calls rpm -q).
type PacmanScanner struct{}
// NewPacmanScanner creates a new pacman scanner
func NewPacmanScanner() *PacmanScanner {
return &PacmanScanner{}
}
func (s *PacmanScanner) Name() string { return "Pacman Update Scanner" }
// IsAvailable checks if pacman-contrib's checkupdates is installed.
// pacman alone is not sufficient — checkupdates is a separate package
// (pacman-contrib) that may not be installed.
func (s *PacmanScanner) IsAvailable() bool {
if _, err := exec.LookPath("checkupdates"); err != nil {
return false
}
_, err := exec.LookPath("pacman")
return err == nil
}
// Scan runs checkupdates and parses the output into update report items.
func (s *PacmanScanner) Scan() ([]client.UpdateReportItem, error) {
runner, err := installer.NewDiscoveryRunner("pacman")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [pacman] discovery_runner_create error=%w", err)
}
// pacman-contrib spells it --nocolor; --color=never is rejected outright
result, err := runner.Run(context.Background(), "--nocolor")
if err != nil && (result == nil || result.ExitCode != 0) {
return nil, fmt.Errorf("[ERROR] [agent] [pacman] checkupdates_failed error=%w", err)
}
if result == nil {
return nil, fmt.Errorf("[ERROR] [agent] [pacman] checkupdates_no_result")
}
return parsePacmanOutput([]byte(result.Stdout))
}
// checkupdatesLine matches: pkgname oldver -> newver
var checkupdatesLine = regexp.MustCompile(`^([^\s]+)\s+(\S+)\s+->\s+(\S+)$`)
func parsePacmanOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := checkupdatesLine.FindStringSubmatch(line)
if len(matches) < 4 {
continue
}
packageName := matches[1]
currentVersion := matches[2]
availableVersion := matches[3]
// Determine repository via pacman -Si (best effort, unprivileged)
repository := getPacmanRepository(packageName)
severity := determinePacmanSeverity(packageName)
update := client.UpdateReportItem{
PackageType: "pacman",
PackageName: packageName,
CurrentVersion: currentVersion,
AvailableVersion: availableVersion,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": "x86_64", // checkupdates doesn't report arch; Arch is typically single-arch per machine
},
}
updates = append(updates, update)
}
return updates, nil
}
// getPacmanRepository queries pacman -Si for the package's repository.
// Returns "unknown" if the query fails (best effort — the update data is
// still valid without it).
func getPacmanRepository(packageName string) string {
cmd := exec.Command("pacman", "-Si", "--color=never", packageName)
output, err := cmd.Output()
if err != nil {
return "unknown"
}
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "Repository") {
// Format: "Repository : core"
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return "unknown"
}
// determinePacmanSeverity assigns a heuristic severity for Arch packages.
// Arch does not have RPM/Debian-style security advisories — security updates
// are intermixed with all other updates in the rolling release. This heuristic
// flags packages that are commonly security-sensitive based on package name.
// OSV.dev (Arch ecosystem) provides actual vulnerability data at the server
// level during the supply-chain check.
func determinePacmanSeverity(packageName string) string {
lower := strings.ToLower(packageName)
// Trust root / crypto
if strings.Contains(lower, "keyring") ||
strings.Contains(lower, "archlinux-keyring") ||
strings.Contains(lower, "openssl") ||
strings.Contains(lower, "gnutls") ||
strings.Contains(lower, "crypto") ||
strings.Contains(lower, "nss") ||
strings.Contains(lower, "gnupg") ||
strings.Contains(lower, "gpgme") {
return "critical"
}
// Kernel and boot
if strings.Contains(lower, "linux") || // linux, linux-lts, linux-zen, linux-hardened
strings.Contains(lower, "kernel") ||
strings.Contains(lower, "systemd") ||
strings.Contains(lower, "grub") ||
strings.Contains(lower, "bootloader") {
return "important"
}
// Core system
if strings.Contains(lower, "glibc") ||
strings.Contains(lower, "bash") ||
strings.Contains(lower, "coreutils") ||
strings.Contains(lower, "sudo") ||
strings.Contains(lower, "pam") ||
strings.Contains(lower, "openssh") ||
strings.Contains(lower, "shadow") {
return "important"
}
// Network-facing
if strings.Contains(lower, "networkmanager") ||
strings.Contains(lower, "iptables") ||
strings.Contains(lower, "nftables") ||
strings.Contains(lower, "wireguard") ||
strings.Contains(lower, "openvpn") ||
strings.Contains(lower, "curl") ||
strings.Contains(lower, "wget") ||
strings.Contains(lower, "openssh") {
return "moderate"
}
// Runtime / development
if strings.Contains(lower, "python") ||
strings.Contains(lower, "nodejs") ||
strings.Contains(lower, "go") ||
strings.Contains(lower, "rust") ||
strings.Contains(lower, "gcc") ||
strings.Contains(lower, "java") ||
strings.Contains(lower, "dotnet") {
return "moderate"
}
return "low"
}

View file

@ -0,0 +1,88 @@
package scanner
import (
"testing"
)
func TestParsePacmanOutput(t *testing.T) {
// checkupdates format: pkgname oldver -> newver
sample := `fakeroot 1:1.37.2-1 -> 1:1.37.2-2
firefox 151.0.4-1 -> 152.0-1
linux 6.14.4-1 -> 6.14.5-1
openssl 3.5.0-1 -> 3.5.1-1
`
updates, err := parsePacmanOutput([]byte(sample))
if err != nil {
t.Fatalf("parsePacmanOutput failed: %v", err)
}
if len(updates) != 4 {
t.Fatalf("expected 4 updates, got %d", len(updates))
}
// fakeroot with epoch
if updates[0].PackageName != "fakeroot" {
t.Errorf("expected fakeroot, got %s", updates[0].PackageName)
}
if updates[0].CurrentVersion != "1:1.37.2-1" {
t.Errorf("expected current 1:1.37.2-1, got %s", updates[0].CurrentVersion)
}
if updates[0].AvailableVersion != "1:1.37.2-2" {
t.Errorf("expected available 1:1.37.2-2, got %s", updates[0].AvailableVersion)
}
if updates[0].PackageType != "pacman" {
t.Errorf("expected package_type pacman, got %s", updates[0].PackageType)
}
// firefox — no epoch
if updates[1].PackageName != "firefox" {
t.Errorf("expected firefox, got %s", updates[1].PackageName)
}
if updates[1].CurrentVersion != "151.0.4-1" {
t.Errorf("expected current 151.0.4-1, got %s", updates[1].CurrentVersion)
}
// linux — kernel severity
if updates[2].PackageName != "linux" {
t.Errorf("expected linux, got %s", updates[2].PackageName)
}
if updates[2].Severity != "important" {
t.Errorf("expected severity important for kernel, got %s", updates[2].Severity)
}
// openssl — crypto severity
if updates[3].PackageName != "openssl" {
t.Errorf("expected openssl, got %s", updates[3].PackageName)
}
if updates[3].Severity != "critical" {
t.Errorf("expected severity critical for openssl, got %s", updates[3].Severity)
}
}
func TestParsePacmanEmpty(t *testing.T) {
updates, err := parsePacmanOutput([]byte(""))
if err != nil {
t.Fatalf("parsePacmanOutput on empty input failed: %v", err)
}
if len(updates) != 0 {
t.Errorf("expected 0 updates from empty input, got %d", len(updates))
}
}
func TestParsePacmanGibberish(t *testing.T) {
// Lines that don't match the pattern should be silently skipped
sample := `some random text
-> dangling arrow
no version info here
pkg without arrow
`
updates, err := parsePacmanOutput([]byte(sample))
if err != nil {
t.Fatalf("parsePacmanOutput on gibberish failed: %v", err)
}
if len(updates) != 0 {
t.Errorf("expected 0 updates from gibberish, got %d", len(updates))
}
}

View file

@ -0,0 +1,29 @@
//go:build !windows
// +build !windows
package scanner
import "github.com/Fimeg/RedFlag/agent/internal/client"
// WindowsUpdateScanner stub for non-Windows platforms
type WindowsUpdateScanner struct{}
// NewWindowsUpdateScanner creates a stub Windows scanner for non-Windows platforms
func NewWindowsUpdateScanner() *WindowsUpdateScanner {
return &WindowsUpdateScanner{}
}
// IsAvailable always returns false on non-Windows platforms
func (s *WindowsUpdateScanner) IsAvailable() bool {
return false
}
// Scan always returns no updates on non-Windows platforms
func (s *WindowsUpdateScanner) Scan() ([]client.UpdateReportItem, error) {
return []client.UpdateReportItem{}, nil
}
func (s *WindowsUpdateScanner) Name() string { return "Windows Update Scanner" }

View file

@ -0,0 +1,94 @@
package scanner
// windows_ghost_test.go — Pre-fix tests for ghost updates.
// [SHARED] — tests inspect installer source file which has no build tag.
//
// F-C1-3 HIGH: No post-install state verification for Windows Updates.
//
// Run: cd agent && go test ./internal/scanner/... -v -run TestWindowsUpdate
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 3.1 — Documents missing post-install verification (F-C1-3)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWindowsUpdateInstallerHasNoPostInstallVerification(t *testing.T) {
// POST-FIX (F-C1-3): Installer now sets RebootRequired and logs post-install state.
installerPath := "../installer/windows.go"
content, err := os.ReadFile(installerPath)
if err != nil {
t.Fatalf("failed to read installer/windows.go: %v", err)
}
src := string(content)
hasPostVerify := strings.Contains(src, "reboot_required") ||
strings.Contains(src, "RebootRequired") ||
strings.Contains(src, "post_install")
if !hasPostVerify {
t.Error("[ERROR] [agent] [scanner] F-C1-3 NOT FIXED: no post-install verification")
}
t.Log("[INFO] [agent] [scanner] F-C1-3 FIXED: post-install state verification present")
}
// ---------------------------------------------------------------------------
// Test 3.2 — Must verify post-install state (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWindowsUpdateInstallerVerifiesPostInstallState(t *testing.T) {
// F-C1-3: After fix, verify IsInstalled=1 or filter reboot-pending.
installerPath := "../installer/windows.go"
content, err := os.ReadFile(installerPath)
if err != nil {
t.Fatalf("failed to read installer/windows.go: %v", err)
}
src := string(content)
hasPostVerify := strings.Contains(src, "reboot_required") ||
strings.Contains(src, "RebootRequired") ||
strings.Contains(src, "post_install") ||
strings.Contains(src, "IsInstalled")
if !hasPostVerify {
t.Errorf("[ERROR] [agent] [scanner] no post-install verification found.\n" +
"F-C1-3: must mark reboot_required or verify IsInstalled.")
}
}
// ---------------------------------------------------------------------------
// Test 3.3 — Search criteria is correct (documentary)
//
// Category: PASS-NOW
// ---------------------------------------------------------------------------
func TestWindowsUpdateSearchCriteriaExcludesInstalled(t *testing.T) {
// F-C1-3: The search criteria is correct in principle,
// but IsInstalled transitions asynchronously after install.
wuaPath := "windows_wua.go"
content, err := os.ReadFile(wuaPath)
if err != nil {
// On non-Windows, this file may not be compilable but exists on disk
t.Skipf("windows_wua.go not readable: %v (expected on non-Windows)", err)
return
}
src := string(content)
if !strings.Contains(src, `IsInstalled=0 AND IsHidden=0`) {
t.Error("[ERROR] [agent] [scanner] expected search criteria IsInstalled=0 AND IsHidden=0")
}
t.Log("[INFO] [agent] [scanner] F-C1-3: search criteria is correct but not re-checked post-install")
}

View file

@ -0,0 +1,13 @@
//go:build windows
// +build windows
package scanner
// WindowsUpdateScanner is an alias for WindowsUpdateScannerWUA on Windows
// This aliases to the WUA implementation on Windows builds
type WindowsUpdateScanner = WindowsUpdateScannerWUA
// NewWindowsUpdateScanner returns the WUA-based scanner on Windows
func NewWindowsUpdateScanner() *WindowsUpdateScanner {
return NewWindowsUpdateScannerWUA()
}

View file

@ -0,0 +1,246 @@
package scanner
// windows_service_parity_test.go — Pre-fix tests for service polling loop parity.
// [SHARED] — reads service/windows.go as a text file, no Windows imports needed.
//
// F-C1-4 MEDIUM: Service has no auto-restart on crash.
// F-C1-5 HIGH: Service runAgent() is duplicated, missing B-2 fixes.
// F-C1-7 LOW: Service runAgent() uses emojis in logs.
//
// Run: cd agent && go test ./internal/scanner/... -v -run TestWindowsService
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 4.1 — Documents missing FailureActions (F-C1-4)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWindowsServiceHasAutoRestartOnCrash(t *testing.T) {
// F-C1-4 RESOLVED: Service DOES have RecoveryActions configured.
// The audit finding F-C1-4 was incorrect — SetRecoveryActions is called.
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
hasRecovery := strings.Contains(src, "RecoveryActions") ||
strings.Contains(src, "FailureActions")
if !hasRecovery {
t.Error("[ERROR] [agent] [service] no RecoveryActions configured")
}
t.Log("[INFO] [agent] [service] F-C1-4 ALREADY CORRECT: service has crash recovery")
}
// ---------------------------------------------------------------------------
// Test 5.1 — Documents fixed jitter in service (F-C1-5)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWindowsServicePollingLoopHasFixedJitter(t *testing.T) {
// POST-FIX (F-C1-5): Service now has proportional jitter.
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
// Ideal fix (now in place): the service delegates to the shared loop via
// agent.RunPollingLoop, so jitter lives there rather than being copied here.
delegates := strings.Contains(src, "RunPollingLoop")
if !delegates && !strings.Contains(src, "maxJitter") && !strings.Contains(src, "pollingInterval / 2") {
t.Error("[ERROR] [agent] [service] F-C1-5 NOT FIXED: proportional jitter not in service")
}
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service delegates jitter to shared loop")
}
// ---------------------------------------------------------------------------
// Test 5.2 — Service must have proportional jitter (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWindowsServicePollingLoopHasProportionalJitter(t *testing.T) {
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
hasProportionalJitter := strings.Contains(src, "pollingInterval / 2") ||
strings.Contains(src, "maxJitter") ||
// Or the ideal fix: service delegates to the shared loop
strings.Contains(src, "RunPollingLoop") ||
strings.Contains(src, "runAgentLoop") ||
strings.Contains(src, "commonPollingLoop")
if !hasProportionalJitter {
t.Errorf("[ERROR] [agent] [service] service polling loop missing proportional jitter.\n" +
"F-C1-5: either deduplicate the loop or apply same jitter formula.")
}
}
// ---------------------------------------------------------------------------
// Test 5.3 — Documents missing exponential backoff (F-C1-5)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWindowsServicePollingLoopHasNoExponentialBackoff(t *testing.T) {
// POST-FIX (F-C1-5): Service now has exponential backoff.
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
// Backoff lives in the shared loop the service delegates to (RunPollingLoop),
// or — for the legacy in-place form — directly via calculateBackoff.
delegates := strings.Contains(src, "RunPollingLoop")
if !delegates && (!strings.Contains(src, "calculateBackoff") || !strings.Contains(src, "consecutiveFailures")) {
t.Error("[ERROR] [agent] [service] F-C1-5 NOT FIXED: exponential backoff missing")
}
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service delegates backoff to shared loop")
}
// ---------------------------------------------------------------------------
// Test 5.4 — Service must have exponential backoff (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWindowsServicePollingLoopHasExponentialBackoff(t *testing.T) {
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
hasBackoff := strings.Contains(src, "calculateBackoff") ||
strings.Contains(src, "consecutiveFailures") ||
strings.Contains(src, "backoffDelay") ||
// Or the ideal fix: delegates to the shared loop
strings.Contains(src, "RunPollingLoop") ||
strings.Contains(src, "runAgentLoop")
if !hasBackoff {
t.Errorf("[ERROR] [agent] [service] service missing exponential backoff.\n" +
"F-C1-5: apply same backoff or deduplicate polling loop.")
}
}
// ---------------------------------------------------------------------------
// Test 5.5 — Polling loop should NOT be duplicated (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestPollingLoopIsNotDuplicated(t *testing.T) {
// F-C1-5: The ideal fix is deduplication. The pragmatic fix is
// applying the same B-2 fixes to both loops and documenting the
// TODO for future extraction. Both approaches are acceptable
// as long as the service has proportional jitter AND backoff.
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
// Accept if either: (a) delegated to shared function, or (b) has parity
hasDelegation := strings.Contains(src, "RunPollingLoop") ||
strings.Contains(src, "runAgentLoop") ||
strings.Contains(src, "polling.Run")
hasParity := strings.Contains(src, "maxJitter") && strings.Contains(src, "calculateBackoff")
if !hasDelegation && !hasParity {
t.Errorf("[ERROR] [agent] [service] service loop is neither deduplicated nor at parity with main.go")
}
t.Log("[INFO] [agent] [service] F-C1-5 FIXED: service polling loop has B-2 parity")
}
// ---------------------------------------------------------------------------
// Test 6.3 — Documents emoji in service logs (F-C1-7)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWindowsServiceHasEmojiInLogs(t *testing.T) {
// POST-FIX (F-C1-7): Emojis removed from service code.
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
hasEmoji := false
for _, r := range src {
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
hasEmoji = true
break
}
}
if hasEmoji {
t.Error("[ERROR] [agent] [service] F-C1-7 NOT FIXED: emoji found in service code")
}
t.Log("[INFO] [agent] [service] F-C1-7 FIXED: no emojis in service code")
}
// ---------------------------------------------------------------------------
// Test 6.4 — Service must have no emojis (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWindowsServiceHasNoEmojiInLogs(t *testing.T) {
servicePath := "../service/windows.go"
content, err := os.ReadFile(servicePath)
if err != nil {
t.Skipf("service/windows.go not readable: %v", err)
return
}
src := string(content)
for _, r := range src {
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
t.Errorf("[ERROR] [agent] [service] emoji found in service code (U+%04X).\n"+
"F-C1-7: ETHOS #1 prohibits emojis in logs.", r)
return
}
}
}

View file

@ -0,0 +1,562 @@
//go:build windows
// +build windows
package scanner
import (
"fmt"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/pkg/windowsupdate"
"github.com/go-ole/go-ole"
"github.com/scjalliance/comshim"
)
// WindowsUpdateScannerWUA scans for Windows updates using the Windows Update Agent (WUA) API
type WindowsUpdateScannerWUA struct{}
// NewWindowsUpdateScannerWUA creates a new Windows Update scanner using WUA API
func NewWindowsUpdateScannerWUA() *WindowsUpdateScannerWUA {
return &WindowsUpdateScannerWUA{}
}
func (s *WindowsUpdateScannerWUA) Name() string { return "Windows Update Scanner" }
// IsAvailable checks if WUA scanner is available on this system
func (s *WindowsUpdateScannerWUA) IsAvailable() bool {
// Only available on Windows
return runtime.GOOS == "windows"
}
// Scan scans for available Windows updates using the Windows Update Agent API
func (s *WindowsUpdateScannerWUA) Scan() ([]client.UpdateReportItem, error) {
if !s.IsAvailable() {
return nil, fmt.Errorf("WUA scanner is only available on Windows")
}
// Initialize COM
comshim.Add(1)
defer comshim.Done()
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
defer ole.CoUninitialize()
// Create update session
session, err := windowsupdate.NewUpdateSession()
if err != nil {
return nil, fmt.Errorf("failed to create Windows Update session: %w", err)
}
// Create update searcher
searcher, err := session.CreateUpdateSearcher()
if err != nil {
return nil, fmt.Errorf("failed to create update searcher: %w", err)
}
// Search for available updates (IsInstalled=0 means not installed)
searchCriteria := "IsInstalled=0 AND IsHidden=0"
result, err := searcher.Search(searchCriteria)
if err != nil {
return nil, fmt.Errorf("failed to search for updates: %w", err)
}
// TODO(DEV-031): Ghost update prevention — filter scan results
// against recently-installed update IDs where RebootRequired=true.
// Currently the RebootRequired flag is reported to the server
// but the scanner still returns the update as available until
// Windows commits the install state.
// See docs/C1_Verification_Report.md Part 4 for full context.
// Convert results to our format
updates := s.convertWUAResult(result)
return updates, nil
}
// convertWUAResult converts WUA search results to our UpdateReportItem format
func (s *WindowsUpdateScannerWUA) convertWUAResult(result *windowsupdate.ISearchResult) []client.UpdateReportItem {
var updates []client.UpdateReportItem
updatesCollection := result.Updates
if updatesCollection == nil {
return updates
}
for _, update := range updatesCollection {
if update == nil {
continue
}
updateItem := s.convertWUAUpdate(update)
updates = append(updates, *updateItem)
}
return updates
}
// convertWUAUpdate converts a single WUA update to our UpdateReportItem format
func (s *WindowsUpdateScannerWUA) convertWUAUpdate(update *windowsupdate.IUpdate) *client.UpdateReportItem {
// Get update information
title := update.Title
description := update.Description
kbArticles := s.getKBArticles(update)
updateIdentity := update.Identity
// Use MSRC severity if available (more accurate than category-based detection)
severity := s.mapMsrcSeverity(update.MsrcSeverity)
if severity == "" {
severity = s.determineSeverityFromCategories(update)
}
// Get version information with improved parsing
currentVersion, availableVersion := s.parseVersionFromTitle(title)
// Get version information
maxDownloadSize := update.MaxDownloadSize
estimatedSize := s.getEstimatedSize(update)
// Create metadata with WUA-specific information
metadata := map[string]interface{}{
"package_manager": "windows_update",
"detected_via": "wua_api",
"kb_articles": kbArticles,
"update_identity": updateIdentity.UpdateID,
"revision_number": updateIdentity.RevisionNumber,
"search_criteria": "IsInstalled=0 AND IsHidden=0",
"download_size": maxDownloadSize,
"estimated_size": estimatedSize,
"api_source": "windows_update_agent",
"scan_timestamp": time.Now().Format(time.RFC3339),
}
// Add MSRC severity if available
if update.MsrcSeverity != "" {
metadata["msrc_severity"] = update.MsrcSeverity
}
// Add security bulletin IDs (includes CVEs)
if len(update.SecurityBulletinIDs) > 0 {
metadata["security_bulletins"] = update.SecurityBulletinIDs
// Extract CVEs from security bulletins
cveList := make([]string, 0)
for _, bulletin := range update.SecurityBulletinIDs {
if strings.HasPrefix(bulletin, "CVE-") {
cveList = append(cveList, bulletin)
}
}
if len(cveList) > 0 {
metadata["cve_list"] = cveList
}
}
// Add deployment information
if update.LastDeploymentChangeTime != nil {
metadata["last_deployment_change"] = update.LastDeploymentChangeTime.Format(time.RFC3339)
metadata["discovered_at"] = update.LastDeploymentChangeTime.Format(time.RFC3339)
}
// Add deadline if present
if update.Deadline != nil {
metadata["deadline"] = update.Deadline.Format(time.RFC3339)
}
// Add flags
if update.IsMandatory {
metadata["is_mandatory"] = true
}
if update.IsBeta {
metadata["is_beta"] = true
}
if update.IsDownloaded {
metadata["is_downloaded"] = true
}
// Add more info URLs
if len(update.MoreInfoUrls) > 0 {
metadata["more_info_urls"] = update.MoreInfoUrls
}
// Add release notes
if update.ReleaseNotes != "" {
metadata["release_notes"] = update.ReleaseNotes
}
// Add support URL
if update.SupportUrl != "" {
metadata["support_url"] = update.SupportUrl
}
// Add categories if available
categories := s.getCategories(update)
if len(categories) > 0 {
metadata["categories"] = categories
}
updateItem := &client.UpdateReportItem{
PackageType: "windows_update",
PackageName: title,
PackageDescription: description,
CurrentVersion: currentVersion,
AvailableVersion: availableVersion,
Severity: severity,
RepositorySource: "Microsoft Update",
Metadata: metadata,
}
// Add KB articles to CVE list field if present
if len(kbArticles) > 0 {
updateItem.KBID = strings.Join(kbArticles, ", ")
}
// Add size information to description if available
if maxDownloadSize > 0 {
sizeStr := s.formatFileSize(uint64(maxDownloadSize))
updateItem.PackageDescription += fmt.Sprintf(" (Size: %s)", sizeStr)
}
return updateItem
}
// getKBArticles extracts KB article IDs from an update
func (s *WindowsUpdateScannerWUA) getKBArticles(update *windowsupdate.IUpdate) []string {
kbCollection := update.KBArticleIDs
if kbCollection == nil {
return []string{}
}
// kbCollection is already a slice of strings
return kbCollection
}
// getCategories extracts update categories
func (s *WindowsUpdateScannerWUA) getCategories(update *windowsupdate.IUpdate) []string {
var categories []string
categoryCollection := update.Categories
if categoryCollection == nil {
return categories
}
for _, category := range categoryCollection {
if category != nil {
name := category.Name
categories = append(categories, name)
}
}
return categories
}
// determineSeverityFromCategories determines severity based on update categories
func (s *WindowsUpdateScannerWUA) determineSeverityFromCategories(update *windowsupdate.IUpdate) string {
categories := s.getCategories(update)
title := strings.ToUpper(update.Title)
// Critical Security Updates
for _, category := range categories {
categoryUpper := strings.ToUpper(category)
if strings.Contains(categoryUpper, "SECURITY") ||
strings.Contains(categoryUpper, "CRITICAL") ||
strings.Contains(categoryUpper, "IMPORTANT") {
return "critical"
}
}
// Check title for security keywords
if strings.Contains(title, "SECURITY") ||
strings.Contains(title, "CRITICAL") ||
strings.Contains(title, "IMPORTANT") ||
strings.Contains(title, "PATCH TUESDAY") {
return "critical"
}
// Driver Updates
for _, category := range categories {
if strings.Contains(strings.ToUpper(category), "DRIVERS") {
return "moderate"
}
}
// Definition Updates
for _, category := range categories {
if strings.Contains(strings.ToUpper(category), "DEFINITION") ||
strings.Contains(strings.ToUpper(category), "ANTIVIRUS") ||
strings.Contains(strings.ToUpper(category), "ANTIMALWARE") {
return "high"
}
}
return "moderate"
}
// categorizeUpdate determines the type of update
func (s *WindowsUpdateScannerWUA) categorizeUpdate(title string, categories []string) string {
titleUpper := strings.ToUpper(title)
// Security Updates
for _, category := range categories {
if strings.Contains(strings.ToUpper(category), "SECURITY") {
return "security"
}
}
if strings.Contains(titleUpper, "SECURITY") ||
strings.Contains(titleUpper, "PATCH") ||
strings.Contains(titleUpper, "VULNERABILITY") {
return "security"
}
// Driver Updates
for _, category := range categories {
if strings.Contains(strings.ToUpper(category), "DRIVERS") {
return "driver"
}
}
if strings.Contains(titleUpper, "DRIVER") {
return "driver"
}
// Definition Updates
for _, category := range categories {
if strings.Contains(strings.ToUpper(category), "DEFINITION") {
return "definition"
}
}
if strings.Contains(titleUpper, "DEFINITION") ||
strings.Contains(titleUpper, "ANTIVIRUS") ||
strings.Contains(titleUpper, "ANTIMALWARE") {
return "definition"
}
// Feature Updates
if strings.Contains(titleUpper, "FEATURE") ||
strings.Contains(titleUpper, "VERSION") ||
strings.Contains(titleUpper, "UPGRADE") {
return "feature"
}
// Quality Updates
if strings.Contains(titleUpper, "QUALITY") ||
strings.Contains(titleUpper, "CUMULATIVE") {
return "quality"
}
return "system"
}
// getEstimatedSize gets the estimated size of the update
func (s *WindowsUpdateScannerWUA) getEstimatedSize(update *windowsupdate.IUpdate) uint64 {
maxSize := update.MaxDownloadSize
if maxSize > 0 {
return uint64(maxSize)
}
return 0
}
// formatFileSize formats bytes into human readable string
func (s *WindowsUpdateScannerWUA) formatFileSize(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// GetUpdateDetails retrieves detailed information about a specific Windows update
func (s *WindowsUpdateScannerWUA) GetUpdateDetails(updateID string) (*client.UpdateReportItem, error) {
// This would require implementing a search by ID functionality
// For now, we don't implement this as it would require additional WUA API calls
return nil, fmt.Errorf("GetUpdateDetails not yet implemented for WUA scanner")
}
// GetUpdateHistory retrieves update history
func (s *WindowsUpdateScannerWUA) GetUpdateHistory() ([]client.UpdateReportItem, error) {
if !s.IsAvailable() {
return nil, fmt.Errorf("WUA scanner is only available on Windows")
}
// Initialize COM
comshim.Add(1)
defer comshim.Done()
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
defer ole.CoUninitialize()
// Create update session
session, err := windowsupdate.NewUpdateSession()
if err != nil {
return nil, fmt.Errorf("failed to create Windows Update session: %w", err)
}
// Create update searcher
searcher, err := session.CreateUpdateSearcher()
if err != nil {
return nil, fmt.Errorf("failed to create update searcher: %w", err)
}
// Query update history
historyEntries, err := searcher.QueryHistoryAll()
if err != nil {
return nil, fmt.Errorf("failed to query update history: %w", err)
}
// Convert history to our format
return s.convertHistoryEntries(historyEntries), nil
}
// convertHistoryEntries converts update history entries to our UpdateReportItem format
func (s *WindowsUpdateScannerWUA) convertHistoryEntries(entries []*windowsupdate.IUpdateHistoryEntry) []client.UpdateReportItem {
var updates []client.UpdateReportItem
for _, entry := range entries {
if entry == nil {
continue
}
// Create a basic update report item from history entry
updateItem := &client.UpdateReportItem{
PackageType: "windows_update_history",
PackageName: entry.Title,
PackageDescription: entry.Description,
CurrentVersion: "Installed",
AvailableVersion: "History Entry",
Severity: s.determineSeverityFromHistoryEntry(entry),
RepositorySource: "Microsoft Update",
Metadata: map[string]interface{}{
"detected_via": "wua_history",
"api_source": "windows_update_agent",
"scan_timestamp": time.Now().Format(time.RFC3339),
"history_date": entry.Date,
"operation": entry.Operation,
"result_code": entry.ResultCode,
"hresult": entry.HResult,
},
}
updates = append(updates, *updateItem)
}
return updates
}
// determineSeverityFromHistoryEntry determines severity from history entry
func (s *WindowsUpdateScannerWUA) determineSeverityFromHistoryEntry(entry *windowsupdate.IUpdateHistoryEntry) string {
title := strings.ToUpper(entry.Title)
// Check title for security keywords
if strings.Contains(title, "SECURITY") ||
strings.Contains(title, "CRITICAL") ||
strings.Contains(title, "IMPORTANT") {
return "critical"
}
if strings.Contains(title, "DEFINITION") ||
strings.Contains(title, "ANTIVIRUS") ||
strings.Contains(title, "ANTIMALWARE") {
return "high"
}
return "moderate"
}
// mapMsrcSeverity maps Microsoft's MSRC severity ratings to our severity levels
func (s *WindowsUpdateScannerWUA) mapMsrcSeverity(msrcSeverity string) string {
switch strings.ToLower(strings.TrimSpace(msrcSeverity)) {
case "critical":
return "critical"
case "important":
return "critical"
case "moderate":
return "moderate"
case "low":
return "low"
case "unspecified", "":
return ""
default:
return ""
}
}
// parseVersionFromTitle attempts to extract current and available version from update title
// Examples:
// "Intel Corporation - Display - 26.20.100.7584" -> ("Unknown", "26.20.100.7584")
// "2024-01 Cumulative Update for Windows 11 Version 22H2 (KB5034123)" -> ("Unknown", "KB5034123")
func (s *WindowsUpdateScannerWUA) parseVersionFromTitle(title string) (currentVersion, availableVersion string) {
currentVersion = "Unknown"
availableVersion = "Unknown"
// Pattern 1: Version at the end after last dash (common for drivers)
// Example: "Intel Corporation - Display - 26.20.100.7584"
if strings.Contains(title, " - ") {
parts := strings.Split(title, " - ")
lastPart := strings.TrimSpace(parts[len(parts)-1])
// Check if last part looks like a version (contains dots and digits)
if strings.Contains(lastPart, ".") && s.containsDigits(lastPart) {
availableVersion = lastPart
return
}
}
// Pattern 2: KB article in parentheses
// Example: "2024-01 Cumulative Update (KB5034123)"
if strings.Contains(title, "(KB") && strings.Contains(title, ")") {
start := strings.Index(title, "(KB")
end := strings.Index(title[start:], ")")
if end > 0 {
kbNumber := title[start+1 : start+end]
availableVersion = kbNumber
return
}
}
// Pattern 3: Date-based versioning
// Example: "2024-01 Security Update"
if strings.Contains(title, "202") { // Year pattern
words := strings.Fields(title)
for _, word := range words {
// Look for YYYY-MM pattern
if len(word) == 7 && word[4] == '-' && s.containsDigits(word[:4]) && s.containsDigits(word[5:]) {
availableVersion = word
return
}
}
}
// Pattern 4: Version keyword followed by number
// Example: "Feature Update to Windows 11, version 23H2"
lowerTitle := strings.ToLower(title)
if strings.Contains(lowerTitle, "version ") {
idx := strings.Index(lowerTitle, "version ")
afterVersion := title[idx+8:]
words := strings.Fields(afterVersion)
if len(words) > 0 {
// Take the first word after "version"
versionStr := strings.TrimRight(words[0], ",.")
availableVersion = versionStr
return
}
}
return
}
// containsDigits checks if a string contains any digits
func (s *WindowsUpdateScannerWUA) containsDigits(str string) bool {
for _, char := range str {
if char >= '0' && char <= '9' {
return true
}
}
return false
}

View file

@ -0,0 +1,788 @@
package scanner
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// WingetPackage represents a single package from winget output
type WingetPackage struct {
Name string `json:"Name"`
ID string `json:"Id"`
Version string `json:"Version"`
Available string `json:"Available"`
Source string `json:"Source"`
IsPinned bool `json:"IsPinned"`
PinReason string `json:"PinReason,omitempty"`
}
// WingetScanner scans for Windows package updates using winget
type WingetScanner struct{}
// NewWingetScanner creates a new Winget scanner
func NewWingetScanner() *WingetScanner {
return &WingetScanner{}
}
func (s *WingetScanner) Name() string { return "Winget Package Update Scanner" }
// IsAvailable checks if winget is available on this system
func (s *WingetScanner) IsAvailable() bool {
if runtime.GOOS != "windows" {
return false
}
_, err := s.findWingetPath()
return err == nil
}
// findWingetPath searches PATH and known system-wide install locations (F-C1-1 fix).
// This ensures winget is found when running as SYSTEM service account.
func (s *WingetScanner) findWingetPath() (string, error) {
// 1. Check PATH first (fastest, works for user-space installs)
if path, err := exec.LookPath("winget"); err == nil {
return path, nil
}
// 2. Check known system-wide install locations (accessible to SYSTEM account)
// TODO: winget v1.6+ required for --output json flag
knownPaths := []string{
`C:\Windows\System32\winget.exe`,
}
// 3. Glob for versioned WindowsApps directory
globPattern := `C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_*\winget.exe`
if matches, err := filepath.Glob(globPattern); err == nil {
knownPaths = append(knownPaths, matches...)
}
for _, p := range knownPaths {
if _, err := os.Stat(p); err == nil {
log.Printf("[INFO] [agent] [scanner] winget_found_at path=%q", p)
return p, nil
}
}
log.Printf("[INFO] [agent] [scanner] winget_not_found checked_path_and_known_locations")
return "", fmt.Errorf("winget not found in PATH or known locations")
}
// Scan scans for available winget package updates
func (s *WingetScanner) Scan() ([]client.UpdateReportItem, error) {
if !s.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
// Try multiple approaches with proper error handling
var lastErr error
// Method 1: Standard winget list with JSON output
if updates, err := s.scanWithJSON(); err == nil {
return updates, nil
} else {
lastErr = err
log.Printf("Winget JSON scan failed: %v\n", err)
}
// Method 2: Fallback to basic winget list without JSON
if updates, err := s.scanWithBasicOutput(); err == nil {
return updates, nil
} else {
lastErr = fmt.Errorf("both winget scan methods failed: %v (last error)", err)
log.Printf("Winget basic scan failed: %v\n", err)
}
// Method 3: Attempt automatic recovery for known issues
if isKnownWingetError(lastErr) {
log.Printf("Attempting automatic winget recovery...\n")
if updates, err := s.attemptWingetRecovery(); err == nil {
log.Printf("Winget recovery successful, found %d updates\n", len(updates))
return updates, nil
} else {
log.Printf("Winget recovery failed: %v\n", err)
}
return nil, fmt.Errorf("winget encountered a known issue (exit code %s). This may be due to Windows Update service or system configuration. Automatic recovery was attempted but failed", getExitCode(lastErr))
}
return nil, lastErr
}
// scanWithJSON attempts to scan using JSON output (most reliable)
func (s *WingetScanner) scanWithJSON() ([]client.UpdateReportItem, error) {
// Run winget list command to get outdated packages
// Using --output json for structured output
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json")
// Use CombinedOutput to capture both stdout and stderr for better error handling
output, err := cmd.CombinedOutput()
if err != nil {
// Check for specific exit codes that might be transient
if isTransientError(err) {
return nil, fmt.Errorf("winget temporary failure: %w", err)
}
return nil, fmt.Errorf("failed to run winget list: %w (output: %s)", err, string(output))
}
// Parse JSON output
var packages []WingetPackage
if err := json.Unmarshal(output, &packages); err != nil {
return nil, fmt.Errorf("failed to parse winget JSON output: %w (output: %s)", err, string(output))
}
var updates []client.UpdateReportItem
// Convert each package to our UpdateReportItem format
for _, pkg := range packages {
// Skip if no available update
if pkg.Available == "" || pkg.Available == pkg.Version {
continue
}
updateItem := s.parseWingetPackage(pkg)
updates = append(updates, *updateItem)
}
return updates, nil
}
// scanWithBasicOutput falls back to parsing text output
func (s *WingetScanner) scanWithBasicOutput() ([]client.UpdateReportItem, error) {
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements")
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to run winget list basic: %w", err)
}
// Simple text parsing fallback
return s.parseWingetTextOutput(string(output))
}
// parseWingetTextOutput parses winget text output using column-position parsing (F-C1-2 fix).
// Handles package names with spaces by using the header separator line to determine column widths.
func (s *WingetScanner) parseWingetTextOutput(output string) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
lines := strings.Split(output, "\n")
// Find the separator line to determine column positions
separatorIdx := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 5 && strings.Count(trimmed, "-") > len(trimmed)/2 {
separatorIdx = i
break
}
}
if separatorIdx < 1 || separatorIdx >= len(lines)-1 {
// Fall back to simple parsing if no separator found
return s.parseWingetTextSimple(output)
}
// Parse column positions from the header line
// Column headers are separated by 2+ spaces
headerLine := lines[separatorIdx-1]
// Find column start positions by locating header keywords
colNames := []string{"Name", "Id", "Version", "Available"}
colStarts := make([]int, 0, len(colNames))
for _, name := range colNames {
idx := strings.Index(headerLine, name)
if idx >= 0 {
colStarts = append(colStarts, idx)
}
}
if len(colStarts) < 3 {
// Not enough columns found, fall back
return s.parseWingetTextSimple(output)
}
// Parse data lines
for i := separatorIdx + 1; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" {
continue
}
// Extract columns by position
var cols []string
for j, start := range colStarts {
if start >= len(line) {
cols = append(cols, "")
continue
}
end := len(line)
if j+1 < len(colStarts) {
end = colStarts[j+1]
if end > len(line) {
end = len(line)
}
}
cols = append(cols, strings.TrimSpace(line[start:end]))
}
if len(cols) < 3 {
continue
}
// Column order: Name, [Id], Version, Available
pkgName := cols[0]
currentVersion := cols[len(cols)-2]
availableVersion := cols[len(cols)-1]
if availableVersion == currentVersion || availableVersion == "Unknown" || availableVersion == "" {
continue
}
update := client.UpdateReportItem{
PackageType: "winget",
PackageName: pkgName,
CurrentVersion: currentVersion,
AvailableVersion: availableVersion,
Severity: s.determineSeverityFromName(pkgName),
RepositorySource: "winget",
PackageDescription: fmt.Sprintf("Update available for %s", pkgName),
Metadata: map[string]interface{}{
"package_manager": "winget",
"detected_via": "text_parser",
},
}
updates = append(updates, update)
}
return updates, nil
}
// parseWingetTextSimple is the legacy simple parser for outputs without clear column headers
func (s *WingetScanner) parseWingetTextSimple(output string) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Name") || strings.HasPrefix(line, "-") || line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) >= 3 {
pkgName := fields[0]
currentVersion := fields[len(fields)-2]
availableVersion := fields[len(fields)-1]
if availableVersion == currentVersion || availableVersion == "Unknown" {
continue
}
update := client.UpdateReportItem{
PackageType: "winget",
PackageName: pkgName,
CurrentVersion: currentVersion,
AvailableVersion: availableVersion,
Severity: s.determineSeverityFromName(pkgName),
RepositorySource: "winget",
PackageDescription: fmt.Sprintf("Update available for %s", pkgName),
Metadata: map[string]interface{}{
"package_manager": "winget",
"detected_via": "text_parser",
},
}
updates = append(updates, update)
}
}
return updates, nil
}
// isTransientError checks if the error might be temporary
func isTransientError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// Common transient error patterns
transientPatterns := []string{
"network error",
"timeout",
"connection refused",
"temporary failure",
"service unavailable",
}
for _, pattern := range transientPatterns {
if strings.Contains(strings.ToLower(errStr), pattern) {
return true
}
}
return false
}
// isKnownWingetError checks for known Winget issues
func isKnownWingetError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// Check for the specific exit code 0x8a150002
if strings.Contains(errStr, "2316632066") || strings.Contains(errStr, "0x8a150002") {
return true
}
// Other known Winget issues
knownPatterns := []string{
"winget is not recognized",
"windows package manager",
"windows app installer",
"restarting your computer",
}
for _, pattern := range knownPatterns {
if strings.Contains(strings.ToLower(errStr), pattern) {
return true
}
}
return false
}
// getExitCode extracts exit code from error if available
func getExitCode(err error) string {
if err == nil {
return "unknown"
}
// Try to extract exit code from error message
errStr := err.Error()
if strings.Contains(errStr, "exit status") {
// Extract exit status number
parts := strings.Fields(errStr)
for i, part := range parts {
if part == "status" && i+1 < len(parts) {
return parts[i+1]
}
}
}
return "unknown"
}
// determineSeverityFromName provides basic severity detection for fallback
func (s *WingetScanner) determineSeverityFromName(name string) string {
lowerName := strings.ToLower(name)
// Security tools get higher priority
if strings.Contains(lowerName, "antivirus") ||
strings.Contains(lowerName, "security") ||
strings.Contains(lowerName, "defender") ||
strings.Contains(lowerName, "firewall") {
return "critical"
}
// Browsers and communication tools get high priority
if strings.Contains(lowerName, "firefox") ||
strings.Contains(lowerName, "chrome") ||
strings.Contains(lowerName, "edge") ||
strings.Contains(lowerName, "browser") {
return "high"
}
return "moderate"
}
// parseWingetPackage converts a WingetPackage to our UpdateReportItem format
func (s *WingetScanner) parseWingetPackage(pkg WingetPackage) *client.UpdateReportItem {
// Determine severity based on package type and source
severity := s.determineSeverity(pkg)
// Categorize the package type
packageCategory := s.categorizePackage(pkg.Name, pkg.Source)
// Create metadata with winget-specific information
metadata := map[string]interface{}{
"package_id": pkg.ID,
"source": pkg.Source,
"category": packageCategory,
"is_pinned": pkg.IsPinned,
"pin_reason": pkg.PinReason,
"package_manager": "winget",
}
// Add additional metadata based on package source
if pkg.Source == "winget" {
metadata["repository_type"] = "community"
} else if pkg.Source == "msstore" {
metadata["repository_type"] = "microsoft_store"
} else {
metadata["repository_type"] = "custom"
}
// Create the update report item
updateItem := &client.UpdateReportItem{
PackageType: "winget",
PackageName: pkg.Name,
CurrentVersion: pkg.Version,
AvailableVersion: pkg.Available,
Severity: severity,
RepositorySource: pkg.Source,
Metadata: metadata,
}
// Add description if available (would need additional winget calls)
// For now, we'll use the package name as description
updateItem.PackageDescription = fmt.Sprintf("Update available for %s from %s", pkg.Name, pkg.Source)
return updateItem
}
// determineSeverity determines the severity of a package update based on various factors
func (s *WingetScanner) determineSeverity(pkg WingetPackage) string {
name := strings.ToLower(pkg.Name)
source := strings.ToLower(pkg.Source)
// Security tools get higher priority
if strings.Contains(name, "antivirus") ||
strings.Contains(name, "security") ||
strings.Contains(name, "firewall") ||
strings.Contains(name, "malware") ||
strings.Contains(name, "defender") ||
strings.Contains(name, "crowdstrike") ||
strings.Contains(name, "sophos") ||
strings.Contains(name, "symantec") {
return "critical"
}
// Browsers and communication tools get high priority
if strings.Contains(name, "firefox") ||
strings.Contains(name, "chrome") ||
strings.Contains(name, "edge") ||
strings.Contains(name, "browser") ||
strings.Contains(name, "zoom") ||
strings.Contains(name, "teams") ||
strings.Contains(name, "slack") ||
strings.Contains(name, "discord") {
return "high"
}
// Development tools
if strings.Contains(name, "visual studio") ||
strings.Contains(name, "vscode") ||
strings.Contains(name, "git") ||
strings.Contains(name, "docker") ||
strings.Contains(name, "nodejs") ||
strings.Contains(name, "python") ||
strings.Contains(name, "java") ||
strings.Contains(name, "powershell") {
return "moderate"
}
// Microsoft Store apps might be less critical
if source == "msstore" {
return "low"
}
// Default severity
return "moderate"
}
// categorizePackage categorizes the package based on name and source
func (s *WingetScanner) categorizePackage(name, source string) string {
lowerName := strings.ToLower(name)
// Development tools
if strings.Contains(lowerName, "visual studio") ||
strings.Contains(lowerName, "vscode") ||
strings.Contains(lowerName, "intellij") ||
strings.Contains(lowerName, "sublime") ||
strings.Contains(lowerName, "notepad++") ||
strings.Contains(lowerName, "git") ||
strings.Contains(lowerName, "docker") ||
strings.Contains(lowerName, "nodejs") ||
strings.Contains(lowerName, "python") ||
strings.Contains(lowerName, "java") ||
strings.Contains(lowerName, "rust") ||
strings.Contains(lowerName, "go") ||
strings.Contains(lowerName, "github") ||
strings.Contains(lowerName, "postman") ||
strings.Contains(lowerName, "wireshark") {
return "development"
}
// Security tools
if strings.Contains(lowerName, "antivirus") ||
strings.Contains(lowerName, "security") ||
strings.Contains(lowerName, "firewall") ||
strings.Contains(lowerName, "malware") ||
strings.Contains(lowerName, "defender") ||
strings.Contains(lowerName, "crowdstrike") ||
strings.Contains(lowerName, "sophos") ||
strings.Contains(lowerName, "symantec") ||
strings.Contains(lowerName, "vpn") ||
strings.Contains(lowerName, "1password") ||
strings.Contains(lowerName, "bitwarden") ||
strings.Contains(lowerName, "lastpass") {
return "security"
}
// Browsers
if strings.Contains(lowerName, "firefox") ||
strings.Contains(lowerName, "chrome") ||
strings.Contains(lowerName, "edge") ||
strings.Contains(lowerName, "opera") ||
strings.Contains(lowerName, "brave") ||
strings.Contains(lowerName, "vivaldi") ||
strings.Contains(lowerName, "browser") {
return "browser"
}
// Communication tools
if strings.Contains(lowerName, "zoom") ||
strings.Contains(lowerName, "teams") ||
strings.Contains(lowerName, "slack") ||
strings.Contains(lowerName, "discord") ||
strings.Contains(lowerName, "telegram") ||
strings.Contains(lowerName, "whatsapp") ||
strings.Contains(lowerName, "skype") ||
strings.Contains(lowerName, "outlook") {
return "communication"
}
// Media and entertainment
if strings.Contains(lowerName, "vlc") ||
strings.Contains(lowerName, "spotify") ||
strings.Contains(lowerName, "itunes") ||
strings.Contains(lowerName, "plex") ||
strings.Contains(lowerName, "kodi") ||
strings.Contains(lowerName, "obs") ||
strings.Contains(lowerName, "streamlabs") {
return "media"
}
// Productivity tools
if strings.Contains(lowerName, "microsoft office") ||
strings.Contains(lowerName, "word") ||
strings.Contains(lowerName, "excel") ||
strings.Contains(lowerName, "powerpoint") ||
strings.Contains(lowerName, "adobe") ||
strings.Contains(lowerName, "photoshop") ||
strings.Contains(lowerName, "acrobat") ||
strings.Contains(lowerName, "notion") ||
strings.Contains(lowerName, "obsidian") ||
strings.Contains(lowerName, "typora") {
return "productivity"
}
// System utilities
if strings.Contains(lowerName, "7-zip") ||
strings.Contains(lowerName, "winrar") ||
strings.Contains(lowerName, "ccleaner") ||
strings.Contains(lowerName, "process") ||
strings.Contains(lowerName, "task manager") ||
strings.Contains(lowerName, "cpu-z") ||
strings.Contains(lowerName, "gpu-z") ||
strings.Contains(lowerName, "hwmonitor") {
return "utility"
}
// Gaming
if strings.Contains(lowerName, "steam") ||
strings.Contains(lowerName, "epic") ||
strings.Contains(lowerName, "origin") ||
strings.Contains(lowerName, "uplay") ||
strings.Contains(lowerName, "gog") ||
strings.Contains(lowerName, "discord") { // Discord is also gaming
return "gaming"
}
// Default category
return "application"
}
// GetPackageDetails retrieves detailed information about a specific winget package
func (s *WingetScanner) GetPackageDetails(packageID string) (*client.UpdateReportItem, error) {
if !s.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
// Run winget show command to get detailed package information
cmd := exec.Command("winget", "show", "--id", packageID, "--output", "json")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run winget show: %w", err)
}
// Parse JSON output (winget show outputs a single package object)
var pkg WingetPackage
if err := json.Unmarshal(output, &pkg); err != nil {
return nil, fmt.Errorf("failed to parse winget show output: %w", err)
}
// Convert to UpdateReportItem format
updateItem := s.parseWingetPackage(pkg)
return updateItem, nil
}
// GetInstalledPackages retrieves all installed packages via winget
func (s *WingetScanner) GetInstalledPackages() ([]WingetPackage, error) {
if !s.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
// Run winget list command to get all installed packages
cmd := exec.Command("winget", "list", "--output", "json")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run winget list: %w", err)
}
// Parse JSON output
var packages []WingetPackage
if err := json.Unmarshal(output, &packages); err != nil {
return nil, fmt.Errorf("failed to parse winget JSON output: %w", err)
}
return packages, nil
}
// attemptWingetRecovery tries to fix common winget issues automatically
func (s *WingetScanner) attemptWingetRecovery() ([]client.UpdateReportItem, error) {
log.Printf("Starting winget recovery process...\n")
// Recovery Method 1: Reset winget sources (common fix)
log.Printf("Attempting to reset winget sources...\n")
if err := s.resetWingetSources(); err == nil {
if updates, scanErr := s.scanWithJSON(); scanErr == nil {
log.Printf("Recovery successful after source reset\n")
return updates, nil
}
}
// Recovery Method 2: Update winget itself (silent)
log.Printf("Attempting to update winget itself...\n")
if err := s.updateWingetSilent(); err == nil {
// Wait a moment for winget to stabilize
time.Sleep(2 * time.Second)
if updates, scanErr := s.scanWithJSON(); scanErr == nil {
log.Printf("Recovery successful after winget update\n")
return updates, nil
}
}
// Recovery Method 3: Repair Windows App Installer (winget backend)
log.Printf("Attempting to repair Windows App Installer...\n")
if err := s.repairWindowsAppInstaller(); err == nil {
// Wait longer for system repairs
time.Sleep(5 * time.Second)
if updates, scanErr := s.scanWithJSON(); scanErr == nil {
log.Printf("Recovery successful after Windows App Installer repair\n")
return updates, nil
}
}
// Recovery Method 4: Force refresh with admin privileges
log.Printf("Attempting admin refresh...\n")
if updates, err := s.scanWithAdminPrivileges(); err == nil {
log.Printf("Recovery successful with admin privileges\n")
return updates, nil
}
// If all recovery attempts failed, return the original error
return nil, fmt.Errorf("all winget recovery attempts failed")
}
// resetWingetSources resets winget package sources
func (s *WingetScanner) resetWingetSources() error {
// Reset winget sources silently
cmd := exec.Command("winget", "source", "reset", "--force")
_, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Failed to reset winget sources: %v\n", err)
return err
}
// Add default sources back
cmd = exec.Command("winget", "source", "add", "winget", "--accept-package-agreements", "--accept-source-agreements")
_, err = cmd.CombinedOutput()
if err != nil {
log.Printf("Failed to add winget source: %v\n", err)
return err
}
return nil
}
// updateWingetSilent updates winget itself silently
func (s *WingetScanner) updateWingetSilent() error {
// Update winget silently with no interaction
cmd := exec.Command("winget", "upgrade", "--id", "Microsoft.AppInstaller", "--silent", "--accept-package-agreements", "--accept-source-agreements")
_, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Failed to update winget: %v\n", err)
return err
}
return nil
}
// repairWindowsAppInstaller attempts to repair the Windows App Installer
func (s *WingetScanner) repairWindowsAppInstaller() error {
// Try to repair using PowerShell
psCmd := `Get-AppxPackage -Name "Microsoft.DesktopAppInstaller" | Repair-AppxPackage -ForceUpdateFromAnyVersion`
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd)
_, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Failed to repair Windows App Installer: %v\n", err)
return err
}
return nil
}
// scanWithAdminPrivileges attempts to scan with elevated privileges if available
func (s *WingetScanner) scanWithAdminPrivileges() ([]client.UpdateReportItem, error) {
// Try running with elevated privileges using PowerShell
psCmd := `Start-Process winget -ArgumentList "list","--outdated","--accept-source-agreements" -Verb RunAs -Wait`
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd)
// This will likely fail without actual admin privileges, but we try anyway
_, err := cmd.CombinedOutput()
if err != nil {
// Fallback to regular scan with different flags
return s.scanWithDifferentFlags()
}
// If admin scan succeeded, try to get the results
return s.scanWithBasicOutput()
}
// scanWithDifferentFlags tries alternative winget flags
func (s *WingetScanner) scanWithDifferentFlags() ([]client.UpdateReportItem, error) {
// Try different combination of flags
flagVariations := [][]string{
{"list", "--outdated", "--accept-source-agreements"},
{"list", "--outdated", "--include-unknown"},
{"list", "--outdated"},
}
for _, flags := range flagVariations {
cmd := exec.Command("winget", flags...)
output, err := cmd.CombinedOutput()
if err == nil {
// Try to parse the output
if updates, parseErr := s.parseWingetTextOutput(string(output)); parseErr == nil {
return updates, nil
}
}
}
return nil, fmt.Errorf("all flag variations failed")
}

View file

@ -0,0 +1,57 @@
package scanner
// winget_logging_test.go — Pre-fix tests for winget logging format.
// [SHARED] — no build tag, compiles on all platforms.
//
// F-C1-6 LOW: Winget scanner uses fmt.Printf not structured logging.
//
// Run: cd agent && go test ./internal/scanner/... -v -run TestWingetScanner
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 6.1 — Documents unstructured logging (F-C1-6)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWingetScannerUsesStructuredLogging(t *testing.T) {
// F-C1-6 LOW: winget scanner uses fmt.Printf for error output.
// ETHOS #1 requires [TAG] [system] [component] format via log.Printf.
content, err := os.ReadFile("winget.go")
if err != nil {
t.Fatalf("failed to read winget.go: %v", err)
}
src := string(content)
if strings.Contains(src, "fmt.Printf") {
t.Error("[ERROR] [agent] [scanner] F-C1-6 NOT FIXED: fmt.Printf still in winget.go")
}
t.Log("[INFO] [agent] [scanner] F-C1-6 FIXED: fmt.Printf replaced with log.Printf")
}
// ---------------------------------------------------------------------------
// Test 6.2 — Must have no fmt.Printf (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWingetScannerHasNoFmtPrintf(t *testing.T) {
content, err := os.ReadFile("winget.go")
if err != nil {
t.Fatalf("failed to read winget.go: %v", err)
}
src := string(content)
if strings.Contains(src, "fmt.Printf") {
t.Errorf("[ERROR] [agent] [scanner] winget.go contains fmt.Printf.\n" +
"F-C1-6: all output must use log.Printf with ETHOS format.")
}
}

View file

@ -0,0 +1,157 @@
package scanner
// winget_parser_test.go — Pre-fix tests for winget output parsing.
// [SHARED] — no build tag, compiles on all platforms.
//
// F-C1-2 MEDIUM: Text fallback parser splits on whitespace,
// breaks on package names with spaces.
// F-C1-8 LOW: No winget parsing tests existed before this file.
//
// Run: cd agent && go test ./internal/scanner/... -v -run TestWinget
import (
"encoding/json"
"testing"
)
// ---------------------------------------------------------------------------
// Test 2.1 — Text parser handles spaces in package names (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWingetTextParserHandlesSpacesInPackageNames(t *testing.T) {
// F-C1-2 MEDIUM: Text parser splits on all whitespace.
// Package names with spaces are truncated to the first word.
// After fix: use column-position parsing based on header separator line.
scanner := NewWingetScanner()
mockOutput := "Name Id Version Available\n" +
"------------------------------------------------------------------------------------\n" +
"Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0\n" +
"Git Git.Git 2.43.0 2.44.0\n"
updates, err := scanner.parseWingetTextOutput(mockOutput)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) == 0 {
t.Fatal("[ERROR] [agent] [scanner] no updates parsed from text output")
}
// The first package should have the full name
firstName := updates[0].PackageName
if firstName != "Microsoft Visual Studio Code" {
t.Errorf("[ERROR] [agent] [scanner] expected full name \"Microsoft Visual Studio Code\", got %q.\n"+
"F-C1-2: text parser truncates names at first space.", firstName)
}
}
// ---------------------------------------------------------------------------
// Test 2.2 — Documents text parser truncation (F-C1-2)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWingetTextParserCurrentlyBreaksOnSpaces(t *testing.T) {
// POST-FIX (F-C1-2): Text parser now uses column-position parsing.
// Full package names with spaces are preserved.
scanner := NewWingetScanner()
mockOutput := "Name Id Version Available\n" +
"------------------------------------------------------------------------------------\n" +
"Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0\n"
updates, err := scanner.parseWingetTextOutput(mockOutput)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) == 0 {
t.Fatal("[ERROR] [agent] [scanner] no updates parsed")
}
if updates[0].PackageName != "Microsoft Visual Studio Code" {
t.Errorf("[ERROR] [agent] [scanner] expected full name, got %q", updates[0].PackageName)
}
t.Log("[INFO] [agent] [scanner] F-C1-2 FIXED: full package name preserved")
}
// ---------------------------------------------------------------------------
// Test 2.3 — JSON parser handles spaces correctly
//
// Category: PASS-NOW (JSON path works)
// ---------------------------------------------------------------------------
func TestWingetJsonParserHandlesSpacesInPackageNames(t *testing.T) {
// F-C1-2: JSON parser handles spaces correctly.
// The text fallback is the broken path.
scanner := NewWingetScanner()
// Mock JSON output matching WingetPackage struct
mockJSON := `[
{"Name":"Microsoft Visual Studio Code","Id":"Microsoft.VisualStudioCode","Version":"1.85.0","Available":"1.86.0","Source":"winget"},
{"Name":"Git","Id":"Git.Git","Version":"2.43.0","Available":"2.44.0","Source":"winget"}
]`
var packages []WingetPackage
if err := json.Unmarshal([]byte(mockJSON), &packages); err != nil {
t.Fatalf("JSON parse error: %v", err)
}
if len(packages) < 1 {
t.Fatal("[ERROR] [agent] [scanner] no packages parsed from JSON")
}
// JSON parser should get full name
item := scanner.parseWingetPackage(packages[0])
if item.PackageName != "Microsoft Visual Studio Code" {
t.Errorf("[ERROR] [agent] [scanner] expected full name, got %q", item.PackageName)
}
t.Log("[INFO] [agent] [scanner] F-C1-2: JSON parser handles spaces correctly")
}
// ---------------------------------------------------------------------------
// Test 2.4 — Both parsers return consistent structure
//
// Category: PASS-NOW
// ---------------------------------------------------------------------------
func TestWingetParserReturnsConsistentStructure(t *testing.T) {
// F-C1-2: Both parsers must return the same struct type.
scanner := NewWingetScanner()
// JSON path
pkg := WingetPackage{
Name: "Git",
ID: "Git.Git",
Version: "2.43.0",
Available: "2.44.0",
Source: "winget",
}
jsonResult := scanner.parseWingetPackage(pkg)
// Text path (simple name without spaces)
textOutput := "Name Id Version Available\n" +
"----------------------------------\n" +
"Git Git.Git 2.43.0 2.44.0\n"
textResults, err := scanner.parseWingetTextOutput(textOutput)
if err != nil {
t.Fatalf("text parse error: %v", err)
}
if len(textResults) == 0 {
t.Fatal("[ERROR] [agent] [scanner] no results from text parser")
}
// Both should have the same package type
if jsonResult.PackageType != textResults[0].PackageType {
t.Errorf("[ERROR] [agent] [scanner] package type mismatch: json=%q text=%q",
jsonResult.PackageType, textResults[0].PackageType)
}
t.Log("[INFO] [agent] [scanner] F-C1-2: both parsers return consistent structure")
}

View file

@ -0,0 +1,75 @@
package scanner
// winget_path_test.go — Pre-fix tests for winget path detection.
// [SHARED] — no build tag, compiles on all platforms.
//
// F-C1-1 HIGH: Winget located via exec.LookPath (PATH only).
// When running as SYSTEM service, winget is not in PATH.
//
// Run: cd agent && go test ./internal/scanner/... -v -run TestWinget
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 1.1 — Documents PATH-only winget search (F-C1-1)
//
// Category: PASS-NOW (documents the bug)
// ---------------------------------------------------------------------------
func TestWingetSearchesPathOnly(t *testing.T) {
// F-C1-1 HIGH: Winget located via PATH only. Windows service
// runs as SYSTEM which does not have %LOCALAPPDATA% in PATH.
// Per-user winget installs are invisible to the SYSTEM account.
content, err := os.ReadFile("winget.go")
if err != nil {
t.Fatalf("failed to read winget.go: %v", err)
}
src := string(content)
// Uses exec.LookPath for discovery
if !strings.Contains(src, `exec.LookPath("winget")`) {
t.Error("[ERROR] [agent] [scanner] expected exec.LookPath in winget discovery")
}
// POST-FIX: Also checks known system-wide install paths
hasKnownPaths := strings.Contains(src, "WindowsApps") ||
strings.Contains(src, `winget.exe`)
if !hasKnownPaths {
t.Error("[ERROR] [agent] [scanner] F-C1-1 NOT FIXED: no known path search")
}
t.Log("[INFO] [agent] [scanner] F-C1-1 FIXED: winget checks PATH and known locations")
}
// ---------------------------------------------------------------------------
// Test 1.2 — Winget must check known install locations (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestWingetChecksKnownInstallLocations(t *testing.T) {
// F-C1-1: After fix, winget discovery must check known
// system-wide install paths in addition to PATH search.
content, err := os.ReadFile("winget.go")
if err != nil {
t.Fatalf("failed to read winget.go: %v", err)
}
src := string(content)
hasKnownPaths := strings.Contains(src, "WindowsApps") ||
strings.Contains(src, "LOCALAPPDATA") ||
strings.Contains(src, "ProgramFiles") ||
strings.Contains(src, `winget.exe`)
if !hasKnownPaths {
t.Errorf("[ERROR] [agent] [scanner] winget does not check known install locations.\n" +
"F-C1-1: must check known paths for SYSTEM service compatibility.")
}
}