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: 8 additions & 0 deletions cmd/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/jmpsec/osctrl/pkg/geoip"
"github.com/jmpsec/osctrl/pkg/logging"
"github.com/jmpsec/osctrl/pkg/nodes"
"github.com/jmpsec/osctrl/pkg/posture"
"github.com/jmpsec/osctrl/pkg/queries"
"github.com/jmpsec/osctrl/pkg/settings"
"github.com/jmpsec/osctrl/pkg/tags"
Expand All @@ -34,6 +35,7 @@ type HandlersApi struct {
RedisCache *cache.RedisManager
Activity activityReader
GeoIP *geoip.GeoIPResolver
Posture *posture.PostureManager
ServiceVersion string
ServiceName string
AuditLog *auditlog.AuditLogManager
Expand Down Expand Up @@ -131,6 +133,12 @@ func WithGeoIP(g *geoip.GeoIPResolver) HandlersOption {
}
}

func WithPosture(pm *posture.PostureManager) HandlersOption {
return func(h *HandlersApi) {
h.Posture = pm
}
}

func WithVersion(version string) HandlersOption {
return func(h *HandlersApi) {
h.ServiceVersion = version
Expand Down
116 changes: 116 additions & 0 deletions cmd/api/handlers/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/jmpsec/osctrl/pkg/nodes"
"github.com/jmpsec/osctrl/pkg/posture"
"github.com/jmpsec/osctrl/pkg/settings"
"github.com/jmpsec/osctrl/pkg/types"
"github.com/jmpsec/osctrl/pkg/users"
Expand Down Expand Up @@ -598,3 +599,118 @@ func (h *HandlersApi) NodesPagedHandler(w http.ResponseWriter, r *http.Request)
TotalPages: totalPages,
})
}

// PostureProfilesHandler — GET /api/v1/posture/profiles
//
// Returns all predefined posture check profiles that operators can merge
// into an environment's schedule config.
// @Summary List posture profiles
// @Description Returns predefined posture check profiles for different node types.
// @Tags posture
// @Produce json
// @Success 200 {array} posture.PostureProfile
// @Security ApiKeyAuth
// @Router /api/v1/posture/profiles [get]
func (h *HandlersApi) PostureProfilesHandler(w http.ResponseWriter, r *http.Request) {
ctxVal := r.Context().Value(ContextKey(contextAPI))
if ctxVal == nil {
apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil)
return
}
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, posture.AllProfiles())
}

// PostureProfileHandler — GET /api/v1/posture/profiles/{id}
//
// Returns a single posture profile by ID.
// @Summary Get posture profile
// @Description Returns a single posture check profile by ID.
// @Tags posture
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} posture.PostureProfile
// @Failure 404 {object} types.ApiErrorResponse "Not found"
// @Security ApiKeyAuth
// @Router /api/v1/posture/profiles/{id} [get]
func (h *HandlersApi) PostureProfileHandler(w http.ResponseWriter, r *http.Request) {
ctxVal := r.Context().Value(ContextKey(contextAPI))
if ctxVal == nil {
apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil)
return
}
profileID := r.PathValue("id")
if profileID == "" {
apiErrorResponse(w, "profile id required", http.StatusBadRequest, nil)
return
}
profile := posture.GetProfile(profileID)
if profile == nil {
apiErrorResponse(w, "profile not found", http.StatusNotFound, nil)
return
}
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, profile)
}

// NodePostureHandler — GET /api/v1/nodes/{env}/node/{uuid}/posture
//
// Returns all posture categories for a node (latest snapshot per category).
// @Summary Get node posture
// @Description Returns security and compliance posture data for a node.
// @Tags nodes
// @Produce json
// @Param env path string true "Environment name or UUID"
// @Param uuid path string true "Node UUID"
// @Success 200 {array} posture.NodePosture
// @Failure 401 {object} types.ApiErrorResponse "Unauthorized"
// @Failure 403 {object} types.ApiErrorResponse "Forbidden"
// @Failure 404 {object} types.ApiErrorResponse "Not found"
// @Security ApiKeyAuth
// @Router /api/v1/nodes/{env}/node/{uuid}/posture [get]
func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request) {
ctxVal := r.Context().Value(ContextKey(contextAPI))
if ctxVal == nil {
apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil)
return
}
ctx := ctxVal.(ContextValue)
user := ctx[ctxUser]

envVar := r.PathValue("env")
uuidVar := r.PathValue("uuid")
if envVar == "" || uuidVar == "" {
apiErrorResponse(w, "env and uuid required", http.StatusBadRequest, nil)
return
}
env, err := h.Envs.Get(envVar)
if err != nil {
apiErrorResponse(w, "error getting environment", http.StatusNotFound, err)
return
}
if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) {
apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user))
return
}
node, err := h.Nodes.GetByUUID(uuidVar)
if err != nil {
apiErrorResponse(w, "node not found", http.StatusNotFound, err)
return
}
if !strings.EqualFold(node.Environment, env.Name) {
apiErrorResponse(w, "node not in environment", http.StatusForbidden, nil)
return
}
if h.Posture == nil {
apiErrorResponse(w, "posture not configured", http.StatusServiceUnavailable, nil)
return
}
records, err := h.Posture.GetByNode(node.UUID)
if err != nil {
apiErrorResponse(w, "error getting posture", http.StatusInternalServerError, err)
return
}
if records == nil {
records = []posture.NodePosture{}
}
h.AuditLog.NodeAction(ctx[ctxUser], "viewed posture for "+uuidVar, strings.Split(r.RemoteAddr, ":")[0], env.ID)
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, records)
}
14 changes: 14 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/jmpsec/osctrl/pkg/logging"
"github.com/jmpsec/osctrl/pkg/nodes"
"github.com/jmpsec/osctrl/pkg/osquery"
"github.com/jmpsec/osctrl/pkg/posture"
"github.com/jmpsec/osctrl/pkg/queries"
"github.com/jmpsec/osctrl/pkg/ratelimit"
"github.com/jmpsec/osctrl/pkg/settings"
Expand Down Expand Up @@ -296,6 +297,7 @@ func osctrlAPIService() {

log.Info().Msg("Initialize environment")
envs = environments.CreateEnvironment(db.Conn)
posturemgr := posture.NewPostureManager(db.Conn)
// Initialize settings
log.Info().Msg("Initialize settings")
settingsmgr = settings.NewSettings(db.Conn)
Expand Down Expand Up @@ -366,6 +368,7 @@ func osctrlAPIService() {
handlers.WithCache(redis),
handlers.WithActivityReader(activity.NewRedisStore(redis.Client, activity.DefaultPrefix, activity.DefaultRetentionDays, 8*24*time.Hour)),
handlers.WithGeoIP(geoIPResolver),
handlers.WithPosture(posturemgr),
handlers.WithVersion(buildVersion),
handlers.WithName(serviceName),
handlers.WithAuditLog(auditLog),
Expand Down Expand Up @@ -480,6 +483,17 @@ func osctrlAPIService() {
muxAPI.Handle(
"GET "+_apiPath(apiNodesPath)+"/{env}",
handlerAuthCheck(http.HandlerFunc(handlersApi.NodesPagedHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
// API: node posture (security & compliance data)
muxAPI.Handle(
"GET "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/posture",
handlerAuthCheck(http.HandlerFunc(handlersApi.NodePostureHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
// API: posture profiles (predefined check templates)
muxAPI.Handle(
"GET "+_apiPath("/posture")+"/profiles",
handlerAuthCheck(http.HandlerFunc(handlersApi.PostureProfilesHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
muxAPI.Handle(
"GET "+_apiPath("/posture")+"/profiles/{id}",
handlerAuthCheck(http.HandlerFunc(handlersApi.PostureProfileHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
// API: node logs
muxAPI.Handle(
"GET "+_apiPath(apiLogsPath)+"/{type}/{env}/{uuid}",
Expand Down
9 changes: 9 additions & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/term"
"gorm.io/gorm/logger"

"github.com/urfave/cli/v3"
)
Expand Down Expand Up @@ -76,6 +77,12 @@ var (
formats map[string]bool
)

func quietDBLogger() {
if db != nil && db.Conn != nil {
db.Conn.Logger = logger.Discard
}
}

// Variables for flags
var (
dbFlag bool
Expand Down Expand Up @@ -1922,6 +1929,7 @@ func checkDB(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("failed to create backend - %w", err)
}
}
quietDBLogger()
if err := db.Check(); err != nil {
return err
}
Expand Down Expand Up @@ -2080,6 +2088,7 @@ func cliWrapper(action func(context.Context, *cli.Command) error) func(context.C
return fmt.Errorf("in CreateDBManager - %w", err)
}
}
quietDBLogger()
// Initialize users. The CLI manages user/permission rows directly
// and never mints JWTs, so we skip WithJWT — CreateToken fails
// fast if anything in this binary ever tries to call it.
Expand Down
8 changes: 8 additions & 0 deletions cmd/tls/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/jmpsec/osctrl/pkg/environments"
"github.com/jmpsec/osctrl/pkg/logging"
"github.com/jmpsec/osctrl/pkg/nodes"
"github.com/jmpsec/osctrl/pkg/posture"
"github.com/jmpsec/osctrl/pkg/queries"
"github.com/jmpsec/osctrl/pkg/settings"
"github.com/jmpsec/osctrl/pkg/tags"
Expand Down Expand Up @@ -62,6 +63,7 @@ type HandlersTLS struct {
Logs *logging.LoggerTLS
WriteHandler *batchWriter
ActivityWriter *activityWriter
Posture *posture.PostureManager
OsqueryValues *config.YAMLConfigurationOsquery
ConfigEndpoints *config.YAMLConfigurationEndpoints
DebugHTTP *zerolog.Logger
Expand Down Expand Up @@ -163,6 +165,12 @@ func WithActivityWriter(activityWriter *activityWriter) Option {
}
}

func WithPosture(pm *posture.PostureManager) Option {
return func(h *HandlersTLS) {
h.Posture = pm
}
}

// WithOsqueryValues to pass osquery configuration values
func WithOsqueryValues(values *config.YAMLConfigurationOsquery) Option {
return func(h *HandlersTLS) {
Expand Down
47 changes: 46 additions & 1 deletion cmd/tls/handlers/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/jmpsec/osctrl/pkg/activity"
"github.com/jmpsec/osctrl/pkg/environments"
"github.com/jmpsec/osctrl/pkg/nodes"
"github.com/jmpsec/osctrl/pkg/posture"
"github.com/jmpsec/osctrl/pkg/queries"
"github.com/jmpsec/osctrl/pkg/settings"
"github.com/jmpsec/osctrl/pkg/types"
Expand Down Expand Up @@ -356,9 +357,13 @@ func (h *HandlersTLS) LogHandler(w http.ResponseWriter, r *http.Request) {
// Process logs and update metadata
go func() {
start := time.Now()
h.Logs.ProcessLogs(t.Data, t.LogType, env.Name, utils.GetIP(r), len(body), (*h.EnvsMap)[env.Name].DebugHTTP)
results := h.Logs.ProcessLogs(t.Data, t.LogType, env.Name, utils.GetIP(r), len(body), (*h.EnvsMap)[env.Name].DebugHTTP)
duration := time.Since(start).Seconds()
logProcessDuration.WithLabelValues(string(env.UUID), t.LogType).Observe(duration)
// Ingest posture data from result logs (if enabled)
if h.Posture != nil && t.LogType == "result" {
h.ingestPosture(results, node.UUID, env.Name)
}
}()
} else {
nodeInvalid = true
Expand Down Expand Up @@ -1388,3 +1393,43 @@ func (h *HandlersTLS) OsqueryConfigEndpointHandler(w http.ResponseWriter, r *htt
// Send response
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, response)
}

// ingestPosture parses result log entries and feeds any posture-prefixed
// query results into the PostureManager.
func (h *HandlersTLS) ingestPosture(results []types.LogResultData, nodeUUID, environment string) {
grouped := make(map[string][]json.RawMessage)
var queryOrder []string
for _, r := range results {
if !posture.IsPostureQuery(r.Name) {
continue
}
if _, ok := grouped[r.Name]; !ok {
queryOrder = append(queryOrder, r.Name)
grouped[r.Name] = []json.RawMessage{}
}
var rows []json.RawMessage
trimmed := bytes.TrimSpace(r.Columns)
switch {
case len(trimmed) == 0:
rows = []json.RawMessage{}
case trimmed[0] == '[':
if err := json.Unmarshal(trimmed, &rows); err != nil {
log.Warn().Err(err).Str("query", r.Name).Msg("posture: failed to parse result rows")
continue
}
default:
rows = []json.RawMessage{json.RawMessage(trimmed)}
}
grouped[r.Name] = append(grouped[r.Name], rows...)
}
for _, queryName := range queryOrder {
columns, err := json.Marshal(grouped[queryName])
if err != nil {
log.Warn().Err(err).Str("query", queryName).Msg("posture: failed to marshal grouped result")
continue
}
if err := h.Posture.IngestResult(nodeUUID, environment, queryName, columns); err != nil {
log.Warn().Err(err).Str("query", queryName).Msg("posture: failed to ingest result")
}
}
}
Loading
Loading