|
|
|
@@ -0,0 +1,395 @@
|
|
|
|
|
// security-agent is the deliberately tiny privileged boundary for manual blocking.
|
|
|
|
|
// It accepts only a fixed JSON protocol over a Unix socket; it never invokes a shell
|
|
|
|
|
// and never accepts command, jail, action, path, or argument strings from NestJS.
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type request struct {
|
|
|
|
|
Action string `json:"action"`
|
|
|
|
|
OperationKey string `json:"operationKey"`
|
|
|
|
|
SourceIP string `json:"sourceIp"`
|
|
|
|
|
Executor string `json:"executor"`
|
|
|
|
|
DurationSeconds int `json:"durationSeconds"`
|
|
|
|
|
Version int `json:"version"`
|
|
|
|
|
Rules []rule `json:"rules"`
|
|
|
|
|
}
|
|
|
|
|
type rule struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
Threshold int `json:"threshold"`
|
|
|
|
|
WindowSeconds int `json:"windowSeconds"`
|
|
|
|
|
CooldownSeconds int `json:"cooldownSeconds"`
|
|
|
|
|
}
|
|
|
|
|
type response struct {
|
|
|
|
|
OK bool `json:"ok"`
|
|
|
|
|
Reference string `json:"reference,omitempty"`
|
|
|
|
|
Blocked bool `json:"blocked,omitempty"`
|
|
|
|
|
Active bool `json:"active,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
type block struct {
|
|
|
|
|
OperationKey string `json:"operationKey"`
|
|
|
|
|
SourceIP string `json:"sourceIp"`
|
|
|
|
|
Executor string `json:"executor"`
|
|
|
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
|
|
|
}
|
|
|
|
|
type state struct {
|
|
|
|
|
Blocks map[string]block `json:"blocks"`
|
|
|
|
|
RuleVersion int `json:"ruleVersion"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var allowedDurations = map[int]bool{600: true, 3600: true, 86400: true, 604800: true}
|
|
|
|
|
var allowedRules = map[string]bool{"admin_login_failure": true, "client_login_failure": true, "ssh_auth_failure": true, "cmpp_auth_failure": true, "cmpp_protocol_abuse": true, "http_invalid_api_key": true, "http_signature_failure": true, "http_replay_attempt": true, "http_malicious_scan": true}
|
|
|
|
|
|
|
|
|
|
type agent struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
statePath, nginxInclude, fail2banConfig string
|
|
|
|
|
data state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
if len(os.Args) == 4 && os.Args[1] == "report" {
|
|
|
|
|
if err := reportEvent(os.Args[2], os.Args[3]); err != nil {
|
|
|
|
|
log.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
socketPath := env("SECURITY_AGENT_SOCKET", "/run/cmpp-security-agent/agent.sock")
|
|
|
|
|
a := &agent{statePath: env("SECURITY_AGENT_STATE", "/var/lib/cmpp-security-agent/state.json"), nginxInclude: env("SECURITY_NGINX_DENY_INCLUDE", "/etc/nginx/snippets/cmpp-security-deny.conf"), fail2banConfig: env("SECURITY_FAIL2BAN_CONFIG", "/etc/fail2ban/jail.d/cmpp-platform-generated.local"), data: state{Blocks: map[string]block{}}}
|
|
|
|
|
if err := a.load(); err != nil {
|
|
|
|
|
log.Fatalf("load state: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0750); err != nil {
|
|
|
|
|
log.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
_ = os.Remove(socketPath)
|
|
|
|
|
listener, err := net.Listen("unix", socketPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Chmod(socketPath, 0660); err != nil {
|
|
|
|
|
log.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
log.Printf("security agent listening on %s", socketPath)
|
|
|
|
|
for {
|
|
|
|
|
connection, err := listener.Accept()
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("accept: %v", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
go a.serve(connection)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *agent) serve(connection net.Conn) {
|
|
|
|
|
defer connection.Close()
|
|
|
|
|
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
|
var req request
|
|
|
|
|
if err := json.NewDecoder(bufio.NewReader(connection)).Decode(&req); err != nil {
|
|
|
|
|
write(connection, response{Error: "invalid request"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
defer a.mu.Unlock()
|
|
|
|
|
a.prune()
|
|
|
|
|
var result response
|
|
|
|
|
switch req.Action {
|
|
|
|
|
case "block":
|
|
|
|
|
result = a.block(req)
|
|
|
|
|
case "unblock":
|
|
|
|
|
result = a.unblock(req)
|
|
|
|
|
case "status":
|
|
|
|
|
result = a.status(req)
|
|
|
|
|
case "apply_rules":
|
|
|
|
|
result = a.applyRules(req)
|
|
|
|
|
default:
|
|
|
|
|
result = response{Error: "unsupported action"}
|
|
|
|
|
}
|
|
|
|
|
write(connection, result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *agent) block(req request) response {
|
|
|
|
|
if net.ParseIP(req.SourceIP) == nil || !allowedDurations[req.DurationSeconds] || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") || len(req.OperationKey) < 16 {
|
|
|
|
|
return response{Error: "invalid fixed block parameters"}
|
|
|
|
|
}
|
|
|
|
|
if existing, ok := a.data.Blocks[req.OperationKey]; ok {
|
|
|
|
|
return response{OK: true, Reference: req.OperationKey, Blocked: existing.ExpiresAt.After(time.Now())}
|
|
|
|
|
}
|
|
|
|
|
if req.Executor == "nftables" {
|
|
|
|
|
if err := nftBlock(req.SourceIP, req.DurationSeconds); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
a.data.Blocks[req.OperationKey] = block{OperationKey: req.OperationKey, SourceIP: req.SourceIP, Executor: req.Executor, ExpiresAt: time.Now().Add(time.Duration(req.DurationSeconds) * time.Second)}
|
|
|
|
|
if req.Executor == "nginx_real_ip" {
|
|
|
|
|
if err := a.writeNginx(); err != nil {
|
|
|
|
|
delete(a.data.Blocks, req.OperationKey)
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := a.save(); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
return a.status(req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *agent) unblock(req request) response {
|
|
|
|
|
if net.ParseIP(req.SourceIP) == nil || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") {
|
|
|
|
|
return response{Error: "invalid fixed unblock parameters"}
|
|
|
|
|
}
|
|
|
|
|
if req.Executor == "nftables" {
|
|
|
|
|
if err := nftUnblock(req.SourceIP); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for key, item := range a.data.Blocks {
|
|
|
|
|
if item.SourceIP == req.SourceIP && item.Executor == req.Executor {
|
|
|
|
|
delete(a.data.Blocks, key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if req.Executor == "nginx_real_ip" {
|
|
|
|
|
if err := a.writeNginx(); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := a.save(); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
return response{OK: true, Reference: req.OperationKey}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *agent) status(req request) response {
|
|
|
|
|
active := command("systemctl", "is-active", "--quiet", "fail2ban") == nil
|
|
|
|
|
if req.SourceIP == "" {
|
|
|
|
|
return response{OK: true, Active: active}
|
|
|
|
|
}
|
|
|
|
|
blocked := false
|
|
|
|
|
if req.Executor == "nftables" {
|
|
|
|
|
family := "blocked_ipv6"
|
|
|
|
|
if net.ParseIP(req.SourceIP).To4() != nil {
|
|
|
|
|
family = "blocked_ipv4"
|
|
|
|
|
}
|
|
|
|
|
output, err := exec.Command("nft", "list", "set", "inet", "cmpp_security", family).CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return response{Error: "nftables readback failed: " + strings.TrimSpace(string(output))}
|
|
|
|
|
}
|
|
|
|
|
blocked = strings.Contains(string(output), req.SourceIP+" timeout") || strings.Contains(string(output), req.SourceIP+" expires")
|
|
|
|
|
} else if req.Executor == "nginx_real_ip" {
|
|
|
|
|
content, err := os.ReadFile(a.nginxInclude)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return response{Error: "nginx deny readback failed: " + err.Error()}
|
|
|
|
|
}
|
|
|
|
|
blocked = strings.Contains(string(content), "deny "+req.SourceIP+";")
|
|
|
|
|
} else {
|
|
|
|
|
return response{Error: "invalid executor"}
|
|
|
|
|
}
|
|
|
|
|
for _, item := range a.data.Blocks {
|
|
|
|
|
if blocked && item.SourceIP == req.SourceIP && item.Executor == req.Executor && item.ExpiresAt.After(time.Now()) {
|
|
|
|
|
return response{OK: true, Active: active, Blocked: true, Reference: item.OperationKey}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return response{OK: true, Active: active, Blocked: false}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *agent) applyRules(req request) response {
|
|
|
|
|
if req.Version <= a.data.RuleVersion {
|
|
|
|
|
return response{OK: true, Reference: strconv.Itoa(a.data.RuleVersion)}
|
|
|
|
|
}
|
|
|
|
|
for _, item := range req.Rules {
|
|
|
|
|
if !allowedRules[item.Code] || item.Threshold < 1 || item.Threshold > 100000 || item.WindowSeconds < 10 || item.WindowSeconds > 86400 || item.CooldownSeconds < 0 || item.CooldownSeconds > 604800 {
|
|
|
|
|
return response{Error: "invalid fixed rule configuration"}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var ssh, scan *rule
|
|
|
|
|
for index := range req.Rules {
|
|
|
|
|
if req.Rules[index].Code == "ssh_auth_failure" {
|
|
|
|
|
ssh = &req.Rules[index]
|
|
|
|
|
}
|
|
|
|
|
if req.Rules[index].Code == "http_malicious_scan" {
|
|
|
|
|
scan = &req.Rules[index]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
content := "# Generated by cmpp-security-agent. Manual changes will be overwritten.\n"
|
|
|
|
|
if ssh != nil {
|
|
|
|
|
content += jail("sshd", *ssh)
|
|
|
|
|
}
|
|
|
|
|
if scan != nil {
|
|
|
|
|
content += jail("cmpp-http-scan", *scan)
|
|
|
|
|
}
|
|
|
|
|
previous, previousErr := os.ReadFile(a.fail2banConfig)
|
|
|
|
|
if err := atomicWrite(a.fail2banConfig, []byte(content), 0640); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
if err := command("fail2ban-client", "-t"); err != nil {
|
|
|
|
|
restore(a.fail2banConfig, previous, previousErr, 0640)
|
|
|
|
|
return response{Error: "fail2ban validation failed: " + err.Error()}
|
|
|
|
|
}
|
|
|
|
|
if err := command("fail2ban-client", "reload"); err != nil {
|
|
|
|
|
restore(a.fail2banConfig, previous, previousErr, 0640)
|
|
|
|
|
return response{Error: "fail2ban reload failed: " + err.Error()}
|
|
|
|
|
}
|
|
|
|
|
a.data.RuleVersion = req.Version
|
|
|
|
|
if err := a.save(); err != nil {
|
|
|
|
|
return response{Error: err.Error()}
|
|
|
|
|
}
|
|
|
|
|
return response{OK: true, Reference: strconv.Itoa(req.Version), Active: true}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func jail(name string, item rule) string {
|
|
|
|
|
enabled := "false"
|
|
|
|
|
if item.Enabled {
|
|
|
|
|
enabled = "true"
|
|
|
|
|
}
|
|
|
|
|
bantime := item.CooldownSeconds
|
|
|
|
|
if bantime < item.WindowSeconds {
|
|
|
|
|
bantime = item.WindowSeconds
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("\n[%s]\nenabled = %s\nfindtime = %d\nmaxretry = %d\nbantime = %d\naction = cmpp-report-only\n", name, enabled, item.WindowSeconds, item.Threshold, bantime)
|
|
|
|
|
}
|
|
|
|
|
func nftBlock(ip string, seconds int) error {
|
|
|
|
|
family := "blocked_ipv6"
|
|
|
|
|
if net.ParseIP(ip).To4() != nil {
|
|
|
|
|
family = "blocked_ipv4"
|
|
|
|
|
}
|
|
|
|
|
return command("nft", "add", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s timeout %ds }", ip, seconds))
|
|
|
|
|
}
|
|
|
|
|
func nftUnblock(ip string) error {
|
|
|
|
|
family := "blocked_ipv6"
|
|
|
|
|
if net.ParseIP(ip).To4() != nil {
|
|
|
|
|
family = "blocked_ipv4"
|
|
|
|
|
}
|
|
|
|
|
err := command("nft", "delete", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s }", ip))
|
|
|
|
|
if err != nil && strings.Contains(err.Error(), "No such file") {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
func (a *agent) writeNginx() error {
|
|
|
|
|
ips := []string{}
|
|
|
|
|
for _, item := range a.data.Blocks {
|
|
|
|
|
if item.Executor == "nginx_real_ip" && item.ExpiresAt.After(time.Now()) {
|
|
|
|
|
ips = append(ips, item.SourceIP)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(ips)
|
|
|
|
|
lines := []string{"# Generated by cmpp-security-agent."}
|
|
|
|
|
for _, ip := range ips {
|
|
|
|
|
lines = append(lines, "deny "+ip+";")
|
|
|
|
|
}
|
|
|
|
|
previous, previousErr := os.ReadFile(a.nginxInclude)
|
|
|
|
|
if err := atomicWrite(a.nginxInclude, []byte(strings.Join(lines, "\n")+"\n"), 0640); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := command("nginx", "-t"); err != nil {
|
|
|
|
|
restore(a.nginxInclude, previous, previousErr, 0640)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := command("systemctl", "reload", "nginx"); err != nil {
|
|
|
|
|
restore(a.nginxInclude, previous, previousErr, 0640)
|
|
|
|
|
_ = command("systemctl", "reload", "nginx")
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
func (a *agent) prune() {
|
|
|
|
|
changed := false
|
|
|
|
|
for key, item := range a.data.Blocks {
|
|
|
|
|
if !item.ExpiresAt.After(time.Now()) {
|
|
|
|
|
delete(a.data.Blocks, key)
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if changed {
|
|
|
|
|
_ = a.writeNginx()
|
|
|
|
|
_ = a.save()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
func (a *agent) load() error {
|
|
|
|
|
bytes, err := os.ReadFile(a.statePath)
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return json.Unmarshal(bytes, &a.data)
|
|
|
|
|
}
|
|
|
|
|
func (a *agent) save() error {
|
|
|
|
|
bytes, err := json.MarshalIndent(a.data, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return atomicWrite(a.statePath, bytes, 0600)
|
|
|
|
|
}
|
|
|
|
|
func atomicWrite(path string, bytes []byte, mode os.FileMode) error {
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
temporary := path + ".tmp"
|
|
|
|
|
if err := os.WriteFile(temporary, bytes, mode); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return os.Rename(temporary, path)
|
|
|
|
|
}
|
|
|
|
|
func restore(path string, previous []byte, previousErr error, mode os.FileMode) {
|
|
|
|
|
if previousErr == nil {
|
|
|
|
|
_ = atomicWrite(path, previous, mode)
|
|
|
|
|
} else if errors.Is(previousErr, os.ErrNotExist) {
|
|
|
|
|
_ = os.Remove(path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
func command(name string, args ...string) error {
|
|
|
|
|
output, err := exec.Command(name, args...).CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output)))
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
func write(connection net.Conn, value response) { _ = json.NewEncoder(connection).Encode(value) }
|
|
|
|
|
func env(name, fallback string) string {
|
|
|
|
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return fallback
|
|
|
|
|
}
|
|
|
|
|
func reportEvent(jail, ip string) error {
|
|
|
|
|
ruleCode := map[string]string{"sshd": "ssh_auth_failure", "cmpp-http-scan": "http_malicious_scan"}[jail]
|
|
|
|
|
if ruleCode == "" || net.ParseIP(ip) == nil {
|
|
|
|
|
return errors.New("unsupported report event")
|
|
|
|
|
}
|
|
|
|
|
body := fmt.Sprintf(`{"ruleCode":%q,"sourceIp":%q,"protocol":%q,"resultCode":%q}`, ruleCode, ip, "fail2ban", jail)
|
|
|
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
|
|
|
request, err := http.NewRequest(http.MethodPost, env("SECURITY_EVENT_URL", "http://127.0.0.1:3000/api/gateway/events/security-detection"), strings.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
request.Header.Set("X-Security-Event-Token", os.Getenv("SECURITY_EVENT_TOKEN"))
|
|
|
|
|
response, err := client.Do(request)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer response.Body.Close()
|
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
|
|
|
return fmt.Errorf("event collector returned %s", response.Status)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|