Cybersecurity-Projects/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go

197 lines
3.9 KiB
Go

/*
©AngelaMos | 2026
ssh.go
Scans SSH configuration and authorized keys for persistence indicators
Checks authorized_keys files for forced command options, sshd_config
for dangerous directives like PermitRootLogin and non-standard
AuthorizedKeysFile paths, and detects ~/.ssh/rc login scripts.
MITRE ATT&CK:
T1098.004 - Account Manipulation: SSH Authorized Keys
*/
package scanner
import (
"path/filepath"
"strings"
"github.com/CarterPerez-dev/sentinel/pkg/types"
)
const (
sshScannerName = "ssh"
mitreSSH = "T1098.004"
)
func init() {
Register(&SSHScanner{})
}
type SSHScanner struct{}
func (s *SSHScanner) Name() string {
return sshScannerName
}
func (s *SSHScanner) Scan(
root string,
) []types.Finding {
var findings []types.Finding
for _, home := range FindUserDirs(root) {
findings = append(
findings,
s.scanAuthorizedKeys(
filepath.Join(
home, ".ssh", "authorized_keys",
),
)...,
)
findings = append(
findings,
s.scanAuthorizedKeys(
filepath.Join(
home, ".ssh", "authorized_keys2",
),
)...,
)
rc := filepath.Join(home, ".ssh", "rc")
if FileExists(rc) {
findings = append(findings, types.Finding{
Scanner: sshScannerName,
Severity: types.SeverityMedium,
Title: "SSH rc script detected",
Path: rc,
Evidence: "~/.ssh/rc executes on every SSH login",
MITRE: mitreSSH,
})
findings = append(
findings,
ScanFileForPatterns(
rc, sshScannerName, mitreSSH,
)...,
)
}
}
sshdConfig := ResolveRoot(root, "/etc/ssh/sshd_config")
findings = append(
findings,
s.scanSSHDConfig(sshdConfig)...,
)
sshdConfigD := ResolveRoot(
root, "/etc/ssh/sshd_config.d",
)
for _, path := range ListFiles(sshdConfigD) {
findings = append(
findings,
s.scanSSHDConfig(path)...,
)
}
return findings
}
func (s *SSHScanner) scanAuthorizedKeys(
path string,
) []types.Finding {
lines := ReadLines(path)
if lines == nil {
return nil
}
var findings []types.Finding
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, "command=") {
findings = append(findings, types.Finding{
Scanner: sshScannerName,
Severity: types.SeverityHigh,
Title: "Forced command in authorized_keys",
Path: path,
Evidence: truncate(trimmed),
MITRE: mitreSSH,
})
}
if strings.Contains(trimmed, "environment=") {
findings = append(findings, types.Finding{
Scanner: sshScannerName,
Severity: types.SeverityHigh,
Title: "Environment override in authorized_keys",
Path: path,
Evidence: truncate(trimmed),
MITRE: mitreSSH,
})
}
}
return findings
}
func (s *SSHScanner) scanSSHDConfig(
path string,
) []types.Finding {
lines := ReadLines(path)
if lines == nil {
return nil
}
var findings []types.Finding
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "permitrootlogin") &&
strings.Contains(lower, "yes") {
findings = append(findings, types.Finding{
Scanner: sshScannerName,
Severity: types.SeverityMedium,
Title: "PermitRootLogin enabled",
Path: path,
Evidence: trimmed,
MITRE: mitreSSH,
})
}
if strings.HasPrefix(lower, "authorizedkeysfile") {
val := strings.Fields(trimmed)
if len(val) >= 2 {
keyPath := val[1]
if !strings.Contains(
keyPath, ".ssh/authorized_keys",
) {
findings = append(
findings, types.Finding{
Scanner: sshScannerName,
Severity: types.SeverityHigh,
Title: "Non-standard AuthorizedKeysFile path",
Path: path,
Evidence: trimmed,
MITRE: mitreSSH,
},
)
}
}
}
}
return findings
}
const maxKeyLineLen = 120
func truncate(s string) string {
if len(s) <= maxKeyLineLen {
return s
}
return s[:maxKeyLineLen] + "..."
}