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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:25 +08:00
commit 161ef94b4f
708 changed files with 189571 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package main
import "os"
// Config holds all target addresses and credentials for the security test harness.
type Config struct {
// Service endpoints
APIURL string // Go API base URL (default http://localhost:9001)
UIURL string // Next.js / tRPC base URL (default http://localhost:3000)
GRPCAddr string // gRPC agent address (default localhost:50051)
ValkeyAddr string // Valkey / Redis address (default localhost:6379)
PostgresAddr string // PostgreSQL address (default localhost:5432)
RabbitMQAddr string // RabbitMQ AMQP address (default localhost:5672)
RabbitMQMgmt string // RabbitMQ Management address (default localhost:15672)
EngineHTTPAddr string // Engine HTTP address (default localhost:5174)
// Credentials
APIKey string // A valid API key for positive tests (auto-generated if empty)
// Flags
NoColor bool // Disable ANSI color codes
Verbose bool // Enable verbose output
}
// LoadConfig reads environment variables with sensible defaults.
func LoadConfig() *Config {
return &Config{
APIURL: envOr("SIRIUS_API_URL", "http://localhost:9001"),
UIURL: envOr("SIRIUS_UI_URL", "http://localhost:3000"),
GRPCAddr: envOr("SIRIUS_GRPC_ADDR", "localhost:50051"),
ValkeyAddr: envOr("SIRIUS_VALKEY_ADDR", "localhost:6379"),
PostgresAddr: envOr("SIRIUS_POSTGRES_ADDR", "localhost:5432"),
RabbitMQAddr: envOr("SIRIUS_RABBITMQ_ADDR", "localhost:5672"),
RabbitMQMgmt: envOr("SIRIUS_RABBITMQ_MGMT_ADDR", "localhost:15672"),
EngineHTTPAddr: envOr("SIRIUS_ENGINE_ADDR", "localhost:5174"),
APIKey: os.Getenv("SIRIUS_API_KEY"),
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+20
View File
@@ -0,0 +1,20 @@
module github.com/SiriusScan/sirius-security-tests
go 1.24.0
toolchain go1.24.1
require (
github.com/SiriusScan/app-agent v0.0.0
google.golang.org/grpc v1.71.1
)
require (
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
replace github.com/SiriusScan/app-agent => ../../../minor-projects/app-agent
+34
View File
@@ -0,0 +1,34 @@
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// ────────────────── HTTP helpers ──────────────────
// httpDo performs an HTTP request and returns status code, body, headers.
// It does NOT follow redirects.
func httpDo(method, url string, headers map[string]string, body string) (int, string, http.Header, error) {
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return 0, "", nil, fmt.Errorf("create request: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // don't follow redirects
},
}
resp, err := client.Do(req)
if err != nil {
return 0, "", nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(respBody), resp.Header, nil
}
// httpGet is a convenience wrapper around httpDo for GET requests.
func httpGet(url string, headers map[string]string) (int, string, http.Header, error) {
return httpDo("GET", url, headers, "")
}
// httpPost is a convenience wrapper for POST with a JSON body.
func httpPost(url string, headers map[string]string, jsonBody string) (int, string, http.Header, error) {
if headers == nil {
headers = map[string]string{}
}
if _, ok := headers["Content-Type"]; !ok {
headers["Content-Type"] = "application/json"
}
return httpDo("POST", url, headers, jsonBody)
}
// httpDelete is a convenience wrapper for DELETE requests.
func httpDelete(url string, headers map[string]string) (int, string, http.Header, error) {
return httpDo("DELETE", url, headers, "")
}
// ────────────────── TCP helpers ──────────────────
// tcpProbe tries to open a TCP connection to addr within the timeout.
// Returns true if the connection succeeded.
func tcpProbe(addr string, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return false
}
conn.Close()
return true
}
// tcpSendRecv opens a TCP connection, sends data, and reads the response.
func tcpSendRecv(addr string, send string, timeout time.Duration) (string, error) {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return "", err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
if send != "" {
_, err = conn.Write([]byte(send))
if err != nil {
return "", err
}
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil && n == 0 {
return "", err
}
return string(buf[:n]), nil
}
// ────────────────── API key helpers ──────────────────
// apiHeaders returns a header map with the given API key.
func apiHeaders(key string) map[string]string {
if key == "" {
return nil
}
return map[string]string{"X-API-Key": key}
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"flag"
"fmt"
"os"
)
func main() {
noColor := flag.Bool("no-color", false, "Disable ANSI color output")
verbose := flag.Bool("verbose", false, "Enable verbose output")
suiteFlag := flag.String("suite", "", "Run only a specific suite (api, trpc, grpc, services, headers, auth-surface)")
flag.Parse()
cfg := LoadConfig()
cfg.NoColor = *noColor
cfg.Verbose = *verbose
report := NewReport(cfg.NoColor)
// Map of available suites.
type suiteRunner struct {
name string
fn func(*Config) SuiteResult
}
allSuites := []suiteRunner{
{"api", RunAPISuite},
{"trpc", RunTRPCSuite},
{"grpc", RunGRPCSuite},
{"services", RunServicesSuite},
{"headers", RunHeadersSuite},
{"auth-surface", RunAuthSurfaceSuite},
}
// Filter to a single suite if requested.
suitesToRun := allSuites
if *suiteFlag != "" {
suitesToRun = nil
for _, s := range allSuites {
if s.name == *suiteFlag {
suitesToRun = append(suitesToRun, s)
break
}
}
if len(suitesToRun) == 0 {
fmt.Fprintf(os.Stderr, "Unknown suite %q. Available: api, trpc, grpc, services, headers, auth-surface\n", *suiteFlag)
os.Exit(2)
}
}
// Run selected suites.
for _, s := range suitesToRun {
result := s.fn(cfg)
report.AddSuite(result)
}
// Print report.
report.Print()
// Exit code 1 if any hard failures.
if report.HasFailures() {
os.Exit(1)
}
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"fmt"
"strings"
"time"
)
// Severity levels for findings.
type Severity int
const (
SevInfo Severity = iota
SevLow
SevMedium
SevHigh
SevCritical
)
func (s Severity) String() string {
switch s {
case SevInfo:
return "INFO"
case SevLow:
return "LOW"
case SevMedium:
return "MEDIUM"
case SevHigh:
return "HIGH"
case SevCritical:
return "CRITICAL"
}
return "UNKNOWN"
}
// Result represents the outcome of a single test.
type Result int
const (
Pass Result = iota
Fail
Warn
Info
Skip
)
func (r Result) String() string {
switch r {
case Pass:
return "PASS"
case Fail:
return "FAIL"
case Warn:
return "WARN"
case Info:
return "INFO"
case Skip:
return "SKIP"
}
return "UNKNOWN"
}
// TestResult holds the outcome of a single security test.
type TestResult struct {
Name string
Result Result
Severity Severity
Detail string // e.g. "expected 401, got 200"
}
// SuiteResult groups test results under a named suite.
type SuiteResult struct {
Name string
Results []TestResult
}
// Report collects all suite results and prints a formatted report.
type Report struct {
Suites []SuiteResult
noColor bool
}
// NewReport creates a new report.
func NewReport(noColor bool) *Report {
return &Report{noColor: noColor}
}
// AddSuite appends a completed suite to the report.
func (r *Report) AddSuite(s SuiteResult) {
r.Suites = append(r.Suites, s)
}
// ────────────────── ANSI helpers ──────────────────
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiBlue = "\033[34m"
ansiCyan = "\033[36m"
ansiWhite = "\033[37m"
ansiGray = "\033[90m"
)
func (r *Report) c(code, text string) string {
if r.noColor {
return text
}
return code + text + ansiReset
}
// ────────────────── Printing ──────────────────
// Print renders the full report to stdout.
func (r *Report) Print() {
line := strings.Repeat("=", 79)
thinLine := strings.Repeat("-", 79)
fmt.Println()
fmt.Println(r.c(ansiBold, line))
fmt.Println(r.c(ansiBold, " SIRIUS SECURITY TEST REPORT"))
fmt.Printf(" Generated: %s\n", time.Now().Format("2006-01-02 15:04:05"))
fmt.Println(r.c(ansiBold, line))
totalPass, totalFail, totalWarn, totalInfo, totalSkip := 0, 0, 0, 0, 0
sevCounts := map[Severity]int{}
for _, suite := range r.Suites {
fmt.Println()
fmt.Printf("%s %s (%d tests)\n",
r.c(ansiBold+ansiCyan, "[SUITE]"),
r.c(ansiBold, suite.Name),
len(suite.Results))
fmt.Println(r.c(ansiGray, thinLine))
for _, t := range suite.Results {
tag := r.resultTag(t.Result)
sevTag := ""
if t.Result != Pass && t.Result != Skip {
sevTag = " " + r.sevTag(t.Severity)
sevCounts[t.Severity]++
}
detail := ""
if t.Detail != "" {
detail = r.c(ansiGray, " → "+t.Detail)
}
fmt.Printf(" %s %s%s%s\n", tag, t.Name, detail, sevTag)
switch t.Result {
case Pass:
totalPass++
case Fail:
totalFail++
case Warn:
totalWarn++
case Info:
totalInfo++
case Skip:
totalSkip++
}
}
}
total := totalPass + totalFail + totalWarn + totalInfo + totalSkip
fmt.Println()
fmt.Println(r.c(ansiBold, line))
fmt.Println(r.c(ansiBold, " SUMMARY"))
fmt.Println(r.c(ansiGray, thinLine))
fmt.Printf(" Total: %d | %s: %d | %s: %d | %s: %d | %s: %d | %s: %d\n",
total,
r.c(ansiGreen, "Passed"), totalPass,
r.c(ansiRed, "Failed"), totalFail,
r.c(ansiYellow, "Warnings"), totalWarn,
r.c(ansiBlue, "Info"), totalInfo,
r.c(ansiGray, "Skipped"), totalSkip,
)
fmt.Println(r.c(ansiGray, thinLine))
fmt.Printf(" %s: %d | %s: %d | %s: %d | %s: %d | %s: %d\n",
r.c(ansiRed+ansiBold, "Critical"), sevCounts[SevCritical],
r.c(ansiRed, "High"), sevCounts[SevHigh],
r.c(ansiYellow, "Medium"), sevCounts[SevMedium],
r.c(ansiGreen, "Low"), sevCounts[SevLow],
r.c(ansiBlue, "Info"), sevCounts[SevInfo],
)
fmt.Println(r.c(ansiBold, line))
fmt.Println()
}
func (r *Report) resultTag(res Result) string {
switch res {
case Pass:
return r.c(ansiGreen+ansiBold, "[PASS]")
case Fail:
return r.c(ansiRed+ansiBold, "[FAIL]")
case Warn:
return r.c(ansiYellow+ansiBold, "[WARN]")
case Info:
return r.c(ansiBlue+ansiBold, "[INFO]")
case Skip:
return r.c(ansiGray+ansiBold, "[SKIP]")
}
return "[????]"
}
func (r *Report) sevTag(sev Severity) string {
switch sev {
case SevCritical:
return r.c(ansiRed+ansiBold, "[CRITICAL]")
case SevHigh:
return r.c(ansiRed, "[HIGH]")
case SevMedium:
return r.c(ansiYellow, "[MEDIUM]")
case SevLow:
return r.c(ansiGreen, "[LOW]")
case SevInfo:
return r.c(ansiBlue, "[INFO]")
}
return "[???]"
}
// HasFailures returns true if any test resulted in Fail.
func (r *Report) HasFailures() bool {
for _, s := range r.Suites {
for _, t := range s.Results {
if t.Result == Fail {
return true
}
}
}
return false
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"encoding/json"
"fmt"
"strings"
)
// RunAPISuite tests authentication and authorization on the Go REST API (port 9001).
func RunAPISuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "Go API Authentication"}
base := cfg.APIURL
// ── Phase 0: Ensure API is reachable ────────────────────────────────
code, _, _, err := httpGet(base+"/health", nil)
if err != nil || code == 0 {
suite.Results = append(suite.Results, TestResult{
Name: "API reachability check",
Result: Skip,
Severity: SevCritical,
Detail: fmt.Sprintf("Cannot reach %s/health: %v", base, err),
})
return suite
}
// ── Phase 1: Obtain a valid API key for positive tests ──────────────
validKey := cfg.APIKey
if validKey == "" {
validKey = provisionAPIKeyForTests(cfg, &suite)
if validKey == "" {
// Cannot proceed without a valid key; all remaining tests skipped.
suite.Results = append(suite.Results, TestResult{
Name: "API key provisioning",
Result: Skip,
Severity: SevCritical,
Detail: "Could not obtain a valid API key; remaining tests skipped",
})
return suite
}
}
// ── Phase 2: Health endpoint bypass ─────────────────────────────────
suite.Results = append(suite.Results, testHealthBypass(base))
// ── Phase 3: No API key on protected endpoints ──────────────────────
protectedEndpoints := []struct {
method string
path string
}{
{"GET", "/host/"},
{"GET", "/vulnerability/nonexistent-id"},
{"GET", "/templates"},
{"GET", "/api/agent-templates"},
{"GET", "/api/v1/scans/status"},
{"GET", "/api/v1/events"},
{"GET", "/api/v1/statistics/vulnerability-trends"},
{"GET", "/api/v1/system/health"},
{"GET", "/api/v1/logs"},
{"GET", "/api/v1/keys"},
{"POST", "/api/v1/keys"},
}
for _, ep := range protectedEndpoints {
suite.Results = append(suite.Results, testNoAPIKey(base, ep.method, ep.path))
}
// ── Phase 4: Invalid API key variants ───────────────────────────────
invalidKeys := []struct {
label string
key string
}{
{"random string", "totally-invalid-key-12345"},
{"malformed sk_ prefix", "sk_not-hex-at-all!!!"},
{"empty value", ""},
{"truncated valid key", validKey[:10]},
}
for _, ik := range invalidKeys {
suite.Results = append(suite.Results, testInvalidKey(base, ik.label, ik.key))
}
// ── Phase 5: Valid API key succeeds ─────────────────────────────────
for _, ep := range protectedEndpoints {
suite.Results = append(suite.Results, testValidKey(base, validKey, ep.method, ep.path))
}
// ── Phase 6: Key in wrong header / query param ──────────────────────
suite.Results = append(suite.Results, testKeyWrongHeader(base, validKey, "Authorization"))
suite.Results = append(suite.Results, testKeyWrongHeader(base, validKey, "Api-Key"))
suite.Results = append(suite.Results, testKeyInQueryParam(base, validKey))
// ── Phase 7: Revoked key ────────────────────────────────────────────
suite.Results = append(suite.Results, testRevokedKey(cfg, validKey)...)
// ── Phase 8: Case sensitivity of header ─────────────────────────────
suite.Results = append(suite.Results, testHeaderCaseSensitivity(base, validKey))
return suite
}
// ────────────────── Individual test functions ──────────────────
func testHealthBypass(base string) TestResult {
code, _, _, err := httpGet(base+"/health", nil)
if err != nil {
return TestResult{Name: "Health endpoint bypass (no key)", Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code == 200 {
return TestResult{Name: "Health endpoint bypass (no key)", Result: Pass,
Detail: "200 OK without API key"}
}
return TestResult{Name: "Health endpoint bypass (no key)", Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected 200, got %d", code)}
}
func testNoAPIKey(base, method, path string) TestResult {
name := fmt.Sprintf("No API key on %s %s", method, path)
code, _, _, err := httpDo(method, base+path, nil, "")
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code == 401 {
return TestResult{Name: name, Result: Pass, Detail: "401 Unauthorized"}
}
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected 401, got %d", code)}
}
func testInvalidKey(base, label, key string) TestResult {
name := fmt.Sprintf("Invalid API key (%s)", label)
headers := map[string]string{"X-API-Key": key}
code, _, _, err := httpGet(base+"/host/", headers)
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code == 401 {
return TestResult{Name: name, Result: Pass, Detail: "401 Unauthorized"}
}
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected 401, got %d", code)}
}
func testValidKey(base, key, method, path string) TestResult {
name := fmt.Sprintf("Valid API key on %s %s", method, path)
headers := apiHeaders(key)
var code int
var err error
if method == "POST" {
code, _, _, err = httpPost(base+path, headers, `{"label":"test"}`)
} else {
code, _, _, err = httpDo(method, base+path, headers, "")
}
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err)}
}
// Any non-401 response means auth succeeded. 404/500 etc. are functional
// issues, not auth failures.
if code != 401 {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("%d (auth passed)", code)}
}
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: "got 401 with valid key"}
}
func testKeyWrongHeader(base, key, headerName string) TestResult {
name := fmt.Sprintf("API key in wrong header (%s)", headerName)
headers := map[string]string{headerName: key}
code, _, _, err := httpGet(base+"/host/", headers)
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code == 401 {
return TestResult{Name: name, Result: Pass,
Detail: "401 — key only accepted via X-API-Key header"}
}
return TestResult{Name: name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("expected 401, got %d (key accepted in wrong header)", code)}
}
func testKeyInQueryParam(base, key string) TestResult {
name := "API key in query parameter"
url := fmt.Sprintf("%s/host/?api_key=%s", base, key)
code, _, _, err := httpGet(url, nil)
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code == 401 {
return TestResult{Name: name, Result: Pass,
Detail: "401 — key only accepted via header, not query param"}
}
return TestResult{Name: name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("expected 401, got %d (key accepted in query param)", code)}
}
func testRevokedKey(cfg *Config, validKey string) []TestResult {
base := cfg.APIURL
var results []TestResult
// Create a new key, then revoke it, then try to use it.
headers := apiHeaders(validKey)
code, body, _, err := httpPost(base+"/api/v1/keys", headers, `{"label":"revoke-test"}`)
if err != nil || code < 200 || code >= 300 {
results = append(results, TestResult{
Name: "Revoked key test (setup: create key)", Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("could not create test key: status=%d err=%v", code, err),
})
return results
}
// Parse the raw key and hash ID from the response.
var createResp struct {
RawKey string `json:"raw_key"`
Key string `json:"key"`
Meta struct {
ID string `json:"id"`
} `json:"meta"`
}
if err := json.Unmarshal([]byte(body), &createResp); err != nil {
results = append(results, TestResult{
Name: "Revoked key test (setup: parse key)", Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("could not parse create response: %v", err),
})
return results
}
newKey := strings.TrimSpace(createResp.RawKey)
if newKey == "" {
newKey = strings.TrimSpace(createResp.Key)
}
keyID := strings.TrimSpace(createResp.Meta.ID)
if newKey == "" || keyID == "" {
results = append(results, TestResult{
Name: "Revoked key test (setup: parse key)", Result: Skip, Severity: SevHigh,
Detail: "create key response missing raw key or key id metadata",
})
return results
}
// Verify the new key works.
code, _, _, _ = httpGet(base+"/host/", apiHeaders(newKey))
if code == 401 {
results = append(results, TestResult{
Name: "Revoked key test (setup: verify new key works)", Result: Skip, Severity: SevHigh,
Detail: "newly created key already returns 401",
})
return results
}
// Revoke the key.
code, _, _, err = httpDelete(base+"/api/v1/keys/"+keyID, headers)
if err != nil || (code < 200 || code >= 300) {
results = append(results, TestResult{
Name: "Revoked key test (setup: revoke key)", Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("could not revoke key: status=%d err=%v", code, err),
})
return results
}
// Try to use the revoked key.
code, _, _, err = httpGet(base+"/host/", apiHeaders(newKey))
if err != nil {
results = append(results, TestResult{
Name: "Revoked key rejected", Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err),
})
return results
}
if code == 401 {
results = append(results, TestResult{
Name: "Revoked key rejected", Result: Pass,
Detail: "401 — revoked key correctly rejected",
})
} else {
results = append(results, TestResult{
Name: "Revoked key rejected", Result: Fail, Severity: SevCritical,
Detail: fmt.Sprintf("expected 401, got %d — revoked key still accepted!", code),
})
}
return results
}
func testHeaderCaseSensitivity(base, key string) TestResult {
// HTTP headers are case-insensitive per spec, so x-api-key should work too.
name := "Header case insensitivity (x-api-key lowercase)"
headers := map[string]string{"x-api-key": key}
code, _, _, err := httpGet(base+"/host/", headers)
if err != nil {
return TestResult{Name: name, Result: Warn, Severity: SevLow,
Detail: fmt.Sprintf("request error: %v", err)}
}
if code != 401 {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — header is case-insensitive (correct per HTTP spec)", code)}
}
return TestResult{Name: name, Result: Warn, Severity: SevLow,
Detail: "lowercase header rejected — may cause client compatibility issues"}
}
// ────────────────── Bootstrap helper ──────────────────
// provisionAPIKeyForTests creates a test API key using the /api/v1/keys endpoint.
// It first tries without auth (in case API_KEY_REQUIRED=false in dev mode).
// Falls back to checking if there's an existing root key reference.
func provisionAPIKeyForTests(cfg *Config, suite *SuiteResult) string {
base := cfg.APIURL
// Try creating a key without auth (works if API_KEY_REQUIRED=false).
code, body, _, err := httpPost(base+"/api/v1/keys", nil, `{"label":"security-test-bootstrap"}`)
if err == nil && code >= 200 && code < 300 {
var resp struct {
RawKey string `json:"raw_key"`
}
if json.Unmarshal([]byte(body), &resp) == nil && resp.RawKey != "" {
suite.Results = append(suite.Results, TestResult{
Name: "API key provisioning (no auth)", Result: Warn, Severity: SevHigh,
Detail: "Key creation succeeded without API key — API_KEY_REQUIRED may be false",
})
return resp.RawKey
}
}
// If we got 401, auth is enforced. We need a key from the environment.
if code == 401 {
suite.Results = append(suite.Results, TestResult{
Name: "API key provisioning", Result: Info, Severity: SevInfo,
Detail: "API requires authentication — set SIRIUS_API_KEY env var with a valid key",
})
}
// Last resort: check if the API returns something useful at health that hints at setup.
if strings.Contains(body, "error") {
suite.Results = append(suite.Results, TestResult{
Name: "API key provisioning", Result: Skip, Severity: SevInfo,
Detail: fmt.Sprintf("Cannot provision key: %s", truncate(body, 120)),
})
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"fmt"
"strings"
)
// RunAuthSurfaceSuite validates authentication and selector-hardening behavior
// across direct tRPC backends (Valkey/RabbitMQ) and sensitive Go API endpoints.
func RunAuthSurfaceSuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "Auth Surface Coverage"}
baseUI := cfg.UIURL
baseAPI := cfg.APIURL
// 1) tRPC direct-backend procedures must reject unauthenticated callers.
trpcMutations := []struct {
name string
path string
body string
}{
{
name: "tRPC store.setValue without session",
path: "/api/trpc/store.setValue",
body: `{"json":{"key":"currentScan","value":"{}"}}`,
},
{
name: "tRPC queue.sendMsg without session",
path: "/api/trpc/queue.sendMsg",
body: `{"json":{"queue":"agent_commands","message":"{\"action\":\"list_agents\"}"}}`,
},
{
name: "tRPC terminal.executeCommand without session",
path: "/api/trpc/terminal.executeCommand",
body: `{"json":{"command":"whoami","target":{"type":"engine"}}}`,
},
{
name: "tRPC agentScan.dispatchAgentScan without session",
path: "/api/trpc/agentScan.dispatchAgentScan",
body: `{"json":{"scanId":"scan-test","mode":"safe","concurrency":1,"timeout":30}}`,
},
}
for _, tc := range trpcMutations {
code, body, _, err := httpPost(baseUI+tc.path, nil, tc.body)
if err != nil {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err),
})
continue
}
if isUnauthorizedLike(code, body) {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Pass, Detail: fmt.Sprintf("%d unauthorized as expected", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected unauthorized, got %d", code),
})
}
}
// 2) Sensitive API endpoints must reject missing API key.
apiProtected := []struct {
name string
method string
path string
body string
}{
{"API admin command without key", "POST", "/api/v1/admin/command", `{"action":"restart","container_name":"sirius-api"}`},
{"API logs clear without key", "DELETE", "/api/v1/logs/clear", ""},
{"API source-aware host ingest without key", "POST", "/host/with-source", `{"host":{"ip":"10.10.10.10"},"source":{"name":"security-test","version":"1.0.0","config":"test"}}`},
{"API key list without key", "GET", "/api/v1/keys", ""},
}
for _, tc := range apiProtected {
code, body, _, err := httpDo(tc.method, baseAPI+tc.path, nil, tc.body)
if err != nil {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", err),
})
continue
}
if code == 401 || isUnauthorizedLike(code, body) {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Pass, Detail: fmt.Sprintf("%d unauthorized as expected", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected 401/unauthorized, got %d", code),
})
}
}
// 3) Selector-hardening checks (requires a valid key).
if cfg.APIKey == "" {
suite.Results = append(suite.Results, TestResult{
Name: "Selector-hardening checks",
Result: Warn,
Severity: SevLow,
Detail: "Skipped: SIRIUS_API_KEY not set for positive-key validation",
})
return suite
}
authHeaders := apiHeaders(cfg.APIKey)
selectorCases := []struct {
name string
method string
path string
body string
expectStatus int
}{
{
name: "Template invalid ID format returns 400",
method: "GET",
path: "/templates/invalid!template",
body: "",
expectStatus: 400,
},
{
name: "API key revoke invalid ID format returns 400",
method: "DELETE",
path: "/api/v1/keys/not-a-valid-hash",
body: "",
expectStatus: 400,
},
{
name: "Agent template test requires connected agent",
method: "POST",
path: "/api/agent-templates/nonexistent/test",
body: `{"agentId":"definitely-not-connected-agent"}`,
expectStatus: 400,
},
}
for _, tc := range selectorCases {
code, _, _, err := httpDo(tc.method, baseAPI+tc.path, authHeaders, tc.body)
if err != nil {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("request error: %v", err),
})
continue
}
if code == tc.expectStatus {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Pass, Detail: fmt.Sprintf("got expected status %d", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: tc.name, Result: Fail, Severity: SevMedium,
Detail: fmt.Sprintf("expected %d, got %d", tc.expectStatus, code),
})
}
}
return suite
}
func isUnauthorizedLike(code int, body string) bool {
if code == 401 || code == 403 {
return true
}
lower := strings.ToLower(body)
return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "unauthenticated")
}
+263
View File
@@ -0,0 +1,263 @@
package main
import (
"context"
"fmt"
"strings"
"time"
pb "github.com/SiriusScan/app-agent/proto/hello"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// RunGRPCSuite tests authentication on the gRPC agent service (port 50051).
func RunGRPCSuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "gRPC Agent Authentication"}
// ── Phase 0: Reachability ───────────────────────────────────────────
if !tcpProbe(cfg.GRPCAddr, 3*time.Second) {
suite.Results = append(suite.Results, TestResult{
Name: "gRPC reachability check",
Result: Skip,
Severity: SevCritical,
Detail: fmt.Sprintf("Cannot reach %s", cfg.GRPCAddr),
})
return suite
}
// ── Phase 1: New agent connection — should receive a token ──────────
token, result := testNewAgentGetsToken(cfg)
suite.Results = append(suite.Results, result)
// ── Phase 2: Returning agent with valid token ───────────────────────
if token != "" {
suite.Results = append(suite.Results, testValidTokenReconnect(cfg, token))
} else {
suite.Results = append(suite.Results, TestResult{
Name: "Returning agent with valid token", Result: Skip, Severity: SevHigh,
Detail: "no token obtained from Phase 1",
})
}
// ── Phase 3: Returning agent with invalid token ─────────────────────
suite.Results = append(suite.Results, testInvalidTokenRejected(cfg))
// ── Phase 4: Known agent without token (should be rejected) ─────────
if token != "" {
suite.Results = append(suite.Results, testKnownAgentNoToken(cfg))
}
// ── Phase 5: Ping without established stream ────────────────────────
suite.Results = append(suite.Results, testPingWithoutStream(cfg))
// ── Phase 6: Agent impersonation ────────────────────────────────────
if token != "" {
suite.Results = append(suite.Results, testAgentImpersonation(cfg, token))
}
return suite
}
// ────────────────── Individual gRPC tests ──────────────────
// testNewAgentGetsToken connects as a brand-new agent and expects to receive
// an auth_token in the server's welcome message.
func testNewAgentGetsToken(cfg *Config) (string, TestResult) {
name := "New agent receives auth token"
agentID := fmt.Sprintf("security-test-new-%d", time.Now().UnixNano())
token, err := connectAndGetToken(cfg.GRPCAddr, agentID, "")
if err != nil {
return "", TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("connection error: %v", err)}
}
if token == "" {
return "", TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: "server did not issue a token in welcome message"}
}
return token, TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("received token (prefix: %s...)", token[:8])}
}
// testValidTokenReconnect connects with the token obtained from Phase 1.
func testValidTokenReconnect(cfg *Config, token string) TestResult {
name := "Returning agent with valid token"
// Use the same agent ID pattern — the server should recognize it.
// NOTE: the token is bound to the agent ID that created it in Phase 1,
// so we need to know that ID. For simplicity, we create a NEW agent
// and get its token, then reconnect with the same ID.
agentID := fmt.Sprintf("security-test-reconnect-%d", time.Now().UnixNano())
// First connection — get a token.
newToken, err := connectAndGetToken(cfg.GRPCAddr, agentID, "")
if err != nil || newToken == "" {
return TestResult{Name: name, Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("setup failed: %v", err)}
}
// Second connection — reconnect with the token.
_, err = connectAndGetToken(cfg.GRPCAddr, agentID, newToken)
if err != nil {
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("reconnect with valid token failed: %v", err)}
}
return TestResult{Name: name, Result: Pass,
Detail: "reconnected successfully with stored token"}
}
// testInvalidTokenRejected tries to connect with a garbage token.
func testInvalidTokenRejected(cfg *Config) TestResult {
name := "Invalid token rejected"
// Create a new agent first so a token exists, then reconnect with bad token.
agentID := fmt.Sprintf("security-test-badtoken-%d", time.Now().UnixNano())
// First: establish the agent so a token exists server-side.
_, err := connectAndGetToken(cfg.GRPCAddr, agentID, "")
if err != nil {
return TestResult{Name: name, Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("setup (first connect) failed: %v", err)}
}
// Second: reconnect with an invalid token.
_, err = connectAndGetToken(cfg.GRPCAddr, agentID, "totally-invalid-token-value")
if err != nil {
// Expected — the server should reject this.
if strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "auth") ||
strings.Contains(err.Error(), "EOF") || strings.Contains(err.Error(), "unavailable") ||
strings.Contains(err.Error(), "RST_STREAM") {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("rejected: %v", truncate(err.Error(), 80))}
}
// Some other error — still likely a rejection.
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("connection failed (likely rejected): %v", truncate(err.Error(), 80))}
}
return TestResult{Name: name, Result: Fail, Severity: SevCritical,
Detail: "server accepted an invalid token!"}
}
// testKnownAgentNoToken tries to connect as a known agent without presenting a token.
func testKnownAgentNoToken(cfg *Config) TestResult {
name := "Known agent without token rejected"
agentID := fmt.Sprintf("security-test-notoken-%d", time.Now().UnixNano())
// First: register the agent.
_, err := connectAndGetToken(cfg.GRPCAddr, agentID, "")
if err != nil {
return TestResult{Name: name, Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("setup failed: %v", err)}
}
// Second: reconnect WITHOUT the token (empty).
_, err = connectAndGetToken(cfg.GRPCAddr, agentID, "")
if err != nil {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("rejected: %v", truncate(err.Error(), 80))}
}
return TestResult{Name: name, Result: Fail, Severity: SevCritical,
Detail: "server allowed reconnection without token for a known agent"}
}
// testPingWithoutStream calls the Ping RPC without an established stream.
func testPingWithoutStream(cfg *Config) TestResult {
name := "Ping RPC without established stream"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.NewClient(cfg.GRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return TestResult{Name: name, Result: Skip, Severity: SevHigh,
Detail: fmt.Sprintf("dial error: %v", err)}
}
defer conn.Close()
client := pb.NewHelloServiceClient(conn)
_, err = client.Ping(ctx, &pb.PingRequest{AgentId: "nonexistent-agent"})
if err != nil {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("rejected: %v", truncate(err.Error(), 80))}
}
return TestResult{Name: name, Result: Fail, Severity: SevHigh,
Detail: "Ping succeeded for agent without an active stream"}
}
// testAgentImpersonation tries to use agent A's token with agent B's ID.
func testAgentImpersonation(cfg *Config, tokenFromOtherAgent string) TestResult {
name := "Agent impersonation (A's token with B's ID)"
victimID := fmt.Sprintf("security-test-impersonate-%d", time.Now().UnixNano())
_, err := connectAndGetToken(cfg.GRPCAddr, victimID, tokenFromOtherAgent)
if err != nil {
return TestResult{Name: name, Result: Pass,
Detail: fmt.Sprintf("impersonation rejected: %v", truncate(err.Error(), 80))}
}
return TestResult{Name: name, Result: Fail, Severity: SevCritical,
Detail: "server accepted token from a different agent — impersonation possible!"}
}
// ────────────────── gRPC connection helper ──────────────────
// connectAndGetToken opens a ConnectStream, sends an initial heartbeat with
// the given agentID and token, receives the server's first message, and
// returns any auth_token from the response. It closes the stream afterwards.
func connectAndGetToken(addr, agentID, token string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return "", fmt.Errorf("dial: %w", err)
}
defer conn.Close()
client := pb.NewHelloServiceClient(conn)
// Add agent_id as metadata (matching the agent implementation).
md := metadata.New(map[string]string{
"agent_id": agentID,
"scripting_enabled": "false",
})
streamCtx := metadata.NewOutgoingContext(ctx, md)
stream, err := client.ConnectStream(streamCtx)
if err != nil {
return "", fmt.Errorf("open stream: %w", err)
}
// Send initial heartbeat with auth token.
err = stream.Send(&pb.AgentMessage{
AgentId: agentID,
Type: pb.MessageType_HEARTBEAT,
Payload: &pb.AgentMessage_Heartbeat{
Heartbeat: &pb.HeartbeatMessage{
Timestamp: time.Now().Unix(),
},
},
AuthToken: token,
})
if err != nil {
return "", fmt.Errorf("send heartbeat: %w", err)
}
// Receive welcome message.
resp, err := stream.Recv()
if err != nil {
return "", fmt.Errorf("recv welcome: %w", err)
}
// Cleanly close.
_ = stream.CloseSend()
return resp.GetAuthToken(), nil
}
+266
View File
@@ -0,0 +1,266 @@
package main
import (
"fmt"
"net/http"
"strings"
)
// RunHeadersSuite tests HTTP security headers and transport-level controls.
func RunHeadersSuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "Security Headers & Transport"}
// We test both the Go API and the UI.
targets := []struct {
label string
url string
}{
{"Go API", cfg.APIURL + "/health"},
{"UI", cfg.UIURL},
}
for _, t := range targets {
_, _, hdrs, err := httpGet(t.url, nil)
if err != nil {
suite.Results = append(suite.Results, TestResult{
Name: t.label + " reachability",
Result: Skip, Severity: SevInfo,
Detail: fmt.Sprintf("cannot reach %s: %v", t.url, err),
})
continue
}
suite.Results = append(suite.Results, testSecurityHeaders(t.label, hdrs)...)
}
// ── CORS on Go API ──────────────────────────────────────────────────
suite.Results = append(suite.Results, testCORS(cfg.APIURL)...)
// ── Error information disclosure ────────────────────────────────────
suite.Results = append(suite.Results, testErrorDisclosure(cfg.APIURL)...)
// ── HTTP method testing ─────────────────────────────────────────────
suite.Results = append(suite.Results, testHTTPMethods(cfg.APIURL)...)
return suite
}
// ────────────────── Security headers ──────────────────
func testSecurityHeaders(label string, hdrs http.Header) []TestResult {
var results []TestResult
checks := []struct {
header string
expected string // If non-empty, check that value contains this.
severity Severity
}{
{"X-Content-Type-Options", "nosniff", SevLow},
{"X-Frame-Options", "", SevLow},
{"Strict-Transport-Security", "", SevMedium},
{"Content-Security-Policy", "", SevLow},
{"X-XSS-Protection", "", SevLow},
{"Referrer-Policy", "", SevLow},
}
for _, c := range checks {
name := fmt.Sprintf("%s: %s header", label, c.header)
val := hdrs.Get(c.header)
if val == "" {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: c.severity,
Detail: "header missing",
})
continue
}
if c.expected != "" && !strings.Contains(strings.ToLower(val), strings.ToLower(c.expected)) {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: c.severity,
Detail: fmt.Sprintf("unexpected value: %s (expected to contain %q)", val, c.expected),
})
continue
}
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: val,
})
}
return results
}
// ────────────────── CORS ──────────────────
func testCORS(apiURL string) []TestResult {
var results []TestResult
// Send a preflight OPTIONS request with a foreign origin.
headers := map[string]string{
"Origin": "https://evil-attacker.com",
"Access-Control-Request-Method": "GET",
}
code, _, respHdrs, err := httpDo("OPTIONS", apiURL+"/health", headers, "")
if err != nil {
results = append(results, TestResult{
Name: "CORS preflight", Result: Skip, Severity: SevMedium,
Detail: fmt.Sprintf("request error: %v", err),
})
return results
}
allowOrigin := respHdrs.Get("Access-Control-Allow-Origin")
name := "CORS: Access-Control-Allow-Origin"
if allowOrigin == "*" {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: "wildcard (*) — allows any origin to make cross-origin requests",
})
} else if allowOrigin == "https://evil-attacker.com" {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: SevHigh,
Detail: "reflects arbitrary origin — CORS misconfiguration",
})
} else if allowOrigin == "" {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: "no CORS header returned for foreign origin (restrictive)",
})
} else {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("restricted to: %s", allowOrigin),
})
}
// Check Allow-Credentials with wildcard (dangerous combination).
allowCreds := respHdrs.Get("Access-Control-Allow-Credentials")
if allowOrigin == "*" && strings.EqualFold(allowCreds, "true") {
results = append(results, TestResult{
Name: "CORS: wildcard + credentials", Result: Fail, Severity: SevHigh,
Detail: "Access-Control-Allow-Origin: * with Allow-Credentials: true — credential theft risk",
})
}
_ = code
return results
}
// ────────────────── Error information disclosure ──────────────────
func testErrorDisclosure(apiURL string) []TestResult {
var results []TestResult
// Hit a non-existent route.
code, body, _, err := httpGet(apiURL+"/nonexistent-path-12345", nil)
if err != nil {
return results
}
name := "Error response disclosure (404)"
lower := strings.ToLower(body)
leaks := []string{}
if strings.Contains(lower, "stack") || strings.Contains(lower, "goroutine") {
leaks = append(leaks, "stack trace")
}
if strings.Contains(lower, "/app/") || strings.Contains(lower, "/go/src/") || strings.Contains(lower, "/home/") {
leaks = append(leaks, "internal file paths")
}
if strings.Contains(lower, "fiber") || strings.Contains(lower, "fasthttp") {
leaks = append(leaks, "framework name")
}
if strings.Contains(lower, "postgres") || strings.Contains(lower, "valkey") || strings.Contains(lower, "redis") {
leaks = append(leaks, "infrastructure names")
}
if len(leaks) > 0 {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("%d response leaks: %s", code, strings.Join(leaks, ", ")),
})
} else {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d response — no sensitive information disclosed", code),
})
}
// Hit with malformed JSON body.
name = "Error response disclosure (malformed body)"
code, body, _, err = httpPost(apiURL+"/host/", nil, `{{{invalid json`)
if err != nil {
return results
}
lower = strings.ToLower(body)
if strings.Contains(lower, "stack") || strings.Contains(lower, "goroutine") ||
strings.Contains(lower, "panic") {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("%d response leaks internal details on malformed input", code),
})
} else {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d response — safe error handling", code),
})
}
return results
}
// ────────────────── HTTP method testing ──────────────────
func testHTTPMethods(apiURL string) []TestResult {
var results []TestResult
// TRACE should be disabled — it can enable XSS via cross-site tracing.
name := "TRACE method disabled"
code, body, _, err := httpDo("TRACE", apiURL+"/health", nil, "")
if err != nil {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: "TRACE request failed (likely disabled)",
})
} else if code == 405 || code == 404 || code == 501 {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — TRACE method not allowed", code),
})
} else if code == 200 && strings.Contains(body, "TRACE") {
results = append(results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: "TRACE method enabled — potential cross-site tracing risk",
})
} else {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d response to TRACE (not echoing)", code),
})
}
// OPTIONS should not reveal sensitive information.
name = "OPTIONS method information"
code, _, hdrs, err := httpDo("OPTIONS", apiURL+"/host/", nil, "")
if err == nil {
allow := hdrs.Get("Allow")
if allow != "" {
results = append(results, TestResult{
Name: name, Result: Info, Severity: SevInfo,
Detail: fmt.Sprintf("%d — Allow: %s", code, allow),
})
} else {
results = append(results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — no Allow header disclosed", code),
})
}
}
return results
}
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"fmt"
"strings"
"time"
)
// RunServicesSuite tests whether internal infrastructure services are
// improperly exposed to the host network.
func RunServicesSuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "Service Exposure"}
timeout := 3 * time.Second
// ── Valkey (port 6379) ──────────────────────────────────────────────
suite.Results = append(suite.Results, testValkeyExposure(cfg.ValkeyAddr, timeout))
// ── PostgreSQL (port 5432) ──────────────────────────────────────────
suite.Results = append(suite.Results, testPostgresExposure(cfg.PostgresAddr, timeout))
// ── RabbitMQ AMQP (port 5672) ───────────────────────────────────────
suite.Results = append(suite.Results, testRabbitMQAMQP(cfg.RabbitMQAddr, timeout))
// ── RabbitMQ Management UI (port 15672) ─────────────────────────────
suite.Results = append(suite.Results, testRabbitMQManagement(cfg.RabbitMQMgmt, timeout)...)
// ── Engine HTTP port (5174) ─────────────────────────────────────────
suite.Results = append(suite.Results, testEngineHTTP(cfg.EngineHTTPAddr, timeout))
return suite
}
// ────────────────── Valkey ──────────────────
func testValkeyExposure(addr string, timeout time.Duration) TestResult {
name := "Valkey (Redis) exposure on " + addr
// Try to connect and send PING.
resp, err := tcpSendRecv(addr, "PING\r\n", timeout)
if err != nil {
return TestResult{Name: name, Result: Pass,
Detail: "not reachable from host (good)"}
}
if strings.Contains(resp, "+PONG") {
return TestResult{Name: name, Result: Warn, Severity: SevHigh,
Detail: "Valkey accepts unauthenticated PING from host — no AUTH configured"}
}
if strings.Contains(resp, "-NOAUTH") || strings.Contains(resp, "-ERR") {
return TestResult{Name: name, Result: Info, Severity: SevLow,
Detail: "Valkey reachable but requires authentication (acceptable)"}
}
return TestResult{Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("Valkey reachable, response: %s", truncate(resp, 60))}
}
// ────────────────── PostgreSQL ──────────────────
func testPostgresExposure(addr string, timeout time.Duration) TestResult {
name := "PostgreSQL exposure on " + addr
if !tcpProbe(addr, timeout) {
return TestResult{Name: name, Result: Pass,
Detail: "not reachable from host (good)"}
}
// PostgreSQL sends a greeting when a connection is opened. Try to read it.
// If we can connect, the port is exposed.
return TestResult{Name: name, Result: Warn, Severity: SevMedium,
Detail: "PostgreSQL port is reachable from host — consider restricting to internal network only"}
}
// ────────────────── RabbitMQ AMQP ──────────────────
func testRabbitMQAMQP(addr string, timeout time.Duration) TestResult {
name := "RabbitMQ AMQP exposure on " + addr
resp, err := tcpSendRecv(addr, "", timeout)
if err != nil {
return TestResult{Name: name, Result: Pass,
Detail: "not reachable from host (good)"}
}
if strings.Contains(resp, "AMQP") {
return TestResult{Name: name, Result: Warn, Severity: SevMedium,
Detail: "RabbitMQ AMQP port is reachable and responded with AMQP handshake"}
}
return TestResult{Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("RabbitMQ port reachable, response: %s", truncate(resp, 60))}
}
// ────────────────── RabbitMQ Management ──────────────────
func testRabbitMQManagement(addr string, timeout time.Duration) []TestResult {
var results []TestResult
mgmtURL := fmt.Sprintf("http://%s", addr)
name := "RabbitMQ Management UI exposure on " + addr
code, _, _, err := httpGet(mgmtURL+"/api/overview", nil)
if err != nil {
results = append(results, TestResult{Name: name, Result: Pass,
Detail: "management UI not reachable from host (good)"})
return results
}
if code == 401 {
results = append(results, TestResult{Name: name, Result: Info, Severity: SevLow,
Detail: "management UI reachable but requires authentication"})
} else {
results = append(results, TestResult{Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("management UI responded with %d — may be accessible", code)})
}
// Test default credentials.
_ = timeout // already tested reachability above
nameCreds := "RabbitMQ default credentials (guest:guest)"
headers := map[string]string{
"Authorization": "Basic Z3Vlc3Q6Z3Vlc3Q=", // base64("guest:guest")
}
code, _, _, err = httpGet(mgmtURL+"/api/overview", headers)
if err != nil {
results = append(results, TestResult{Name: nameCreds, Result: Pass,
Detail: "request failed"})
return results
}
if code == 200 {
results = append(results, TestResult{Name: nameCreds, Result: Warn, Severity: SevHigh,
Detail: "default credentials accepted — change RabbitMQ admin password"})
} else if code == 401 {
results = append(results, TestResult{Name: nameCreds, Result: Pass,
Detail: "default credentials rejected (good)"})
} else {
results = append(results, TestResult{Name: nameCreds, Result: Info, Severity: SevLow,
Detail: fmt.Sprintf("unexpected status %d", code)})
}
return results
}
// ────────────────── Engine HTTP ──────────────────
func testEngineHTTP(addr string, timeout time.Duration) TestResult {
name := "Engine HTTP port exposure on " + addr
url := fmt.Sprintf("http://%s/health", addr)
code, _, _, err := httpGet(url, nil)
if err != nil {
return TestResult{Name: name, Result: Info, Severity: SevInfo,
Detail: "engine HTTP port not reachable from host"}
}
if code == 200 {
return TestResult{Name: name, Result: Info, Severity: SevLow,
Detail: "engine health endpoint reachable (expected for monitoring)"}
}
return TestResult{Name: name, Result: Info, Severity: SevLow,
Detail: fmt.Sprintf("engine HTTP reachable, status %d", code)}
}
+243
View File
@@ -0,0 +1,243 @@
package main
import (
"fmt"
"strings"
)
// RunTRPCSuite tests authentication on tRPC endpoints (Next.js, port 3000).
// tRPC endpoints are called as plain HTTP — queries use GET, mutations use POST.
// Format: /api/trpc/{router.procedure}?input=<url-encoded-json> for queries
// /api/trpc/{router.procedure} with JSON body for mutations
func RunTRPCSuite(cfg *Config) SuiteResult {
suite := SuiteResult{Name: "tRPC Authentication"}
base := cfg.UIURL
// ── Phase 0: Reachability ───────────────────────────────────────────
code, _, _, err := httpGet(base, nil)
if err != nil || code == 0 {
suite.Results = append(suite.Results, TestResult{
Name: "tRPC reachability check",
Result: Skip,
Severity: SevCritical,
Detail: fmt.Sprintf("Cannot reach %s: %v", base, err),
})
return suite
}
// ── Phase 1: Protected procedures WITHOUT session cookie ────────────
// These should all return UNAUTHORIZED when called without a valid session.
protectedProcedures := []struct {
router string
procedure string
isQuery bool // true = GET query, false = POST mutation
input string
}{
{"apikeys", "listKeys", true, ""},
{"apikeys", "createKey", false, `{"json":{"label":"test"}}`},
{"terminal", "executeCommand", false, `{"json":{"command":"whoami"}}`},
{"terminal", "getHistory", true, ""},
{"agent", "listAgentsWithHosts", true, ""},
{"user", "getProfile", true, ""},
{"agentScan", "getAgentScanStatus", true, `{"json":{"scanId":"test"}}`},
}
for _, p := range protectedProcedures {
name := fmt.Sprintf("Protected %s.%s without session", p.router, p.procedure)
endpoint := fmt.Sprintf("%s/api/trpc/%s.%s", base, p.router, p.procedure)
var code int
var body string
var reqErr error
if p.isQuery {
url := endpoint
if p.input != "" {
url += "?input=" + p.input
}
code, body, _, reqErr = httpGet(url, map[string]string{
"Content-Type": "application/json",
})
} else {
payload := p.input
if payload == "" {
payload = "{}"
}
code, body, _, reqErr = httpPost(endpoint, nil, payload)
}
if reqErr != nil {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", reqErr),
})
continue
}
if isUnauthorized(code, body) {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — correctly denied", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("expected UNAUTHORIZED, got %d", code),
})
}
}
// ── Phase 2: Protected procedures with INVALID session cookie ───────
fakeCookie := "next-auth.session-token=fake-invalid-session-value-12345"
for _, p := range protectedProcedures[:3] { // test a representative sample
name := fmt.Sprintf("Protected %s.%s with invalid session", p.router, p.procedure)
endpoint := fmt.Sprintf("%s/api/trpc/%s.%s", base, p.router, p.procedure)
headers := map[string]string{
"Cookie": fakeCookie,
"Content-Type": "application/json",
}
var code int
var body string
var reqErr error
if p.isQuery {
code, body, _, reqErr = httpGet(endpoint, headers)
} else {
code, body, _, reqErr = httpPost(endpoint, headers, p.input)
}
if reqErr != nil {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Fail, Severity: SevHigh,
Detail: fmt.Sprintf("request error: %v", reqErr),
})
continue
}
if isUnauthorized(code, body) {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — invalid session correctly rejected", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Fail, Severity: SevCritical,
Detail: fmt.Sprintf("expected UNAUTHORIZED, got %d — invalid session accepted!", code),
})
}
}
// ── Phase 3: Public procedures accessibility (informational) ────────
// These are expected to work without authentication — report for awareness.
publicProcedures := []struct {
router string
procedure string
input string
}{
{"host", "getHostList", ""},
{"vulnerability", "getAllVulnerabilities", ""},
{"scanner", "getLatestScan", ""},
{"templates", "getTemplates", ""},
{"statistics", "getVulnerabilityTrends", ""},
{"events", "getEvents", ""},
}
for _, p := range publicProcedures {
name := fmt.Sprintf("Public %s.%s accessible without session", p.router, p.procedure)
endpoint := fmt.Sprintf("%s/api/trpc/%s.%s", base, p.router, p.procedure)
url := endpoint
if p.input != "" {
url += "?input=" + p.input
}
code, _, _, reqErr := httpGet(url, map[string]string{
"Content-Type": "application/json",
})
if reqErr != nil {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Warn, Severity: SevLow,
Detail: fmt.Sprintf("request error: %v", reqErr),
})
continue
}
if code >= 200 && code < 500 && !isUnauthorized(code, "") {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Info, Severity: SevInfo,
Detail: fmt.Sprintf("%d — public endpoint (no auth required)", code),
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Pass,
Detail: fmt.Sprintf("%d — unexpectedly protected (good!)", code),
})
}
}
// ── Phase 4: Public WRITE procedures audit ──────────────────────────
// Flag public mutations that perform write/destructive operations.
publicWriteProcedures := []struct {
router string
procedure string
risk string
}{
{"scanner", "startScan", "Can initiate network scans without auth"},
{"scanner", "cancelScan", "Can cancel running scans without auth"},
{"scanner", "forceStopScan", "Can force-stop scans without auth"},
{"scanner", "resetScanState", "Can reset scan state without auth"},
{"templates", "createTemplate", "Can create templates without auth"},
{"templates", "deleteTemplate", "Can delete templates without auth"},
{"scripts", "createScript", "Can create scripts without auth"},
{"scripts", "deleteScript", "Can delete scripts without auth"},
{"store", "setValue", "Can write to Valkey store without auth"},
{"queue", "sendMsg", "Can send messages to RabbitMQ without auth"},
{"host", "createHost", "Can create host records without auth"},
{"repositories", "add", "Can add template repositories without auth"},
{"repositories", "delete", "Can delete repositories without auth"},
{"agentTemplates", "uploadTemplate", "Can upload agent templates without auth"},
{"agentTemplates", "deleteTemplate", "Can delete agent templates without auth"},
{"agentTemplates", "deployTemplate", "Can deploy templates to agents without auth"},
}
for _, p := range publicWriteProcedures {
name := fmt.Sprintf("Public write: %s.%s", p.router, p.procedure)
endpoint := fmt.Sprintf("%s/api/trpc/%s.%s", base, p.router, p.procedure)
// Send an empty/minimal mutation body — we just want to check if auth is enforced.
code, body, _, reqErr := httpPost(endpoint, nil, `{}`)
if reqErr != nil {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("request error: %v", reqErr),
})
continue
}
if isUnauthorized(code, body) {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Pass,
Detail: "requires authentication",
})
} else {
suite.Results = append(suite.Results, TestResult{
Name: name, Result: Warn, Severity: SevMedium,
Detail: fmt.Sprintf("%d — %s", code, p.risk),
})
}
}
return suite
}
// isUnauthorized checks if a response indicates an authentication failure.
// tRPC returns 401 for protectedProcedure, or a JSON error with "UNAUTHORIZED".
func isUnauthorized(code int, body string) bool {
if code == 401 || code == 403 {
return true
}
lower := strings.ToLower(body)
return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "unauthenticated")
}