Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/internal/proc/security.go

425 lines
9.4 KiB
Go

/*
CarterPerez-dev | 2026
security.go
SecurityProfile aggregates all security-relevant attributes of a running
process
Reads seccomp mode, AppArmor profile, SELinux context, no_new_privs
flag, capabilities, namespaces, and root filesystem from /proc. All
reads use graceful degradation. SecurityScore() produces a 0-100 score
and GetIssues() returns human-readable problem descriptions for
runtime auditing of container processes.
Key exports:
SecurityProfile - complete security posture of a single process
GetSecurityProfile - builds SecurityProfile from /proc/<pid>
SecurityScore, GetIssues - scoring and issue enumeration
CheckHostNamespaceSharing, IsRunningAsRoot - standalone helpers
SeccompMode - typed enum (Disabled, Strict, Filter)
Connects to:
proc/capabilities.go - CapabilitySet embedded and used for scoring
*/
package proc
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
type SecurityProfile struct {
PID int
SeccompMode SeccompMode
SeccompFilter bool
AppArmorProfile string
SELinuxContext string
NoNewPrivs bool
Capabilities *CapabilitySet
Namespaces map[string]uint64
UserNS bool
RootFS string
CgroupNS bool
}
type SeccompMode int
const (
SeccompDisabled SeccompMode = 0
SeccompStrict SeccompMode = 1
SeccompFilter SeccompMode = 2
)
func (s SeccompMode) String() string {
switch s {
case SeccompDisabled:
return "disabled"
case SeccompStrict:
return "strict"
case SeccompFilter:
return "filter"
default:
return "unknown"
}
}
func (s SeccompMode) IsEnabled() bool {
return s != SeccompDisabled
}
func GetSecurityProfile(pid int) (*SecurityProfile, error) {
procPath := fmt.Sprintf("/proc/%d", pid)
if _, err := os.Stat(procPath); os.IsNotExist(err) {
return nil, fmt.Errorf("process %d does not exist", pid)
}
profile := &SecurityProfile{
PID: pid,
Namespaces: make(map[string]uint64),
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readSeccomp(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readAppArmor(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readSELinux(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readNoNewPrivs(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readCapabilities(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readNamespaces(procPath); err != nil {
}
//nolint:staticcheck // graceful degradation - errors intentionally ignored
if err := profile.readRootFS(procPath); err != nil {
}
return profile, nil
}
func (p *SecurityProfile) readSeccomp(procPath string) error {
file, err := os.Open(filepath.Join(procPath, "status"))
if err != nil {
return err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Seccomp:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
switch parts[1] {
case "0":
p.SeccompMode = SeccompDisabled
case "1":
p.SeccompMode = SeccompStrict
case "2":
p.SeccompMode = SeccompFilter
p.SeccompFilter = true
}
}
break
}
}
return scanner.Err()
}
func (p *SecurityProfile) readAppArmor(procPath string) error {
data, err := os.ReadFile(filepath.Join(procPath, "attr/current"))
if err != nil {
attrPath := filepath.Join(procPath, "attr/apparmor/current")
data, err = os.ReadFile(attrPath)
if err != nil {
return err
}
}
profile := strings.TrimSpace(string(data))
profile = strings.TrimSuffix(profile, " (enforce)")
profile = strings.TrimSuffix(profile, " (complain)")
p.AppArmorProfile = profile
return nil
}
func (p *SecurityProfile) readSELinux(procPath string) error {
data, err := os.ReadFile(filepath.Join(procPath, "attr/current"))
if err != nil {
return err
}
context := strings.TrimSpace(string(data))
if strings.Contains(context, ":") {
p.SELinuxContext = context
}
return nil
}
func (p *SecurityProfile) readNoNewPrivs(procPath string) error {
file, err := os.Open(filepath.Join(procPath, "status"))
if err != nil {
return err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "NoNewPrivs:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
p.NoNewPrivs = parts[1] == "1"
}
break
}
}
return scanner.Err()
}
func (p *SecurityProfile) readCapabilities(procPath string) error {
file, err := os.Open(filepath.Join(procPath, "status"))
if err != nil {
return err
}
defer func() { _ = file.Close() }()
p.Capabilities = &CapabilitySet{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "CapInh":
_, _ = fmt.Sscanf(value, "%x", &p.Capabilities.Inheritable)
case "CapPrm":
_, _ = fmt.Sscanf(value, "%x", &p.Capabilities.Permitted)
case "CapEff":
_, _ = fmt.Sscanf(value, "%x", &p.Capabilities.Effective)
case "CapBnd":
_, _ = fmt.Sscanf(value, "%x", &p.Capabilities.Bounding)
case "CapAmb":
_, _ = fmt.Sscanf(value, "%x", &p.Capabilities.Ambient)
}
}
return scanner.Err()
}
func (p *SecurityProfile) readNamespaces(procPath string) error {
nsPath := filepath.Join(procPath, "ns")
entries, err := os.ReadDir(nsPath)
if err != nil {
return err
}
for _, entry := range entries {
link, err := os.Readlink(filepath.Join(nsPath, entry.Name()))
if err != nil {
continue
}
var inode uint64
_, _ = fmt.Sscanf(link, "%*[^[]:[%d]", &inode)
p.Namespaces[entry.Name()] = inode
if entry.Name() == "user" {
initLink, _ := os.Readlink("/proc/1/ns/user")
var initInode uint64
_, _ = fmt.Sscanf(initLink, "%*[^[]:[%d]", &initInode)
p.UserNS = inode != initInode
}
if entry.Name() == "cgroup" {
initLink, _ := os.Readlink("/proc/1/ns/cgroup")
var initInode uint64
_, _ = fmt.Sscanf(initLink, "%*[^[]:[%d]", &initInode)
p.CgroupNS = inode != initInode
}
}
return nil
}
func (p *SecurityProfile) readRootFS(procPath string) error {
link, err := os.Readlink(filepath.Join(procPath, "root"))
if err != nil {
return err
}
p.RootFS = link
return nil
}
func (p *SecurityProfile) HasSeccompEnabled() bool {
return p.SeccompMode.IsEnabled()
}
func (p *SecurityProfile) HasAppArmorEnabled() bool {
return p.AppArmorProfile != "" &&
p.AppArmorProfile != "unconfined" &&
!strings.HasPrefix(p.AppArmorProfile, "unconfined")
}
func (p *SecurityProfile) HasSELinuxEnabled() bool {
return p.SELinuxContext != "" &&
!strings.Contains(p.SELinuxContext, "unconfined")
}
func (p *SecurityProfile) HasMACEnabled() bool {
return p.HasAppArmorEnabled() || p.HasSELinuxEnabled()
}
func (p *SecurityProfile) HasUserNamespace() bool {
return p.UserNS
}
func (p *SecurityProfile) IsPrivileged() bool {
if p.Capabilities == nil {
return false
}
return p.Capabilities.IsFullyPrivileged()
}
func (p *SecurityProfile) SecurityScore() int {
score := 100
if !p.HasSeccompEnabled() {
score -= 20
}
if !p.HasMACEnabled() {
score -= 15
}
if !p.NoNewPrivs {
score -= 10
}
if p.Capabilities != nil {
switch {
case p.Capabilities.IsFullyPrivileged():
score -= 40
case p.Capabilities.HasCriticalCapabilities():
score -= 25
case p.Capabilities.HasDangerousCapabilities():
score -= 15
}
}
if !p.UserNS {
score -= 5
}
if score < 0 {
score = 0
}
return score
}
func (p *SecurityProfile) GetIssues() []string {
var issues []string
if !p.HasSeccompEnabled() {
issues = append(issues, "Seccomp filtering is disabled")
}
if !p.HasMACEnabled() {
issues = append(issues, "No MAC (AppArmor/SELinux) profile active")
}
if !p.NoNewPrivs {
issues = append(issues, "no_new_privs is not set")
}
if p.Capabilities != nil {
if p.Capabilities.IsFullyPrivileged() {
issues = append(
issues,
"Process has full capabilities (privileged)",
)
} else {
for _, cap := range p.Capabilities.GetCriticalCapabilities() {
issues = append(
issues,
fmt.Sprintf("Has critical capability: %s", cap),
)
}
}
}
return issues
}
func CheckHostNamespaceSharing(pid int) (map[string]bool, error) {
shared := make(map[string]bool)
namespaces := []string{"pid", "net", "ipc", "uts", "mnt"}
for _, ns := range namespaces {
procLink, err := os.Readlink(fmt.Sprintf("/proc/%d/ns/%s", pid, ns))
if err != nil {
continue
}
initLink, err := os.Readlink(fmt.Sprintf("/proc/1/ns/%s", ns))
if err != nil {
continue
}
shared[ns] = procLink == initLink
}
return shared, nil
}
func IsRunningAsRoot(pid int) (bool, error) {
file, err := os.Open(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return false, err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
return fields[1] == "0", nil
}
}
}
return false, scanner.Err()
}