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
64 changes: 64 additions & 0 deletions cmd/api/handlers/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,3 +714,67 @@ func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request)
h.AuditLog.NodeAction(ctx[ctxUser], "viewed posture for "+uuidVar, strings.Split(r.RemoteAddr, ":")[0], env.ID)
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, records)
}

// NodePostureScoreHandler — GET /api/v1/nodes/{env}/node/{uuid}/posture/score
//
// Evaluates the node's posture data against SOC2/ISO 27001 controls
// and returns a quantified risk score.
// @Summary Get node posture risk score
// @Description Returns a security and compliance risk score based on posture data.
// @Tags nodes
// @Produce json
// @Param env path string true "Environment name or UUID"
// @Param uuid path string true "Node UUID"
// @Success 200 {object} posture.PostureScore
// @Failure 401 {object} types.ApiErrorResponse "Unauthorized"
// @Failure 403 {object} types.ApiErrorResponse "Forbidden"
// @Failure 404 {object} types.ApiErrorResponse "Not found"
// @Failure 503 {object} types.ApiErrorResponse "Service unavailable"
// @Security ApiKeyAuth
// @Router /api/v1/nodes/{env}/node/{uuid}/posture/score [get]
func (h *HandlersApi) NodePostureScoreHandler(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
}
calculator := posture.NewScoreCalculator()
score := calculator.Score(records)
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, score)
}
3 changes: 3 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,9 @@ func osctrlAPIService() {
muxAPI.Handle(
"GET "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/posture",
handlerAuthCheck(http.HandlerFunc(handlersApi.NodePostureHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
muxAPI.Handle(
"GET "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/posture/score",
handlerAuthCheck(http.HandlerFunc(handlersApi.NodePostureScoreHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
// API: posture profiles (predefined check templates)
muxAPI.Handle(
"GET "+_apiPath("/posture")+"/profiles",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/api/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiFetch } from './client';
import type { NodePosture, PostureProfile } from './types';
import type { NodePosture, PostureProfile, PostureScore } from './types';
import type {
NodesPagedResponse,
OsqueryNode,
Expand Down Expand Up @@ -101,3 +101,9 @@ export function getPostureProfiles(): Promise<PostureProfile[]> {
export function getPostureProfile(id: string): Promise<PostureProfile> {
return apiFetch<PostureProfile>(`/api/v1/posture/profiles/${encodeURIComponent(id)}`);
}

export function getNodePostureScore(env: string, uuid: string): Promise<PostureScore> {
return apiFetch<PostureScore>(
`/api/v1/nodes/${encodeURIComponent(env)}/node/${encodeURIComponent(uuid)}/posture/score`,
);
}
23 changes: 23 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,26 @@ export interface PostureProfile {
platform: string;
queries: Record<string, ProfileQuery>;
}

export interface ControlResult {
category: string;
control_id: string;
framework: string;
title: string;
description: string;
status: string;
severity: string;
score: number;
detail: string;
}

export interface PostureScore {
node_uuid: string;
timestamp: string;
total_score: number;
risk_level: string;
controls: ControlResult[];
pass_count: number;
warn_count: number;
fail_count: number;
}
97 changes: 95 additions & 2 deletions frontend/src/features/nodes/NodeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useState, useRef, useEffect, useMemo } from 'react';
import { usePageTitle } from '$/lib/usePageTitle';
import { useParams, Link, useNavigate } from '@tanstack/react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getNode, listNodeLogs, deleteNode, getNodePosture } from '$/api/nodes';
import type { NodePosture } from '$/api/types';
import { getNode, listNodeLogs, deleteNode, getNodePosture, getNodePostureScore } from '$/api/nodes';
import type { NodePosture, PostureScore } from '$/api/types';
import { getMe } from '$/api/users';
import { listEnvironments } from '$/api/environments';
import {
Expand Down Expand Up @@ -1553,6 +1553,12 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) {
staleTime: 30_000,
retry: 1,
});
const { data: scoreData, isLoading: scoreLoading } = useQuery({
queryKey: ['node-posture-score', env, uuid],
queryFn: () => getNodePostureScore(env, uuid),
staleTime: 30_000,
retry: 1,
});

if (isLoading) {
return (
Expand Down Expand Up @@ -1594,13 +1600,100 @@ function PostureTab({ env, uuid }: { env: string; uuid: string }) {

return (
<div className="overflow-auto p-4 space-y-3">
{scoreData && <PostureScorePanel score={scoreData} />}
{data.map((item) => (
<PostureCard key={item.category} item={item} />
))}
</div>
);
}

// ---------------------------------------------------------------------------
// PostureScorePanel — risk score gauge + control summary
// ---------------------------------------------------------------------------
function PostureScorePanel({ score }: { score: PostureScore }) {
const riskColors: Record<string, string> = {
low: 'var(--success)',
medium: 'var(--warning)',
high: 'var(--danger)',
critical: 'var(--danger)',
};
const riskColor = riskColors[score.risk_level] ?? 'var(--text-3)';

return (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--bg-1)] overflow-hidden">
{/* Score header */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-[color:var(--border)]">
{/* Score gauge */}
<div className="relative flex-shrink-0">
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="28" fill="none" stroke="var(--bg-3)" strokeWidth="6" />
<circle
cx="32" cy="32" r="28" fill="none"
stroke={riskColor} strokeWidth="6"
strokeDasharray={`${(score.total_score / 100) * 176} 176`}
strokeLinecap="round"
transform="rotate(-90 32 32)"
/>
</svg>
<span
className="absolute inset-0 flex items-center justify-center text-lg font-bold font-mono-tabular"
style={{ color: riskColor }}
>
{score.total_score}
</span>
</div>
{/* Summary */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-display font-semibold text-[color:var(--text-1)]">
Risk score
</span>
<span
className="px-1.5 py-0.5 rounded text-[10px] font-mono-tabular font-semibold uppercase tracking-[0.08em] border"
style={{ color: riskColor, borderColor: riskColor, background: `color-mix(in oklab, ${riskColor} 10%, transparent)` }}
>
{score.risk_level}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] font-mono-tabular">
<span className="text-[color:var(--success)]">{score.pass_count} pass</span>
<span className="text-[color:var(--warning)]">{score.warn_count} warn</span>
<span className="text-[color:var(--danger)]">{score.fail_count} fail</span>
</div>
</div>
</div>
{/* Control results */}
<div className="divide-y divide-[color:var(--border)]">
{score.controls.map((ctrl) => {
const statusColor = ctrl.status === 'pass' ? 'var(--success)' : ctrl.status === 'warn' ? 'var(--warning)' : 'var(--danger)';
return (
<div key={ctrl.control_id + ctrl.category} className="px-4 py-2 flex items-start gap-3">
<span
className="flex-shrink-0 mt-0.5 inline-block w-2 h-2 rounded-full"
style={{ background: statusColor }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[color:var(--text-1)]">{ctrl.title}</span>
<span className="text-[9px] font-mono-tabular text-[color:var(--text-3)] px-1 rounded bg-[color:var(--bg-2)]">{ctrl.control_id}</span>
<span className="text-[9px] font-mono-tabular text-[color:var(--text-3)]">{ctrl.framework}</span>
</div>
<p className="text-[10px] text-[color:var(--text-3)] mt-0.5">{ctrl.detail}</p>
</div>
{ctrl.score > 0 && (
<span className="flex-shrink-0 text-[10px] font-mono-tabular font-semibold" style={{ color: statusColor }}>
+{ctrl.score}
</span>
)}
</div>
);
})}
</div>
</div>
);
}

function PostureCard({ item }: { item: NodePosture }) {
const [expanded, setExpanded] = useState(false);
const summary = (() => {
Expand Down
Loading
Loading