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
6 changes: 4 additions & 2 deletions client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,10 @@ func (c *Client) ListAcks(ctx context.Context) ([]AckRecord, error) {
return out, nil
}

// AckFinding records a finding acknowledgement. It requires a bearer token
// unless the server runs with authentication disabled.
// AckFinding records a finding acknowledgement. The fingerprint must belong to
// a finding in the latest report, or the server answers 404, and the caller's
// key must reach that finding's cluster. It requires a bearer token unless the
// server runs with authentication disabled.
func (c *Client) AckFinding(ctx context.Context, fingerprint string, req AckRequest) (*AckRecord, error) {
var out AckRecord
path := "/api/findings/" + url.PathEscape(fingerprint) + "/ack"
Expand Down
34 changes: 29 additions & 5 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func newContractServer(t *testing.T, insecure bool, token string) (*client.Clien
func seedScan(t *testing.T, st store.Store) string {
t.Helper()
results := map[string]map[string]scanner.Result{
"prod-a": {"version": {Scanner: "version", Data: map[string]any{"gitVersion": "v1.29.0"}}},
"prod-b": {"version": {Scanner: "version", Data: map[string]any{"gitVersion": "v1.28.3"}}},
"prod-a": {"version": {Scanner: "version", Data: map[string]any{"git_version": "v1.29.0"}}},
"prod-b": {"version": {Scanner: "version", Data: map[string]any{"git_version": "v1.28.3"}}},
}
id, err := st.SaveScan(context.Background(), []string{"prod-a", "prod-b"}, results)
if err != nil {
Expand Down Expand Up @@ -203,13 +203,26 @@ func TestContract(t *testing.T) {
})

t.Run("findings ack", func(t *testing.T) {
const fp = "fp-contract-1"
report, err := c.GetScanReport(ctx, scanID)
if err != nil {
t.Fatalf("get scan report: %v", err)
}
if len(report.Findings) == 0 {
t.Fatal("seeded report has no findings to ack")
}
f := report.Findings[0]
fp := f.Fingerprint
ack, err := c.AckFinding(ctx, fp, client.AckRequest{AckBy: "tester", Reason: "known noise"})
if err != nil {
t.Fatalf("ack finding: %v", err)
}
if ack.Fingerprint != fp {
t.Errorf("ack fingerprint = %q, want %q", ack.Fingerprint, fp)
want := client.AckRecord{
Fingerprint: fp, Cluster: f.Cluster, Scanner: f.Scanner, Title: f.Title,
AckBy: "tester", Reason: "known noise",
}
ignore := cmpopts.IgnoreFields(client.AckRecord{}, "CreatedAt")
if diff := cmp.Diff(want, *ack, ignore); diff != "" {
t.Errorf("ack mismatch (-want +got):\n%s", diff)
}
acks, err := c.ListAcks(ctx)
if err != nil {
Expand Down Expand Up @@ -273,6 +286,17 @@ func TestContract(t *testing.T) {
}
})

t.Run("ack finding unknown", func(t *testing.T) {
_, err := c.AckFinding(ctx, "fp-unknown", client.AckRequest{AckBy: "tester"})
var apiErr *client.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("ack finding: want *APIError, got %v", err)
}
if apiErr.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want %d", apiErr.StatusCode, http.StatusNotFound)
}
})

t.Run("ack alert unknown", func(t *testing.T) {
_, err := c.AckAlert(ctx, "fp-unknown", client.AckAlertRequest{AckBy: "tester"})
var apiErr *client.APIError
Expand Down
9 changes: 2 additions & 7 deletions client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,9 @@ type IntegrationStatus struct {
Hint string `json:"hint,omitempty"`
}

// AckRequest is the body for acknowledging a finding.
// AckRequest is the body for acknowledging a finding. The server takes the
// cluster, scanner, and title from the finding it holds.
type AckRequest struct {
// Cluster narrows the ack to one cluster.
Cluster string `json:"cluster,omitempty"`
// Scanner narrows the ack to one scanner.
Scanner string `json:"scanner,omitempty"`
// Title narrows the ack to one finding title.
Title string `json:"title,omitempty"`
// AckBy identifies who acknowledged the finding.
AckBy string `json:"ack_by,omitempty"`
// Reason records why the finding was acknowledged.
Expand Down
40 changes: 32 additions & 8 deletions docs/operator/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Fleetsweeper has two authentication paths, designed to coexist:
| Role | Permissions |
| --- | --- |
| `admin` | Everything, including key management and audit log. |
| `operator` | Trigger scans, acknowledge findings, manage groups and locations within scope. Cannot manage keys. |
| `operator` | Trigger scans, acknowledge findings, and manage tags and locations within scope. Cannot manage groups or keys. |
| `viewer` | Read-only. Cannot reach `/admin/*`. |

Read-only endpoints (GET) are open to viewers and above (or to anyone when
Expand All @@ -33,14 +33,38 @@ Each key carries `cluster_scope`, a JSON array. The semantics are:

Cluster scope is enforced on:

- `POST /api/scans` (contexts list is filtered; a request becomes 403 only
when no contexts remain).
- `POST /api/scans`. The contexts list is filtered to the key's scope. A
request becomes 403 only when no contexts remain.
- `PUT /api/locations/{cluster}` and `DELETE /api/locations/{cluster}`.
- `POST /api/findings/{fingerprint}/ack` (when the request body carries a
`cluster` field).

Group management (`POST /api/groups`, `DELETE /api/groups/{name}`) is
admin-only because groups cross cluster boundaries.
- `PUT /api/clusters/{name}/tags/{key}` and
`DELETE /api/clusters/{name}/tags/{key}`.
- `POST /api/whatif`, against the cluster the request names.
- `POST /api/findings/{fingerprint}/ack` and
`DELETE /api/findings/{fingerprint}/ack`.
- `POST /api/alerts/{fingerprint}/ack`, against the cluster stored on the
alert. An alert with no cluster needs a key with `*` scope.

Acks resolve the cluster on the server. The ack body carries only `ack_by`,
`reason`, and `snooze_until`, and a body that names `cluster`, `scanner`, or
`title` is rejected with 400. To create an ack, the server finds the finding
with that fingerprint in the latest report and checks the key's scope against
the finding's cluster. A fingerprint that no current finding carries returns
404. To remove an ack, the server checks the cluster stored on the ack. A
fleet-scoped finding or ack (cluster `fleet`) needs a key with `*` scope, so a
key scoped to `prod-east` cannot silence a fleet-wide finding.

Group management (`POST /api/groups`, `DELETE /api/groups/{name}`) needs
the `admin` role. An operator is refused even with `*` scope. A group widens
every key scoped to it, so defining one is an admin act.

Cluster scope limits writes. Most read endpoints are not scoped, so a key
scoped to `prod-west` can still read the findings and full scanner results of
`prod-east`. Treat any caller that can read the API as able to see the whole
fleet.

`POST /api/webhooks/scan-trigger` takes no API key. Its HMAC secret is the
authority, and a request signed with that secret may scan any context. Guard
the secret as you would an admin token.

## Creating a key

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Paths below are under `/api` unless they start with a root path.
| GET | `/integrations` | Which optional integrations are on. |
| GET | `/scanners` | Every scanner with its label, description, and category. |
| GET | `/acks` | Active finding acknowledgements. |
| POST, DELETE | `/findings/{fingerprint}/ack` | Acknowledge a finding or remove the acknowledgement. |
| POST, DELETE | `/findings/{fingerprint}/ack` | Acknowledge a finding in the latest report or remove the acknowledgement. The server takes the cluster, scanner, and title from the finding. |
| GET | `/findings/timeline`, `/findings/persistence` | Findings over recent scans and how often each recurs. |
| GET | `/alerts` | Inbound AlertManager and Falco alerts, each with its ack state. |
| POST | `/alerts/{fingerprint}/ack` | Acknowledge an inbound alert. |
Expand Down
Loading
Loading