Skip to content

Commit 7c33913

Browse files
feat(library): rules per revision endpoint + detail page picker/table
Co-Authored-By: Bryce Anglin <brycemanglin@gmail.com>
1 parent e155b68 commit 7c33913

5 files changed

Lines changed: 888 additions & 89 deletions

File tree

api/internal/server/stigs_handler.go

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,108 @@ func (s APIServer) GetRuleByRuleId(w http.ResponseWriter, r *http.Request, ruleI
175175
writeAuthError(w, http.StatusInternalServerError, "failed to get rule")
176176
return
177177
}
178-
writeJSON(w, http.StatusOK, ruleToAPI(*row))
178+
// The /stigs/rules/{ruleId} lookup card always wants the full set.
179+
writeJSON(w, http.StatusOK, ruleToAPIWith(*row, ruleProjFull))
180+
}
181+
182+
// ruleProjSet is a parsed view of the ?projection query.
183+
type ruleProjSet struct {
184+
Detail bool
185+
Check bool
186+
Fix bool
187+
CCIs bool
188+
Stigs bool
189+
}
190+
191+
// ruleProjFull is the projection requested by the /stigs/rules/{ruleId}
192+
// lookup card (everything except the cross-revision Stigs nav list).
193+
var ruleProjFull = ruleProjSet{Detail: true, Check: true, Fix: true, CCIs: true, Stigs: true}
194+
195+
// parseRuleProjection turns a *RuleProjectionQuery (which is *[]string
196+
// underneath) into a parsed flag set.
197+
func parseRuleProjection(q *api.RuleProjectionQuery) ruleProjSet {
198+
out := ruleProjSet{}
199+
if q == nil {
200+
return out
201+
}
202+
for _, p := range *q {
203+
switch p {
204+
case "detail":
205+
out.Detail = true
206+
case "check":
207+
out.Check = true
208+
case "fix":
209+
out.Fix = true
210+
case "ccis":
211+
out.CCIs = true
212+
case "stigs":
213+
out.Stigs = true
214+
}
215+
}
216+
return out
217+
}
218+
219+
// GetRulesByRevision returns all rules for a given (benchmarkId,
220+
// revisionStr) pair. revisionStr may be "latest" to select the most
221+
// recently imported revision.
222+
func (s APIServer) GetRulesByRevision(w http.ResponseWriter, r *http.Request, benchmarkId string, revisionStr string, params api.GetRulesByRevisionParams) {
223+
if !s.requiredScope(w, r, "stig-manager:stig:read") {
224+
return
225+
}
226+
if s.Stigs == nil {
227+
writeAuthError(w, http.StatusNotFound, "revision not found")
228+
return
229+
}
230+
proj := parseRuleProjection(params.Projection)
231+
rows, _, err := s.Stigs.ListRulesByRevision(r.Context(), benchmarkId, revisionStr, store.RulesByRevisionOptions{
232+
IncludeCCIs: proj.CCIs,
233+
IncludeDetail: proj.Detail,
234+
IncludeCheck: proj.Check,
235+
IncludeFix: proj.Fix,
236+
})
237+
if err != nil {
238+
if errors.Is(err, store.ErrNotFound) {
239+
writeAuthError(w, http.StatusNotFound, "revision not found")
240+
return
241+
}
242+
s.logErr(r, "list rules by revision", err)
243+
writeAuthError(w, http.StatusInternalServerError, "failed to list rules")
244+
return
245+
}
246+
out := make([]api.RuleProjected, 0, len(rows))
247+
for _, row := range rows {
248+
out = append(out, ruleToAPIWith(row, proj))
249+
}
250+
writeJSON(w, http.StatusOK, out)
251+
}
252+
253+
// GetRuleByRevision returns a single rule projection scoped to a
254+
// specific (benchmarkId, revisionStr).
255+
func (s APIServer) GetRuleByRevision(w http.ResponseWriter, r *http.Request, benchmarkId string, revisionStr string, ruleId string, params api.GetRuleByRevisionParams) {
256+
if !s.requiredScope(w, r, "stig-manager:stig:read") {
257+
return
258+
}
259+
if s.Stigs == nil {
260+
writeAuthError(w, http.StatusNotFound, "rule not found")
261+
return
262+
}
263+
proj := parseRuleProjection(params.Projection)
264+
row, err := s.Stigs.GetRuleByRevision(r.Context(), benchmarkId, revisionStr, ruleId, store.RulesByRevisionOptions{
265+
IncludeCCIs: proj.CCIs,
266+
IncludeDetail: proj.Detail,
267+
IncludeCheck: proj.Check,
268+
IncludeFix: proj.Fix,
269+
})
270+
if err != nil {
271+
if errors.Is(err, store.ErrNotFound) {
272+
writeAuthError(w, http.StatusNotFound, "rule not found")
273+
return
274+
}
275+
s.logErr(r, "get rule by revision", err)
276+
writeAuthError(w, http.StatusInternalServerError, "failed to get rule")
277+
return
278+
}
279+
writeJSON(w, http.StatusOK, ruleToAPIWith(*row, proj))
179280
}
180281

181282
// GetCci returns a single CCI projection plus the STIGs that reference
@@ -233,14 +334,15 @@ func storeToAPIStig(row store.STIG, withRevisions bool) api.STIG {
233334
return out
234335
}
235336

236-
// ruleToAPI projects a store.RuleProjection into the OpenAPI
237-
// RuleProjected schema returned by GET /stigs/rules/{ruleId}.
238-
func ruleToAPI(row store.RuleProjection) api.RuleProjected {
337+
// ruleToAPIWith projects a store.RuleProjection into the OpenAPI
338+
// RuleProjected schema, only emitting the heavy fields the caller
339+
// asked for via ?projection.
340+
func ruleToAPIWith(row store.RuleProjection, proj ruleProjSet) api.RuleProjected {
239341
rid := api.RuleId(row.RuleID)
240342
title := api.RuleTitle(row.Title)
241343
version := api.VersionString(row.VersionStr)
242-
groupID := api.GroupId(row.RevisionStr)
243-
groupTitle := api.GroupTitle("")
344+
groupID := api.GroupId(row.GroupID)
345+
groupTitle := api.GroupTitle(row.GroupTitle)
244346

245347
out := api.RuleProjected{
246348
RuleId: &rid,
@@ -251,26 +353,26 @@ func ruleToAPI(row store.RuleProjection) api.RuleProjected {
251353
GroupTitle: &groupTitle,
252354
}
253355

254-
if row.CheckContent != "" || row.CheckSystem != "" {
356+
if proj.Check && (row.CheckContent != "" || row.CheckSystem != "") {
255357
out.Check = &api.Check{
256358
Content: strPtr(row.CheckContent),
257359
System: strPtr(row.CheckSystem),
258360
}
259361
}
260-
if row.FixText != "" || row.FixID != "" {
362+
if proj.Fix && (row.FixText != "" || row.FixID != "") {
261363
out.Fix = &api.Fix{
262364
Text: strPtr(row.FixText),
263365
Fixref: strPtr(row.FixID),
264366
}
265367
}
266-
if len(row.CCIs) > 0 {
368+
if proj.CCIs && len(row.CCIs) > 0 {
267369
basics := make([]api.CciBasic, 0, len(row.CCIs))
268370
for _, cci := range row.CCIs {
269371
basics = append(basics, api.CciBasic{Cci: api.CciString(cci)})
270372
}
271373
out.Ccis = &basics
272374
}
273-
if row.Description != "" {
375+
if proj.Detail && row.Description != "" {
274376
// Embed the raw VulnDiscussion blob under detail.vulnDiscussion;
275377
// fine-grained parsing of the DISA pseudo-XML lives behind the
276378
// review-content milestone.
@@ -289,7 +391,7 @@ func ruleToAPI(row store.RuleProjection) api.RuleProjected {
289391
Weight *string `json:"weight,omitempty"`
290392
}{VulnDiscussion: &desc}
291393
}
292-
if row.BenchmarkID != "" {
394+
if proj.Stigs && row.BenchmarkID != "" {
293395
bid := api.BenchmarkId(row.BenchmarkID)
294396
out.Stigs = &[]api.RevisionBasic{{
295397
BenchmarkId: &bid,

api/internal/server/stigs_integration_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,95 @@ func TestSTIGsHTTPFlow(t *testing.T) {
186186
t.Fatalf("rule severity: %v", ruleBody["severity"])
187187
}
188188

189+
// GET /stigs/{benchmarkId}/revisions/{revisionStr}/rules → 200 +
190+
// array of rules. Default (no projection) returns the basics only.
191+
rec = httptest.NewRecorder()
192+
req = httptest.NewRequest(http.MethodGet, "/api/stigs/TEST_OS_STIG/revisions/V2R3/rules", nil)
193+
req.Header.Set("Authorization", "Bearer "+fx.token(t, "stig-manager:stig:read"))
194+
handler.ServeHTTP(rec, req)
195+
if rec.Code != http.StatusOK {
196+
t.Fatalf("list rules: got %d (body=%s)", rec.Code, rec.Body.String())
197+
}
198+
var rulesArr []map[string]any
199+
if err := json.Unmarshal(rec.Body.Bytes(), &rulesArr); err != nil {
200+
t.Fatalf("rules json: %v", err)
201+
}
202+
if len(rulesArr) == 0 {
203+
t.Fatalf("expected at least one rule, got 0")
204+
}
205+
first := rulesArr[0]
206+
if first["ruleId"] == nil || first["severity"] == nil {
207+
t.Fatalf("rule row missing ruleId/severity: %+v", first)
208+
}
209+
// Default projection must NOT include heavy fields.
210+
if _, has := first["check"]; has {
211+
t.Fatalf("default projection unexpectedly includes check: %+v", first)
212+
}
213+
if _, has := first["detail"]; has {
214+
t.Fatalf("default projection unexpectedly includes detail: %+v", first)
215+
}
216+
217+
// With ?projection=check&projection=detail&projection=ccis the
218+
// heavy fields show up.
219+
rec = httptest.NewRecorder()
220+
req = httptest.NewRequest(http.MethodGet,
221+
"/api/stigs/TEST_OS_STIG/revisions/V2R3/rules?projection=check&projection=detail&projection=ccis",
222+
nil)
223+
req.Header.Set("Authorization", "Bearer "+fx.token(t, "stig-manager:stig:read"))
224+
handler.ServeHTTP(rec, req)
225+
if rec.Code != http.StatusOK {
226+
t.Fatalf("list rules proj: got %d (body=%s)", rec.Code, rec.Body.String())
227+
}
228+
if err := json.Unmarshal(rec.Body.Bytes(), &rulesArr); err != nil {
229+
t.Fatalf("rules proj json: %v", err)
230+
}
231+
gotCheck := false
232+
for _, row := range rulesArr {
233+
if _, has := row["check"]; has {
234+
gotCheck = true
235+
break
236+
}
237+
}
238+
if !gotCheck {
239+
t.Fatalf("expected at least one rule with check: %+v", rulesArr)
240+
}
241+
242+
// "latest" resolves to the most-recent imported revision.
243+
rec = httptest.NewRecorder()
244+
req = httptest.NewRequest(http.MethodGet, "/api/stigs/TEST_OS_STIG/revisions/latest/rules", nil)
245+
req.Header.Set("Authorization", "Bearer "+fx.token(t, "stig-manager:stig:read"))
246+
handler.ServeHTTP(rec, req)
247+
if rec.Code != http.StatusOK {
248+
t.Fatalf("list rules latest: got %d (body=%s)", rec.Code, rec.Body.String())
249+
}
250+
251+
// GET /stigs/{bid}/revisions/{rev}/rules/{ruleId} → 200.
252+
rec = httptest.NewRecorder()
253+
req = httptest.NewRequest(http.MethodGet,
254+
"/api/stigs/TEST_OS_STIG/revisions/V2R3/rules/SV-100001r1_rule?projection=check&projection=fix",
255+
nil)
256+
req.Header.Set("Authorization", "Bearer "+fx.token(t, "stig-manager:stig:read"))
257+
handler.ServeHTTP(rec, req)
258+
if rec.Code != http.StatusOK {
259+
t.Fatalf("get rule by revision: got %d (body=%s)", rec.Code, rec.Body.String())
260+
}
261+
var ruleByRevBody map[string]any
262+
if err := json.Unmarshal(rec.Body.Bytes(), &ruleByRevBody); err != nil {
263+
t.Fatalf("rule by revision json: %v", err)
264+
}
265+
if ruleByRevBody["ruleId"] != "SV-100001r1_rule" {
266+
t.Fatalf("rule by revision ruleId: %v", ruleByRevBody["ruleId"])
267+
}
268+
269+
// Unknown revision → 404.
270+
rec = httptest.NewRecorder()
271+
req = httptest.NewRequest(http.MethodGet, "/api/stigs/TEST_OS_STIG/revisions/V99R99/rules", nil)
272+
req.Header.Set("Authorization", "Bearer "+fx.token(t, "stig-manager:stig:read"))
273+
handler.ServeHTTP(rec, req)
274+
if rec.Code != http.StatusNotFound {
275+
t.Fatalf("unknown revision: got %d", rec.Code)
276+
}
277+
189278
// GET /stigs/ccis/{cci} → 200 + cci with stigs[]. The OpenAPI spec
190279
// accepts six digits with no prefix; the handler normalises.
191280
rec = httptest.NewRecorder()

0 commit comments

Comments
 (0)