161ef94b4f
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
356 lines
13 KiB
Go
356 lines
13 KiB
Go
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] + "..."
|
|
}
|