fix(security): scope package search, run documents, workloads and vulnerability reads to the token's tags
This commit is contained in:
@@ -131,6 +131,33 @@ var serverScopedRoutes = map[string]bool{
|
||||
"POST /api/monitors": true,
|
||||
"PUT /api/monitors/:id": true,
|
||||
|
||||
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns — each entry
|
||||
// of which carries a ServerID and a Hostname — to what the caller's scope
|
||||
// admits, setting servers_restricted (a boolean, never a count) when any
|
||||
// entry was dropped. These are the parents of the per-server log routes
|
||||
// above: scoping the logs but not the document naming the hosts left the
|
||||
// hostnames readable without the output.
|
||||
"GET /api/runs/:runId": true,
|
||||
"GET /api/workflows/:id/runs": true,
|
||||
|
||||
// searchPackages passes the caller's selector into services.SearchPackages,
|
||||
// which drops hits on servers outside it using one VisibleServerIDs
|
||||
// membership set. The MCP search_fleet tool answers the same question and
|
||||
// was already scoped; this makes the REST twin agree.
|
||||
"GET /api/packages/search": true,
|
||||
|
||||
// listWorkloads passes the caller's selector into services.SearchWorkloads,
|
||||
// which drops hits on servers outside it. A WorkloadHit names a server ID,
|
||||
// so the fleet-wide form enumerated hosts directly.
|
||||
"GET /api/workloads": true,
|
||||
|
||||
// listVulnerabilities passes the selector as FindingFilter.TokenScope,
|
||||
// narrowing server_id in the same query the Tags selector already narrows,
|
||||
// and vulnerabilitySummary passes it to CountOpenFindingsBySeverity so the
|
||||
// summary tiles count only visible servers.
|
||||
"GET /api/vulnerabilities": true,
|
||||
"GET /api/vulnerabilities/summary": true,
|
||||
|
||||
// Creating a server has no server to filter yet.
|
||||
"POST /api/servers": false,
|
||||
// The agent's own enrolment routes authenticate as the agent, not as a
|
||||
|
||||
@@ -131,6 +131,40 @@ type WorkflowResponse struct {
|
||||
TargetsRestricted bool `json:"targets_restricted,omitempty"`
|
||||
}
|
||||
|
||||
// RunResponse is a workflow run with its ServerRuns narrowed to the servers
|
||||
// the acting token's scope admits. Each models.ServerRun carries both a
|
||||
// ServerID and a Hostname, so an unfiltered run document names every host it
|
||||
// touched — the same disclosure WorkflowResponse.TargetServerIDs closes one
|
||||
// level up, and the parent of the per-server log routes that were already
|
||||
// scoped.
|
||||
//
|
||||
// ServersRestricted follows the targets_restricted precedent exactly: a
|
||||
// boolean and no count, because how many entries were dropped is itself
|
||||
// information about a fleet the caller must not be able to size.
|
||||
type RunResponse struct {
|
||||
*models.WorkflowRun
|
||||
ServerRuns []models.ServerRun `json:"server_runs"`
|
||||
ServersRestricted bool `json:"servers_restricted,omitempty"`
|
||||
}
|
||||
|
||||
// scopeRun narrows one run's ServerRuns using the (visible, restricted) pair
|
||||
// services.VisibleServerIDs returns.
|
||||
func scopeRun(r *models.WorkflowRun, visible map[string]bool, restricted bool) RunResponse {
|
||||
if !restricted {
|
||||
return RunResponse{WorkflowRun: r, ServerRuns: r.ServerRuns}
|
||||
}
|
||||
out := make([]models.ServerRun, 0, len(r.ServerRuns))
|
||||
hidden := false
|
||||
for _, sr := range r.ServerRuns {
|
||||
if visible[sr.ServerID] {
|
||||
out = append(out, sr)
|
||||
} else {
|
||||
hidden = true
|
||||
}
|
||||
}
|
||||
return RunResponse{WorkflowRun: r, ServerRuns: out, ServersRestricted: hidden}
|
||||
}
|
||||
|
||||
type UpdateAgentResponse struct {
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version"`
|
||||
|
||||
@@ -49,6 +49,8 @@ func listVulnerabilities(c *gin.Context) {
|
||||
ServerID: c.Query("server"),
|
||||
Tags: tagsFromQuery(c),
|
||||
HasFix: hasFixFromQuery(c),
|
||||
|
||||
TokenScope: auth.ServerScope(c),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -143,7 +145,7 @@ func tagsFromQuery(c *gin.Context) map[string]string {
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/summary [get]
|
||||
func vulnerabilitySummary(c *gin.Context) {
|
||||
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
|
||||
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c), auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -361,7 +363,7 @@ func searchPackages(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
hits, err := services.SearchPackages(auth.InstanceID(c), name)
|
||||
hits, err := services.SearchPackages(auth.InstanceID(c), name, auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -618,7 +618,17 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, runs)
|
||||
|
||||
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]RunResponse, 0, len(runs))
|
||||
for i := range runs {
|
||||
out = append(out, scopeRun(&runs[i], visible, restricted))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// getRun godoc
|
||||
@@ -627,7 +637,7 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} models.WorkflowRun
|
||||
// @Success 200 {object} RunResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
@@ -638,7 +648,12 @@ func getRun(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, scopeRun(r, visible, restricted))
|
||||
}
|
||||
|
||||
// cancelRun godoc
|
||||
|
||||
@@ -245,7 +245,7 @@ func getWorkloadLogs(c *gin.Context) {
|
||||
// @Router /workloads [get]
|
||||
func listWorkloads(c *gin.Context) {
|
||||
hits, err := services.SearchWorkloads(auth.InstanceID(c),
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"))
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"), auth.ServerScope(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package mcp
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -403,6 +404,15 @@ func init() {
|
||||
f := services.FindingFilter{
|
||||
Severity: stringArg(args, "severity"),
|
||||
State: stringArg(args, "status"),
|
||||
|
||||
// The service layer drops findings on servers outside this
|
||||
// token's scope, so the affected-host count below is over
|
||||
// visible servers only. An unfiltered count is the same
|
||||
// aggregate leak ListKeys.AssignedCount was fixed for: it
|
||||
// says something exists on a machine the caller must not
|
||||
// know about. A CVE affecting only out-of-scope hosts
|
||||
// disappears from the list rather than showing a zero.
|
||||
TokenScope: c.TokenScope,
|
||||
}
|
||||
findings, err := services.ListInstanceFindings(c.InstanceID, f)
|
||||
if err != nil {
|
||||
@@ -420,12 +430,29 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Go randomises map iteration order, so truncating a ranged map
|
||||
// to a page made two identical calls return different CVEs — a
|
||||
// model comparing its own two answers would see the fleet change
|
||||
// under it. Sorting by CVE ID (then package, since the key is a
|
||||
// pair) makes the page deterministic.
|
||||
keys := make([]key, 0, len(meta))
|
||||
for k := range meta {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].cve != keys[j].cve {
|
||||
return keys[i].cve < keys[j].cve
|
||||
}
|
||||
return keys[i].pkg < keys[j].pkg
|
||||
})
|
||||
|
||||
limit := pageLimit(args)
|
||||
out := make([]vulnSummary, 0, limit)
|
||||
for k, v := range meta {
|
||||
for _, k := range keys {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
v := meta[k]
|
||||
v.AffectedNum = counts[k]
|
||||
out = append(out, v)
|
||||
}
|
||||
@@ -490,7 +517,7 @@ func init() {
|
||||
"install is returned instead")
|
||||
}
|
||||
|
||||
hits, err := services.SearchPackages(c.InstanceID, name)
|
||||
hits, err := services.SearchPackages(c.InstanceID, name, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not search packages: %w", err)
|
||||
}
|
||||
@@ -501,10 +528,11 @@ func init() {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
// A hit's server must be resolved through the token's own
|
||||
// scope: SearchPackages runs unscoped across the instance,
|
||||
// so a server outside the token's tag restriction is
|
||||
// dropped here rather than named to the caller.
|
||||
// SearchPackages now filters by the token's scope itself,
|
||||
// so this resolve is how the hostname is obtained rather
|
||||
// than the only scope check. It stays scoped anyway: this
|
||||
// loop is what turns a server ID into a name the model
|
||||
// sees, and a second check costs nothing.
|
||||
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -119,6 +119,11 @@ type FindingFilter struct {
|
||||
// patchable"; false is the unfixable set — remove the package, disable the
|
||||
// service, or accept it, but do not wait for an update.
|
||||
HasFix *bool
|
||||
// TokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted. A finding on a server outside it is dropped: a CVE row
|
||||
// names a server ID and a hostname, and a count that includes invisible
|
||||
// hosts is itself a statement about a fleet the caller must not see.
|
||||
TokenScope map[string]string
|
||||
}
|
||||
|
||||
// ListInstanceFindings returns findings across the whole fleet.
|
||||
@@ -166,6 +171,36 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
|
||||
filter["server_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
|
||||
// The token restriction is applied the same way the Tags selector above
|
||||
// is — by narrowing server_id — rather than by a post-pass, so the two
|
||||
// cannot disagree and the query keeps one shape. IntersectSelectors is
|
||||
// not used here because Tags has already been resolved to IDs by this
|
||||
// point; intersecting the ID sets is the same operation one level down.
|
||||
if len(f.TokenScope) > 0 {
|
||||
visible, restricted, err := VisibleServerIDs(instanceID, f.TokenScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if restricted {
|
||||
ids := make([]string, 0, len(visible))
|
||||
for id := range visible {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []models.VulnFinding{}, nil
|
||||
}
|
||||
if existing, ok := filter["server_id"]; ok {
|
||||
filter["$and"] = bson.A{
|
||||
bson.M{"server_id": existing},
|
||||
bson.M{"server_id": bson.M{"$in": ids}},
|
||||
}
|
||||
delete(filter, "server_id")
|
||||
} else {
|
||||
filter["server_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cur, err := db.Col("vuln_findings").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -182,12 +217,31 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
|
||||
// CountOpenFindingsBySeverity powers the summary tiles. Accepted findings are
|
||||
// excluded: they are suppressed from counts until their expiry, which is the
|
||||
// whole point of accepting one.
|
||||
func CountOpenFindingsBySeverity(instanceID string) (map[string]int, error) {
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted: a summary tile counting findings on hosts the caller cannot
|
||||
// see is the same aggregate leak as an unfiltered affected-host count.
|
||||
func CountOpenFindingsBySeverity(instanceID string, tokenScope map[string]string) (map[string]int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
match := bson.M{"instance_id": instanceID, "state": models.FindingOpen}
|
||||
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if restricted {
|
||||
ids := make([]string, 0, len(visible))
|
||||
for id := range visible {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
match["server_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
|
||||
cur, err := db.Col("vuln_findings").Aggregate(ctx, []bson.M{
|
||||
{"$match": bson.M{"instance_id": instanceID, "state": models.FindingOpen}},
|
||||
{"$match": match},
|
||||
{"$group": bson.M{"_id": "$severity", "n": bson.M{"$sum": 1}}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -464,4 +518,4 @@ func sweepFixedFindings(ctx context.Context) {
|
||||
log.Printf("vuln sweeper: removed %d fixed findings for %s", res.DeletedCount, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,16 @@ type PackageHit struct {
|
||||
// The Mongo filter narrows to documents containing the name; the second pass is
|
||||
// needed because a multikey match returns the whole document, not the matching
|
||||
// array element.
|
||||
func SearchPackages(instanceID, name string) ([]PackageHit, error) {
|
||||
//
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted; a hit on a server outside it is dropped before it is returned.
|
||||
// The filtering is done with VisibleServerIDs — one membership set resolved
|
||||
// once — rather than by resolving each hit's server individually the way
|
||||
// search_fleet does, because a package search can return one hit per host in
|
||||
// the fleet and the query shape must not depend on how many matched. The Mongo
|
||||
// query itself is unchanged: server_packages carries no tags to filter on, so
|
||||
// the narrowing is necessarily a second pass either way.
|
||||
func SearchPackages(instanceID, name string, tokenScope map[string]string) ([]PackageHit, error) {
|
||||
ctx := context.Background()
|
||||
cur, err := db.Col("server_packages").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
@@ -109,8 +118,16 @@ func SearchPackages(instanceID, name string) ([]PackageHit, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hits := []PackageHit{}
|
||||
for _, d := range docs {
|
||||
if restricted && !visible[d.ServerID] {
|
||||
continue
|
||||
}
|
||||
for _, p := range d.Packages {
|
||||
if p.Name == name {
|
||||
hits = append(hits, PackageHit{ServerID: d.ServerID, Name: p.Name, Version: p.Version})
|
||||
|
||||
@@ -93,7 +93,13 @@ type WorkloadHit struct {
|
||||
|
||||
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
|
||||
// is stored rather than fetched on demand and discarded.
|
||||
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
|
||||
//
|
||||
// tokenScope is the acting credential's tag restriction, nil meaning
|
||||
// unrestricted. A WorkloadHit names a server ID, so an unfiltered fleet-wide
|
||||
// search enumerates hosts a restricted token must not see. server_workloads
|
||||
// carries no tags of its own, so the narrowing is a membership test against
|
||||
// VisibleServerIDs resolved once — the same shape SearchPackages uses.
|
||||
func SearchWorkloads(instanceID, image, stack, state string, tokenScope map[string]string) ([]WorkloadHit, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
@@ -112,8 +118,16 @@ func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hits := []WorkloadHit{}
|
||||
for _, d := range docs {
|
||||
if restricted && !visible[d.ServerID] {
|
||||
continue
|
||||
}
|
||||
for _, w := range d.Workloads {
|
||||
if image != "" && w.Image != image {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user