diff --git a/go/node_registry.go b/go/node_registry.go new file mode 100644 index 0000000..33753e1 --- /dev/null +++ b/go/node_registry.go @@ -0,0 +1,46 @@ +package cstx + +import ( + "encoding/json" + "fmt" + "sync" +) + +// SCOParser decodes one native CSTX node type. Parsers are registered by the +// package that owns the type, keeping the graph engine extensible. +type SCOParser func([]byte) (SCONode, error) + +var ( + parserMu sync.RWMutex + parsers = map[string]SCOParser{} +) + +func RegisterSCOParser(nodeType string, parser SCOParser) error { + if nodeType == "" || parser == nil { + return fmt.Errorf("cstx: node type and parser are required") + } + parserMu.Lock() + defer parserMu.Unlock() + if _, exists := parsers[nodeType]; exists { + return fmt.Errorf("cstx: parser already registered for %q", nodeType) + } + parsers[nodeType] = parser + return nil +} + +func parseRegisteredSCONode(data []byte) (SCONode, bool, error) { + var header struct { + Type string `json:"cstx_type"` + } + if err := json.Unmarshal(data, &header); err != nil { + return nil, false, err + } + parserMu.RLock() + parser := parsers[header.Type] + parserMu.RUnlock() + if parser == nil { + return nil, false, nil + } + node, err := parser(data) + return node, true, err +} diff --git a/go/node_registry_test.go b/go/node_registry_test.go new file mode 100644 index 0000000..cd87db0 --- /dev/null +++ b/go/node_registry_test.go @@ -0,0 +1,39 @@ +package cstx + +import ( + "encoding/json" + "testing" +) + +type registryTestNode struct { + nodeHeader + Value string `json:"value"` +} + +func TestRegisteredParserTakesPrecedence(t *testing.T) { + typ := "test_registry_node" + if err := RegisterSCOParser(typ, func(data []byte) (SCONode, error) { + var node registryTestNode + return &node, json.Unmarshal(data, &node) + }); err != nil { + t.Fatal(err) + } + node, err := ParseSCONode([]byte(`{"cstx_type":"test_registry_node","cstx_id":"x","value":"ok"}`)) + if err != nil { + t.Fatal(err) + } + if node.CstxType() != typ || node.CstxID() != "x" { + t.Fatalf("unexpected node: %#v", node) + } +} + +func TestRegisterSCOParserRejectsDuplicate(t *testing.T) { + typ := "test_registry_duplicate" + parser := func([]byte) (SCONode, error) { return ®istryTestNode{}, nil } + if err := RegisterSCOParser(typ, parser); err != nil { + t.Fatal(err) + } + if err := RegisterSCOParser(typ, parser); err == nil { + t.Fatal("expected duplicate registration to fail") + } +} diff --git a/go/sco_easm.go b/go/sco_easm.go index a505d05..907e894 100644 --- a/go/sco_easm.go +++ b/go/sco_easm.go @@ -137,6 +137,16 @@ type SarifVulnNode struct { Evidence string `json:"evidence,omitempty"` } +// ReportNode is a domain-neutral report SCO used by consumers that publish +// evidence-backed summaries alongside graph findings. +type ReportNode struct { + nodeHeader + Value string `json:"value,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + Markdown string `json:"markdown,omitempty"` +} + type CertificateNode struct { nodeHeader Fingerprint string `json:"fingerprint"` @@ -248,6 +258,9 @@ func (h nodeHeader) CstxID() string { return h.ID } // ParseSCONode unmarshals a JSON node into the correct typed struct. func ParseSCONode(data []byte) (SCONode, error) { + if node, registered, err := parseRegisteredSCONode(data); registered || err != nil { + return node, err + } var h nodeHeader if err := json.Unmarshal(data, &h); err != nil { return nil, err @@ -293,6 +306,10 @@ func ParseSCONode(data []byte) (SCONode, error) { var v SarifVulnNode err := json.Unmarshal(data, &v) return &v, err + case "report": + var v ReportNode + err := json.Unmarshal(data, &v) + return &v, err case "certificate": var v CertificateNode err := json.Unmarshal(data, &v)