Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cmd/openwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,9 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
// SecurityConfig reader so the firewall probe can retry a
// sudo -n failure via sudo -S -k with the credential password
// — same gating as the collector + the privilege probe.
WithPolicyLoader(cfgStore)
WithPolicyLoader(cfgStore).
// Sudo-mode learning for the firewall probe (system-connection-profile).
WithProfiles(connStore)

// OS Intelligence collector — runs one RunCycle per host: SSH
// session, snapshot.Collect (packages/services/users/network/etc.),
Expand All @@ -414,7 +416,9 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
WithSudoPolicyLoader(func(ctx context.Context) (owssh.SudoPolicy, error) {
cfg, err := cfgStore.LoadSecurity(ctx)
return owssh.SudoPolicy{AllowCredentialPassword: cfg.AllowCredentialSudoPassword}, err
})
}).
// Sudo-mode learning across the cycle's sudo commands (system-connection-profile).
WithProfiles(connStore)

// Intelligence scheduler — cron-like loop that picks "due" hosts
// from host_intelligence_state.next_intelligence_at and dispatches
Expand Down
53 changes: 51 additions & 2 deletions internal/intelligence/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ type Publisher interface {
// via systemconfig.Store.LoadSecurity. Tests substitute a constant.
type SudoPolicyLoader func(ctx context.Context) (owssh.SudoPolicy, error)

// ConnProfileStore is the subset of connprofile the collector uses to
// learn the host's SUDO mode: lead each cycle's sudo commands with the
// recorded mode and record the mode that actually worked. nil disables
// sudo-mode learning. (SSH auth-method learning is handled separately by
// the profile-aware transport.) Spec system-connection-profile v1.2.0.
type ConnProfileStore interface {
Get(ctx context.Context, hostID uuid.UUID) (connprofile.Profile, error)
RecordSudoMode(ctx context.Context, hostID uuid.UUID, m connprofile.SudoMode) error
}

// Service is the OS Intelligence collector. Construct via NewService.
type Service struct {
pool *pgxpool.Pool
Expand All @@ -79,6 +89,7 @@ type Service struct {
lookup HostLookup
transport SSHTransport
sudoPolicy SudoPolicyLoader
profiles ConnProfileStore
}

// NewService constructs a Service. emit + bus may be nil — RunCycle
Expand Down Expand Up @@ -114,6 +125,15 @@ func (s *Service) WithSudoPolicyLoader(l SudoPolicyLoader) *Service {
return s
}

// WithProfiles enables per-host sudo-mode learning: each cycle leads its
// sudo commands with the host's recorded mode and records the mode that
// worked. nil (the default) keeps the historical sudo -n-first probing.
// Spec system-connection-profile v1.2.0 C-07 / AC-10.
func (s *Service) WithProfiles(p ConnProfileStore) *Service {
s.profiles = p
return s
}

// hostFacts is the internal hand-off used by runCycleWithTransport so
// tests can build it directly. cred can be nil when the stub transport
// ignores credentials.
Expand Down Expand Up @@ -253,12 +273,33 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap
}
sudoFallbackCount := 0

// Sudo-mode learning: lead this cycle's sudo commands with the host's
// recorded mode (skips the doomed `sudo -n` on a password-sudo host),
// observe what actually worked, and record it once at cycle end.
// sudoPrefer threads the observation forward so later sudo commands in
// the same cycle also lead correctly. Spec system-connection-profile
// v1.2.0 C-07.
var knownSudo, learnedSudo connprofile.SudoMode
if s.profiles != nil {
if p, perr := s.profiles.Get(ctx, hf.HostID); perr == nil {
knownSudo = p.SudoMode
}
}
sudoPrefer := string(knownSudo)
observeSudo := func(observed string) {
if observed != "" {
learnedSudo = connprofile.SudoMode(observed)
sudoPrefer = observed
}
}

snap := Snapshot{CollectedAt: time.Now().UTC()}

if out, code, err := sess.Run(ctx, "cat /etc/passwd"); err == nil && code == 0 {
// Spec v1.1.0 C-09: sudo -n first; sudo -S -k with cred.Password
// on fallback if policy + credential allow.
shadow, scode, used, serr := owssh.RunSudo(ctx, sess, hf.Cred, policy, "cat /etc/shadow")
shadow, scode, used, observed, serr := owssh.RunSudo(ctx, sess, hf.Cred, policy, sudoPrefer, "cat /etc/shadow")
observeSudo(observed)
if used {
sudoFallbackCount++
}
Expand Down Expand Up @@ -385,7 +426,8 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap
// (sudo denied) silently drop the entry — partial success.
if path == "/etc/shadow" {
// Spec v1.1.0 C-09 — same gating as the shadow read above.
out, code, used, err := owssh.RunSudo(ctx, sess, hf.Cred, policy, "sha256sum "+path)
out, code, used, observed, err := owssh.RunSudo(ctx, sess, hf.Cred, policy, sudoPrefer, "sha256sum "+path)
observeSudo(observed)
if used {
sudoFallbackCount++
}
Expand All @@ -412,6 +454,13 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap
// covers ufw-inactive Ubuntu hosts (count=0 via the non-sudo
// fallback inside the heredoc).

// Record the learned sudo mode once per cycle — only when a form was
// confirmed AND it differs from what was already stored (a no-op
// upsert otherwise). Spec system-connection-profile v1.2.0 C-07.
if s.profiles != nil && learnedSudo != connprofile.SudoUnknown && learnedSudo != knownSudo {
_ = s.profiles.RecordSudoMode(ctx, hf.HostID, learnedSudo)
}

return snap, sudoFallbackCount, nil
}

Expand Down
38 changes: 37 additions & 1 deletion internal/intelligence/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ type PolicyLoader interface {
LoadSecurity(ctx context.Context) (systemconfig.SecurityConfig, error)
}

// SudoProfileStore is the subset of connprofile the discovery service
// uses to learn the host's SUDO mode for the firewall probe: lead with
// the recorded mode and record the mode a sudo firewall command confirms.
// nil disables sudo-mode learning. (SSH auth-method learning is handled
// separately by the profile-aware transport.) Spec system-connection-
// profile v1.2.0.
type SudoProfileStore interface {
Get(ctx context.Context, hostID uuid.UUID) (connprofile.Profile, error)
RecordSudoMode(ctx context.Context, hostID uuid.UUID, m connprofile.SudoMode) error
}

type Service struct {
pool *pgxpool.Pool
credSvc *credential.Service
Expand All @@ -157,6 +168,7 @@ type Service struct {
lookup HostLookup
transport SSHTransport
policy PolicyLoader
profiles SudoProfileStore
}

// NewService constructs a Service. emit + bus may be nil — Discover
Expand Down Expand Up @@ -203,6 +215,15 @@ func (s *Service) WithPolicyLoader(p PolicyLoader) *Service {
return s
}

// WithProfiles enables per-host sudo-mode learning for the firewall probe:
// lead with the host's recorded sudo mode and record the mode a sudo
// firewall command confirms. nil (the default) keeps the historical
// sudo -n-first probing. Spec system-connection-profile v1.2.0 C-07.
func (s *Service) WithProfiles(p SudoProfileStore) *Service {
s.profiles = p
return s
}

// hostFacts is the internal hand-off from Discover (which knows the
// hostID and pulls addr + cred) to discoverWithTransport (which only
// needs the prepared tuple). Tests build it directly.
Expand Down Expand Up @@ -340,10 +361,25 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst
cfg.policy = sec
}
}
if svc, status, ok := probeFirewall(ctx, sess, cfg); ok {
// Sudo-mode learning: lead the firewall probe with the host's recorded
// mode, and record the mode a sudo command confirms. Best-effort — a
// lookup miss leads in the default order. Spec system-connection-
// profile v1.2.0 C-07.
var knownSudo connprofile.SudoMode
if s.profiles != nil {
if p, perr := s.profiles.Get(ctx, hf.HostID); perr == nil {
knownSudo = p.SudoMode
cfg.prefer = knownSudo
}
}
svc, status, learnedSudo, ok := probeFirewall(ctx, sess, cfg)
if ok {
facts.FirewallService = svc
facts.FirewallStatus = status
}
if s.profiles != nil && learnedSudo != connprofile.SudoUnknown && learnedSudo != knownSudo {
_ = s.profiles.RecordSudoMode(ctx, hf.HostID, learnedSudo)
}

return facts, nil
}
Expand Down
77 changes: 74 additions & 3 deletions internal/intelligence/discovery/firewall_fallback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package discovery
import (
"testing"

"github.com/Hanalyx/openwatch/internal/connprofile"
"github.com/Hanalyx/openwatch/internal/credential"
"github.com/Hanalyx/openwatch/internal/systemconfig"
)
Expand Down Expand Up @@ -56,7 +57,7 @@ func TestProbeFirewall_PasswordFallback_UFWSuccess(t *testing.T) {
policy: systemconfig.SecurityConfig{AllowCredentialSudoPassword: true},
cred: validHostCred(),
}
svc, status, ok := probeFirewall(testCtx(t), sess, cfg)
svc, status, learned, ok := probeFirewall(testCtx(t), sess, cfg)
if !ok {
t.Fatalf("ok: want true (fallback succeeded), got false")
}
Expand All @@ -66,6 +67,10 @@ func TestProbeFirewall_PasswordFallback_UFWSuccess(t *testing.T) {
if status != "active" {
t.Errorf("status: want active, got %q", status)
}
// The sudo -S fallback confirmed password sudo — learned mode.
if learned != connprofile.SudoPassword {
t.Errorf("learned sudo mode: want %q, got %q", connprofile.SudoPassword, learned)
}
// The fallback call MUST have been issued through RunWithStdin
// with the credential password on stdin.
if got := len(stub.stdinCalls); got == 0 {
Expand Down Expand Up @@ -106,10 +111,14 @@ func TestProbeFirewall_PasswordFallback_PolicyOff(t *testing.T) {
policy: systemconfig.SecurityConfig{AllowCredentialSudoPassword: false},
cred: validHostCred(),
}
_, _, ok := probeFirewall(testCtx(t), sess, cfg)
_, _, learned, ok := probeFirewall(testCtx(t), sess, cfg)
if ok {
t.Errorf("ok: want false (policy off, no sudo path succeeded), got true")
}
// Nothing confirmed sudo (policy off, every form denied).
if learned != connprofile.SudoUnknown {
t.Errorf("learned sudo mode: want unknown, got %q", learned)
}
if got := len(stub.stdinCalls); got != 0 {
t.Errorf("RunWithStdin called %d times with policy off; want 0", got)
}
Expand All @@ -133,13 +142,75 @@ func TestProbeFirewall_NoFallbackOnSudoNSuccess(t *testing.T) {
policy: systemconfig.SecurityConfig{AllowCredentialSudoPassword: true},
cred: validHostCred(),
}
svc, status, ok := probeFirewall(testCtx(t), sess, cfg)
svc, status, learned, ok := probeFirewall(testCtx(t), sess, cfg)
if !ok || svc != "firewalld" || status != "active" {
t.Errorf("first-firewall hit: svc=%q status=%q ok=%v", svc, status, ok)
}
// Sudoless firewalld hit first — no sudo command ran, so the probe
// confirms no sudo mode (learning stays with whatever liveness knows).
if learned != connprofile.SudoUnknown {
t.Errorf("learned sudo mode: want unknown (sudoless path), got %q", learned)
}
// Zero RunWithStdin calls — fallback never engaged.
if got := len(stub.stdinCalls); got != 0 {
t.Errorf("RunWithStdin called %d times though sudo -n was not even attempted", got)
}
})
}

// @spec system-connection-profile
// @ac AC-11
// AC-11 (discovery sudo): the firewall probe opportunistically reports the
// sudo mode a real sudo command confirms — NOPASSWD here (sudo -n ufw
// status exits 0) — and leads with sudo -S when the host is recorded as
// needing a password.
func TestProbeFirewall_SudoModeLearning(t *testing.T) {
t.Run("system-connection-profile/AC-11", func(t *testing.T) {
// NOPASSWD host: firewalld absent, sudo -n ufw status succeeds.
stub := newStubSSHTransport()
stub.SeedAll()
stub.outputs["sudo -n ufw status"] = stubResult{out: []byte("Status: active\n"), exitCode: 0}

sess, _ := stub.Dial(testCtx(t), "host", 22, validHostCred())
cfg := sudoFallbackConfig{
policy: systemconfig.SecurityConfig{AllowCredentialSudoPassword: true},
cred: validHostCred(),
}
svc, _, learned, ok := probeFirewall(testCtx(t), sess, cfg)
if !ok || svc != "ufw" {
t.Fatalf("probe: ok=%v svc=%q, want true/ufw", ok, svc)
}
if learned != connprofile.SudoNopasswd {
t.Errorf("learned: want %q, got %q", connprofile.SudoNopasswd, learned)
}
// NOPASSWD confirmed via sudo -n: no password fed to stdin.
if got := len(stub.stdinCalls); got != 0 {
t.Errorf("RunWithStdin called %d times on a NOPASSWD host; want 0", got)
}
})

t.Run("known password host leads with sudo -S", func(t *testing.T) {
stub := newStubSSHTransport()
stub.SeedAll()
// Only sudo -S ufw status is seeded; sudo -n is left unseeded (127).
stub.outputs["sudo -S -k -p '' ufw status"] = stubResult{out: []byte("Status: active\n"), exitCode: 0}

sess, _ := stub.Dial(testCtx(t), "host", 22, validHostCred())
cfg := sudoFallbackConfig{
policy: systemconfig.SecurityConfig{AllowCredentialSudoPassword: true},
cred: validHostCred(),
prefer: connprofile.SudoPassword,
}
svc, _, learned, ok := probeFirewall(testCtx(t), sess, cfg)
if !ok || svc != "ufw" {
t.Fatalf("probe: ok=%v svc=%q, want true/ufw", ok, svc)
}
if learned != connprofile.SudoPassword {
t.Errorf("learned: want %q, got %q", connprofile.SudoPassword, learned)
}
// Led with sudo -S: the password was fed on the first ufw attempt.
if len(stub.stdinCalls) == 0 || stub.stdinCalls[0].cmd != "sudo -S -k -p '' ufw status" {
t.Errorf("did not lead with sudo -S: stdinCalls=%+v", stub.stdinCalls)
}
})
}
Loading
Loading