chore: import upstream snapshot with attribution
Check engine pin consistency / Dockerfile / CI pin consistency (push) Successful in 8s
Sirius CI/CD Pipeline / Detect Changes (push) Successful in 23s
Validate Docker Configuration / Validate Docker Compose Configuration (push) Successful in 47s
Sirius CI/CD Pipeline / Build API (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Build UI (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Engine Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Merge API Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Merge UI Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Build Engine (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Build Infra (${{ matrix.service }}, ${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-postgres) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-rabbitmq) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-valkey) (push) Has been cancelled
Sirius CI/CD Pipeline / Integration Test (push) Has been cancelled
Sirius CI/CD Pipeline / Public Stack Contract (push) Has been cancelled
Sirius CI/CD Pipeline / Dispatch Demo Deployment (sirius-demo branch) (push) Has been cancelled
Sirius CI/CD Pipeline / Dispatch Demo Canary (main branch) (push) Has been cancelled
Sirius CI/CD Pipeline / Guard Registry Namespace (push) Has been cancelled
Check engine pin consistency / Dockerfile / CI pin consistency (push) Successful in 8s
Sirius CI/CD Pipeline / Detect Changes (push) Successful in 23s
Validate Docker Configuration / Validate Docker Compose Configuration (push) Successful in 47s
Sirius CI/CD Pipeline / Build API (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Build UI (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Engine Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Merge API Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Merge UI Manifest (push) Has been cancelled
Sirius CI/CD Pipeline / Build Engine (${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Build Infra (${{ matrix.service }}, ${{ matrix.platform }}) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-postgres) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-rabbitmq) (push) Has been cancelled
Sirius CI/CD Pipeline / Merge Infra Manifest (sirius-valkey) (push) Has been cancelled
Sirius CI/CD Pipeline / Integration Test (push) Has been cancelled
Sirius CI/CD Pipeline / Public Stack Contract (push) Has been cancelled
Sirius CI/CD Pipeline / Dispatch Demo Deployment (sirius-demo branch) (push) Has been cancelled
Sirius CI/CD Pipeline / Dispatch Demo Canary (main branch) (push) Has been cancelled
Sirius CI/CD Pipeline / Guard Registry Namespace (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Package scanner_storage_contract verifies that every producer and
|
||||
// consumer of the scanner Valkey schema agrees on the wire format.
|
||||
//
|
||||
// The contract lives in github.com/SiriusScan/go-api/sirius/store/templates.
|
||||
// Each writer (sirius-api, app-scanner, app-agent) and reader (sirius-api,
|
||||
// app-agent, sirius-ui-via-tRPC) routes through that package after PR 7,
|
||||
// so exercising the helpers end-to-end against a single in-memory KV is
|
||||
// sufficient to detect any drift in:
|
||||
//
|
||||
// - canonical key shapes ("template:custom:<id>", "nse:script:<id>", ...)
|
||||
// - JSON envelope and meta projections
|
||||
// - canonicalization rules for script IDs
|
||||
package scanner_storage_contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SiriusScan/go-api/sirius/store"
|
||||
"github.com/SiriusScan/go-api/sirius/store/templates"
|
||||
)
|
||||
|
||||
// fakeKV satisfies the narrow kvReader/kvWriter surface the templates
|
||||
// package uses; it stands in for valkey for cross-pair contract checks.
|
||||
type fakeKV struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
}
|
||||
|
||||
func newFakeKV() *fakeKV { return &fakeKV{m: map[string]string{}} }
|
||||
|
||||
func (f *fakeKV) GetValue(_ context.Context, key string) (store.ValkeyResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.m[key]
|
||||
if !ok {
|
||||
return store.ValkeyResponse{}, errors.New("not found")
|
||||
}
|
||||
return store.ValkeyResponse{Message: store.ValkeyValue{Value: v}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeKV) SetValue(_ context.Context, key, value string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.m[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeKV) DeleteValue(_ context.Context, key string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.m, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sampleTemplate() *templates.TemplateRecord {
|
||||
body := []byte("id: contract-template\n")
|
||||
now := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
|
||||
return &templates.TemplateRecord{
|
||||
ID: "contract-template",
|
||||
Version: "v1",
|
||||
Checksum: templates.SHA256Hex(body),
|
||||
Size: int64(len(body)),
|
||||
Severity: "high",
|
||||
Platforms: []string{"linux"},
|
||||
DetectionType: "agent",
|
||||
Author: "contract-suite",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
VulnerabilityIDs: []string{"CVE-2026-0001"},
|
||||
IsCustom: true,
|
||||
Content: body,
|
||||
Metadata: map[string]string{"source": "contract-test"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_TemplateWriterReaderPairs walks every producer/consumer
|
||||
// pair for agent templates. Each pair must end up reading the exact
|
||||
// envelope the writer handed to the shared helper.
|
||||
func TestContract_TemplateWriterReaderPairs(t *testing.T) {
|
||||
pairs := []struct {
|
||||
writer string
|
||||
reader string
|
||||
}{
|
||||
{"sirius-api", "app-agent"},
|
||||
{"app-agent", "sirius-api"},
|
||||
{"sirius-api", "sirius-api"},
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
t.Run(p.writer+"->"+p.reader, func(t *testing.T) {
|
||||
kv := newFakeKV()
|
||||
ctx := context.Background()
|
||||
rec := sampleTemplate()
|
||||
|
||||
if err := templates.WriteTemplate(ctx, kv, rec); err != nil {
|
||||
t.Fatalf("[%s] write: %v", p.writer, err)
|
||||
}
|
||||
|
||||
got, err := templates.ReadTemplate(ctx, kv, rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] read: %v", p.reader, err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("[%s] read returned nil for id %q", p.reader, rec.ID)
|
||||
}
|
||||
if got.ID != rec.ID || got.Severity != rec.Severity || !bytes.Equal(got.Content, rec.Content) {
|
||||
t.Fatalf("[%s -> %s] envelope mismatch:\n got: %+v\nwant: %+v", p.writer, p.reader, got, rec)
|
||||
}
|
||||
if got.Checksum != rec.Checksum {
|
||||
t.Fatalf("[%s -> %s] checksum mismatch: got %q want %q", p.writer, p.reader, got.Checksum, rec.Checksum)
|
||||
}
|
||||
|
||||
meta, err := templates.ReadTemplateMeta(ctx, kv, rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] read meta: %v", p.reader, err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("[%s] missing meta record", p.reader)
|
||||
}
|
||||
if len(meta.Content) != 0 {
|
||||
t.Fatalf("[%s] meta record must omit Content (got %d bytes)", p.reader, len(meta.Content))
|
||||
}
|
||||
if meta.Checksum != rec.Checksum {
|
||||
t.Fatalf("[%s] meta checksum mismatch: got %q want %q", p.reader, meta.Checksum, rec.Checksum)
|
||||
}
|
||||
if meta.IsCustom != rec.IsCustom {
|
||||
t.Fatalf("[%s] meta IsCustom drift: got %v want %v", p.reader, meta.IsCustom, rec.IsCustom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_TemplateKeyShape locks the on-disk key namespaces.
|
||||
func TestContract_TemplateKeyShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
isCustom bool
|
||||
want string
|
||||
}{
|
||||
{"foo", false, "template:standard:foo"},
|
||||
{"foo", true, "template:custom:foo"},
|
||||
{"my-yaml-rule", true, "template:custom:my-yaml-rule"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := templates.AgentTemplateKey(c.id, c.isCustom); got != c.want {
|
||||
t.Fatalf("AgentTemplateKey(%q, %v) = %q, want %q", c.id, c.isCustom, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := templates.AgentTemplateMetaKey("foo"); got != "template:meta:foo" {
|
||||
t.Fatalf("meta key = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_NseScriptCanonicalization is the regression net for the
|
||||
// PR 1 / PR 7 bug: app-scanner used to write "nse:script:foo.nse" while
|
||||
// the UI looked up "nse:script:foo". Both sides MUST go through
|
||||
// templates.NseScriptKey, which canonicalizes for them.
|
||||
func TestContract_NseScriptCanonicalization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := newFakeKV()
|
||||
|
||||
// Producer: app-scanner imitator writes a `.nse`-suffixed id.
|
||||
rec := &templates.NseScriptRecord{
|
||||
Content: "description = [[shellshock]]\n",
|
||||
Metadata: templates.NseScriptMeta{
|
||||
Author: "contract-suite",
|
||||
Tags: []string{"vuln", "exploit"},
|
||||
Description: "Detects CVE-2014-6271",
|
||||
},
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
if err := templates.WriteNseScript(ctx, kv, "http-shellshock.nse", rec); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// Consumer: ui-via-api imitator looks up the canonical id.
|
||||
got, err := templates.ReadNseScript(ctx, kv, "http-shellshock")
|
||||
if err != nil {
|
||||
t.Fatalf("read canonical: %v", err)
|
||||
}
|
||||
if got == nil || got.Content != rec.Content || got.Metadata.Description != rec.Metadata.Description {
|
||||
t.Fatalf("canonical read returned %+v", got)
|
||||
}
|
||||
|
||||
// Suffix-tolerant lookup must yield the same record.
|
||||
gotSuffixed, err := templates.ReadNseScript(ctx, kv, "http-shellshock.nse")
|
||||
if err != nil {
|
||||
t.Fatalf("read suffixed: %v", err)
|
||||
}
|
||||
if gotSuffixed == nil || gotSuffixed.Content != rec.Content {
|
||||
t.Fatalf("suffixed read returned %+v", gotSuffixed)
|
||||
}
|
||||
|
||||
for k := range kv.m {
|
||||
if strings.HasSuffix(k, ".nse") {
|
||||
t.Fatalf("non-canonical key persisted: %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_NseManifestCanonicalization asserts that the manifest
|
||||
// writer canonicalizes its map keys, so a producer that loads scripts
|
||||
// from disk with file extensions still produces a manifest the UI can
|
||||
// index by canonical id.
|
||||
func TestContract_NseManifestCanonicalization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := newFakeKV()
|
||||
|
||||
m := &templates.NseManifest{
|
||||
Name: "sirius-nse",
|
||||
Version: "v0.0.1",
|
||||
Scripts: map[string]templates.NseManifestEntry{
|
||||
"http-shellshock.nse": {Name: "http-shellshock", Path: "http-shellshock.nse", Protocol: "tcp"},
|
||||
"smb-vuln.nse": {Name: "smb-vuln", Path: "smb-vuln.nse", Protocol: "tcp"},
|
||||
},
|
||||
}
|
||||
if err := templates.WriteNseManifest(ctx, kv, m); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
got, err := templates.ReadNseManifest(ctx, kv)
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("manifest missing")
|
||||
}
|
||||
for key := range got.Scripts {
|
||||
if strings.HasSuffix(key, ".nse") {
|
||||
t.Fatalf("manifest key not canonicalized: %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := got.Scripts["http-shellshock"]; !ok {
|
||||
t.Fatalf("expected canonical http-shellshock key, got %v", got.Scripts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_TemplateWireShape pins the JSON envelope so a careless
|
||||
// rename of a TemplateRecord field doesn't silently break consumers
|
||||
// that read the bytes off the wire. If you intentionally change the
|
||||
// schema you must bump go-api and update this test in the same change
|
||||
// set, per the drift policy in
|
||||
// documentation/dev/architecture/README.scanner-storage.md.
|
||||
func TestContract_TemplateWireShape(t *testing.T) {
|
||||
rec := sampleTemplate()
|
||||
data, err := templates.EncodeTemplate(rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var generic map[string]any
|
||||
if err := json.Unmarshal(data, &generic); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v", err)
|
||||
}
|
||||
required := []string{
|
||||
"id", "version", "checksum", "size", "severity", "platforms",
|
||||
"detection_type", "author", "created", "updated",
|
||||
"vulnerability_ids", "is_custom", "content",
|
||||
}
|
||||
for _, k := range required {
|
||||
if _, ok := generic[k]; !ok {
|
||||
t.Fatalf("envelope missing required field %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_NseScriptWireShape pins the script JSON envelope.
|
||||
func TestContract_NseScriptWireShape(t *testing.T) {
|
||||
rec := &templates.NseScriptRecord{
|
||||
Content: "description = [[x]]\n",
|
||||
Metadata: templates.NseScriptMeta{Author: "a", Tags: []string{"t"}, Description: "d"},
|
||||
UpdatedAt: 42,
|
||||
}
|
||||
data, err := templates.EncodeNseScript(rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var generic map[string]any
|
||||
if err := json.Unmarshal(data, &generic); err != nil {
|
||||
t.Fatalf("script envelope is not valid JSON: %v", err)
|
||||
}
|
||||
for _, k := range []string{"content", "metadata", "updatedAt"} {
|
||||
if _, ok := generic[k]; !ok {
|
||||
t.Fatalf("script envelope missing required field %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module github.com/SiriusScan/sirius-scanner-storage-contract
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/SiriusScan/go-api v0.0.18
|
||||
|
||||
require (
|
||||
github.com/valkey-io/valkey-go v1.0.60 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
github.com/SiriusScan/go-api v0.0.18 h1:O6MLBFY2ssONkReMLT3LlLYFGBhKoWp5VOKXsTxOzOI=
|
||||
github.com/SiriusScan/go-api v0.0.18/go.mod h1:3uw5dE8qotMGDkF7AF3pwVSxcLNJY7ZI8csTXEmkOkE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||
github.com/valkey-io/valkey-go v1.0.60 h1:idh959D20H5n7D/kwEdTKNaMn5+4HpZTn7bLXnAhQIw=
|
||||
github.com/valkey-io/valkey-go v1.0.60/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
Reference in New Issue
Block a user