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,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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user