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
143 changes: 142 additions & 1 deletion cmd/dbtool/reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,148 @@ all:
auditSections,
)

return os.WriteFile(path, []byte(yaml), 0o600)
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
return err
}

// Fixture manifest for the CATS runner's fixture-integrity gates (#608).
// Written alongside the refData it describes so the two can never disagree
// about which ids a campaign depends on.
return writeFixtureManifest(
filepath.Join(filepath.Dir(path), "cats-fixtures.json"),
fixtureIDs{
threatModelID: tmID, threatID: threatID, diagramID: diagramID,
documentID: documentID, assetID: assetID, noteID: noteID,
feedbackID: feedbackID, repositoryID: repoID,
teamID: teamID, projectID: projectID,
teamNoteID: teamNoteID, projectNoteID: projectNoteID,
groupID: adminGroupID, userID: targetUUID,
surveyID: surveyID, surveyResponseID: responseID,
triageNoteID: triageNoteID, webhookID: webhookID, deliveryID: deliveryID,
},
)
}

// SEM@0: resolved seeded ids the fixture manifest is built from (pure)
type fixtureIDs struct {
threatModelID, threatID, diagramID, documentID, assetID, noteID string
feedbackID, repositoryID string
teamID, projectID, teamNoteID, projectNoteID string
groupID, userID string
surveyID, surveyResponseID, triageNoteID string
webhookID, deliveryID string
}

// SEM@0: one seeded fixture the campaign depends on, plus how to check it is still alive (pure)
type referenceFixture struct {
// Key is the refData key CATS substitutes this id into.
Key string `json:"key"`
ID string `json:"id"`
// VerifyURL is a server-relative GET that returns 200 iff the fixture
// still exists. Parent ids are already resolved.
VerifyURL string `json:"verify_url"`
// AnchorPath is the CATS path template whose DELETE would consume this
// fixture. Named in the gate's failure message so the fix (add a decoy
// override for that anchor) is obvious from the error alone.
AnchorPath string `json:"anchor_path"`
// Decoyed records whether AnchorPath already has a decoy override, so a
// fixture that dies anyway is reported as "decoy not working" rather than
// "decoy missing".
Decoyed bool `json:"decoyed"`
}

// SEM@0: fixture manifest file structure listing verifiable and unverifiable seeded fixtures (pure)
type fixtureManifest struct {
Version string `json:"version"`
CreatedAt string `json:"created_at"`
Fixtures []referenceFixture `json:"fixtures"`
// Unverifiable names fixtures the gates cannot check, with the reason.
// Recorded explicitly rather than omitted: a gate that silently skips
// part of the fixture set reads as "all fixtures survived" when it is
// really "the ones I could see survived".
Unverifiable map[string]string `json:"unverifiable"`
}

// SEM@0: serialize the seeded-fixture integrity manifest consumed by the CATS runner gates (mutates shared state)
func writeFixtureManifest(path string, ids fixtureIDs) error {
tm := ids.threatModelID
// Anchors that writeYAMLReference emits a decoy override for. Keep in step
// with the `decoys` slice above.
decoyed := map[string]bool{
"/teams/{team_id}": true,
"/projects/{project_id}": true,
"/threat_models/{threat_model_id}": true,
"/admin/groups/{group_id}": true,
"/admin/users/{user_id}": true,
"/intake/survey_responses/{survey_response_id}": true,
}

candidates := []referenceFixture{
{"threat_model_id", tm, "/threat_models/" + tm, "/threat_models/{threat_model_id}", false},
{"threat_id", ids.threatID, "/threat_models/" + tm + "/threats/" + ids.threatID,
"/threat_models/{threat_model_id}/threats/{threat_id}", false},
{"diagram_id", ids.diagramID, "/threat_models/" + tm + "/diagrams/" + ids.diagramID,
"/threat_models/{threat_model_id}/diagrams/{diagram_id}", false},
{"document_id", ids.documentID, "/threat_models/" + tm + "/documents/" + ids.documentID,
"/threat_models/{threat_model_id}/documents/{document_id}", false},
{"asset_id", ids.assetID, "/threat_models/" + tm + "/assets/" + ids.assetID,
"/threat_models/{threat_model_id}/assets/{asset_id}", false},
{"note_id", ids.noteID, "/threat_models/" + tm + "/notes/" + ids.noteID,
"/threat_models/{threat_model_id}/notes/{note_id}", false},
{"feedback_id", ids.feedbackID, "/threat_models/" + tm + "/feedback/" + ids.feedbackID,
"/threat_models/{threat_model_id}/feedback/{feedback_id}", false},
{"repository_id", ids.repositoryID, "/threat_models/" + tm + "/repositories/" + ids.repositoryID,
"/threat_models/{threat_model_id}/repositories/{repository_id}", false},
{"team_id", ids.teamID, "/teams/" + ids.teamID, "/teams/{team_id}", false},
{"project_id", ids.projectID, "/projects/" + ids.projectID, "/projects/{project_id}", false},
{"team_note_id", ids.teamNoteID, "/teams/" + ids.teamID + "/notes/" + ids.teamNoteID,
"/teams/{team_id}/notes/{team_note_id}", false},
{"project_note_id", ids.projectNoteID, "/projects/" + ids.projectID + "/notes/" + ids.projectNoteID,
"/projects/{project_id}/notes/{project_note_id}", false},
{"group_id", ids.groupID, "/admin/groups/" + ids.groupID, "/admin/groups/{group_id}", false},
{"user_id", ids.userID, "/admin/users/" + ids.userID, "/admin/users/{user_id}", false},
{"survey_id", ids.surveyID, "/admin/surveys/" + ids.surveyID, "/admin/surveys/{survey_id}", false},
{"survey_response_id", ids.surveyResponseID, "/intake/survey_responses/" + ids.surveyResponseID,
"/intake/survey_responses/{survey_response_id}", false},
{"triage_note_id", ids.triageNoteID,
"/intake/survey_responses/" + ids.surveyResponseID + "/triage_notes/" + ids.triageNoteID,
"/intake/survey_responses/{survey_response_id}/triage_notes/{triage_note_id}", false},
{"webhook_id", ids.webhookID, "/admin/webhooks/subscriptions/" + ids.webhookID,
"/admin/webhooks/subscriptions/{webhook_id}", false},
{"delivery_id", ids.deliveryID, "/admin/webhooks/deliveries/" + ids.deliveryID,
"/admin/webhooks/deliveries/{delivery_id}", false},
}

// #nosec G101 -- these are refData key names and prose reasons, not credentials
unverifiable := map[string]string{
"credential_id": "spec has no GET for /me/client_credentials/{credential_id} (delete-only)",
"addon_id": "no spec path uses {addon_id}",
"key": "metadata key is not an addressable resource",
"entry_id": "audit entries are harvested after seeding, not seeded, and are not deletable fixtures",
}

fixtures := make([]referenceFixture, 0, len(candidates))
for _, f := range candidates {
// A fixture that was never seeded has no integrity to check, and
// including it would make the gate fail every run on a false death.
if f.ID == "" || f.ID == nilUUID {
unverifiable[f.Key] = "not present in this seed run"
continue
}
f.Decoyed = decoyed[f.AnchorPath]
fixtures = append(fixtures, f)
}

data, err := json.MarshalIndent(fixtureManifest{
Version: "1.0.0",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Fixtures: fixtures,
Unverifiable: unverifiable,
}, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal fixture manifest: %w", err)
}
return os.WriteFile(path, append(data, '\n'), 0o600)
}

// Seed refs for the destructive-fuzz decoys. Anchor paths (/teams/{team_id},
Expand Down
125 changes: 125 additions & 0 deletions cmd/dbtool/reference_fixtures_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

// readManifest writes a manifest for `ids` into a temp dir and parses it back.
func readManifest(t *testing.T, ids fixtureIDs) fixtureManifest {
t.Helper()
path := filepath.Join(t.TempDir(), "cats-fixtures.json")
if err := writeFixtureManifest(path, ids); err != nil {
t.Fatalf("writeFixtureManifest: %v", err)
}
data, err := os.ReadFile(path) // #nosec G304 -- test-controlled temp path
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var m fixtureManifest
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
return m
}

func fullIDs() fixtureIDs {
return fixtureIDs{
threatModelID: "tm-1", threatID: "th-1", diagramID: "dg-1",
documentID: "doc-1", assetID: "as-1", noteID: "nt-1",
feedbackID: "fb-1", repositoryID: "repo-1",
teamID: "team-1", projectID: "proj-1",
teamNoteID: "tn-1", projectNoteID: "pn-1",
groupID: "grp-1", userID: "usr-1",
surveyID: "sv-1", surveyResponseID: "sr-1", triageNoteID: "1",
webhookID: "wh-1", deliveryID: "dl-1",
}
}

// A fully-seeded run must produce a verify URL for every fixture, with parent
// ids resolved inline — the runner GETs these verbatim, so an unsubstituted
// "{threat_model_id}" would make the gate report a false fixture death.
func TestFixtureManifestResolvesParentIDs(t *testing.T) {
m := readManifest(t, fullIDs())

want := map[string]string{
"threat_model_id": "/threat_models/tm-1",
"threat_id": "/threat_models/tm-1/threats/th-1",
"diagram_id": "/threat_models/tm-1/diagrams/dg-1",
"asset_id": "/threat_models/tm-1/assets/as-1",
"feedback_id": "/threat_models/tm-1/feedback/fb-1",
"repository_id": "/threat_models/tm-1/repositories/repo-1",
"team_id": "/teams/team-1",
"project_id": "/projects/proj-1",
"team_note_id": "/teams/team-1/notes/tn-1",
"project_note_id": "/projects/proj-1/notes/pn-1",
"group_id": "/admin/groups/grp-1",
"user_id": "/admin/users/usr-1",
"survey_response_id": "/intake/survey_responses/sr-1",
"triage_note_id": "/intake/survey_responses/sr-1/triage_notes/1",
"webhook_id": "/admin/webhooks/subscriptions/wh-1",
"delivery_id": "/admin/webhooks/deliveries/dl-1",
}

got := make(map[string]string, len(m.Fixtures))
for _, f := range m.Fixtures {
got[f.Key] = f.VerifyURL
}
for key, url := range want {
if got[key] != url {
t.Errorf("fixture %q verify_url = %q, want %q", key, got[key], url)
}
}
}

// Every anchor that writeYAMLReference emits a decoy override for must be
// marked decoyed, so a fixture that dies anyway is reported as "the decoy is
// not working" rather than sending the reader off to add one that exists.
func TestFixtureManifestMarksDecoyedAnchors(t *testing.T) {
m := readManifest(t, fullIDs())

decoyed := map[string]bool{
"threat_model_id": true, "team_id": true, "project_id": true,
"group_id": true, "user_id": true, "survey_response_id": true,
// Nested fixtures have no anchor decoy.
"threat_id": false, "note_id": false, "team_note_id": false,
"survey_id": false, "webhook_id": false,
}
for _, f := range m.Fixtures {
want, tracked := decoyed[f.Key]
if tracked && f.Decoyed != want {
t.Errorf("fixture %q decoyed = %v, want %v (anchor %s)",
f.Key, f.Decoyed, want, f.AnchorPath)
}
}
}

// An id that was never seeded must be reported as unverifiable rather than
// emitted as a fixture: the gate would GET a nil-UUID URL, get a 404, and fail
// every run on a fixture that never existed.
func TestFixtureManifestExcludesUnseededIDs(t *testing.T) {
ids := fullIDs()
ids.webhookID = nilUUID
ids.deliveryID = ""

m := readManifest(t, ids)
for _, f := range m.Fixtures {
if f.Key == "webhook_id" || f.Key == "delivery_id" {
t.Errorf("unseeded fixture %q must not be verifiable (id %q)", f.Key, f.ID)
}
}
for _, key := range []string{"webhook_id", "delivery_id"} {
if _, ok := m.Unverifiable[key]; !ok {
t.Errorf("unseeded fixture %q must appear in unverifiable", key)
}
}
// Fixtures with no individual GET in the spec are permanently unverifiable
// and must be named, not silently dropped.
for _, key := range []string{"credential_id", "addon_id"} {
if _, ok := m.Unverifiable[key]; !ok {
t.Errorf("%q must be recorded as unverifiable", key)
}
}
}
Loading