diff --git a/server/internal/api/serverscope.go b/server/internal/api/serverscope.go index 5c181b8..f27a90a 100644 --- a/server/internal/api/serverscope.go +++ b/server/internal/api/serverscope.go @@ -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 diff --git a/server/internal/api/types.go b/server/internal/api/types.go index a8c65ed..2cfd1df 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -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"` diff --git a/server/internal/api/vulnerabilities.go b/server/internal/api/vulnerabilities.go index ffa5e15..64a2cdf 100644 --- a/server/internal/api/vulnerabilities.go +++ b/server/internal/api/vulnerabilities.go @@ -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 diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 7289897..5303a31 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -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 diff --git a/server/internal/api/workloads.go b/server/internal/api/workloads.go index 0655522..61248e5 100644 --- a/server/internal/api/workloads.go +++ b/server/internal/api/workloads.go @@ -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 diff --git a/server/internal/mcp/tools_work.go b/server/internal/mcp/tools_work.go index 64002b4..36099a1 100644 --- a/server/internal/mcp/tools_work.go +++ b/server/internal/mcp/tools_work.go @@ -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 diff --git a/server/internal/services/findings.go b/server/internal/services/findings.go index be43e96..ddb63d5 100644 --- a/server/internal/services/findings.go +++ b/server/internal/services/findings.go @@ -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) } } -} \ No newline at end of file +} diff --git a/server/internal/services/packages.go b/server/internal/services/packages.go index c70e88c..a366f90 100644 --- a/server/internal/services/packages.go +++ b/server/internal/services/packages.go @@ -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}) diff --git a/server/internal/services/workloads.go b/server/internal/services/workloads.go index f99dbf4..1a08f2e 100644 --- a/server/internal/services/workloads.go +++ b/server/internal/services/workloads.go @@ -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