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
+336
View File
@@ -0,0 +1,336 @@
# SiriusScan Container Testing Infrastructure
This directory contains comprehensive testing infrastructure for SiriusScan's Docker containerized microservices architecture.
## Overview
The testing infrastructure provides automated validation of:
- **Container Builds**: Individual Docker image builds
- **Service Health**: Health checks and basic functionality
- **Integration**: End-to-end service communication
- **Configuration**: Docker Compose validation
## Quick Start
```bash
# Run all tests
make test-all
# Run specific test types
make test-build # Test container builds
make test-health # Test service health
make test-integration # Test service integration
# Start development environment
make dev
# Start production environment
make prod
```
## Test Scripts
### 1. Build Tests (`test-build.sh`)
Tests individual container builds and Docker Compose configurations.
**What it tests:**
- Base, development, and production Docker Compose configurations
- sirius-ui production and development builds
- sirius-api runner and development builds
- sirius-engine runtime and development builds
**Usage:**
```bash
./testing/scripts/test-build.sh
```
**Output:**
- ✅ PASSED: Configuration validations
- ❌ FAILED: Build failures with detailed logs
- 📊 Summary report with pass/fail counts
### 2. Health Tests (`test-health.sh`)
Tests service health checks and basic functionality.
**What it tests:**
- Container startup and readiness
- Health check endpoints
- Database connectivity (PostgreSQL, Valkey)
- Message queue connectivity (RabbitMQ)
- Service-to-service communication
- Port accessibility
**Usage:**
```bash
# Test base environment
./testing/scripts/test-health.sh
# Test development environment
./testing/scripts/test-health.sh dev
# Test production environment
./testing/scripts/test-health.sh prod
```
**Output:**
- 🏥 Health check results for each service
- 🔗 Service communication validation
- 📊 Comprehensive health report
### 3. Integration Tests (`test-integration.sh`)
Tests end-to-end service integration and functionality.
**What it tests:**
- API endpoint functionality
- Database operations
- Service communication flows
- Authentication flows
- Scanning functionality
- Error handling
**Usage:**
```bash
# Test base environment
./testing/scripts/test-integration.sh
# Test development environment
./testing/scripts/test-integration.sh dev
# Test production environment
./testing/scripts/test-integration.sh prod
```
**Output:**
- 🔗 API endpoint validation
- 🗄️ Database operation tests
- 🔐 Authentication flow tests
- 🔍 Scanning functionality tests
- ⚠️ Error handling validation
## Configuration Files
### Docker Compose Configurations
- **`docker-compose.yaml`**: Base production configuration with health checks
- **`docker-compose.dev.yaml`**: Development overrides with hot reloading and volume mounts
### Test Configuration
- **`testing/scripts/`**: Test execution scripts
- **`testing/logs/`**: Test execution logs (auto-created)
- **`Makefile`**: Easy command interface
## Test Results
### Log Files
All test executions generate detailed logs in `testing/logs/`:
- `build_test_YYYYMMDD_HHMMSS.log`
- `health_test_YYYYMMDD_HHMMSS.log`
- `integration_test_YYYYMMDD_HHMMSS.log`
### Exit Codes
- **0**: All tests passed
- **1**: One or more tests failed
### Test Reports
Each test script provides:
- Real-time colored output
- Detailed command execution logs
- Pass/fail summary with counts
- Specific failure details
## Development Workflow
### 1. Pre-commit Testing
```bash
# Quick validation before committing
make ci-test
```
### 2. Full Validation
```bash
# Complete validation before deployment
make validate
```
### 3. Development Testing
```bash
# Start development environment
make dev
# Run health tests
make test-health
# Check logs
make logs
```
### 4. Production Testing
```bash
# Test production configuration
make build-prod
# Run production health tests
make test-health prod
```
## Troubleshooting
### Common Issues
1. **Build Failures**
- Check Docker daemon is running
- Verify sufficient disk space
- Check network connectivity for git clones
2. **Health Check Failures**
- Ensure all services are running
- Check port conflicts
- Verify service dependencies
3. **Integration Test Failures**
- Check database connectivity
- Verify API endpoints are responding
- Check service communication
### Debug Commands
```bash
# Check service status
make status
# View service logs
make logs
# Check individual container logs
docker compose logs sirius-ui
docker compose logs sirius-api
docker compose logs sirius-engine
```
### Cleanup
```bash
# Clean up everything
make clean
# Stop services only
make stop
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: Container Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Container Tests
run: make ci-test
```
### Jenkins Pipeline Example
```groovy
pipeline {
agent any
stages {
stage('Build Test') {
steps {
sh 'make test-build'
}
}
stage('Health Test') {
steps {
sh 'make test-health'
}
}
stage('Integration Test') {
steps {
sh 'make test-integration'
}
}
}
}
```
## Best Practices
### 1. Test Execution Order
1. **Build Tests**: Validate configurations and builds
2. **Health Tests**: Verify basic functionality
3. **Integration Tests**: Test end-to-end flows
### 2. Environment Testing
- Test all three environments (base, dev, prod)
- Use appropriate environment for each test type
- Clean up between test runs
### 3. Log Management
- Review logs for detailed failure information
- Archive logs for historical analysis
- Use log rotation for long-running tests
### 4. Performance Considerations
- Tests run in parallel where possible
- Use appropriate timeouts for service startup
- Clean up test containers and images
## Contributing
### Adding New Tests
1. Create test function in appropriate script
2. Add test to main execution flow
3. Update documentation
4. Test the new test function
### Modifying Existing Tests
1. Update test logic
2. Verify test still passes
3. Update documentation if needed
4. Test in all environments
## Support
For issues with the testing infrastructure:
1. Check the troubleshooting section
2. Review test logs for specific errors
3. Verify Docker and Docker Compose installation
4. Check service dependencies and configuration
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
set -euo pipefail
# Local CI Integration Test
# Mirrors the hardened ci.yml integration test step exactly.
# Uses locally-available images (default: local-prod tag).
#
# Usage:
# ./testing/ci-local-integration-test.sh # uses local-prod tag
# ./testing/ci-local-integration-test.sh latest # uses latest tag
# IMAGE_TAG=v1.0.0 ./testing/ci-local-integration-test.sh # uses v1.0.0 tag
IMAGE_TAG="${1:-${IMAGE_TAG:-local-prod}}"
REGISTRY="ghcr.io"
IMAGE_NAMESPACE="siriusscan"
COMPOSE_FILE="docker-compose.ci-local-test.yml"
PROJECT_NAME="sirius-ci-local"
# Portable timeout — macOS lacks coreutils `timeout`
if command -v timeout &>/dev/null; then
TIMEOUT_CMD="timeout"
elif command -v gtimeout &>/dev/null; then
TIMEOUT_CMD="gtimeout"
else
TIMEOUT_CMD=""
fi
run_with_timeout() {
local secs="$1"; shift
if [ -n "$TIMEOUT_CMD" ]; then
"$TIMEOUT_CMD" "$secs" "$@"
else
local pid
"$@" &
pid=$!
( sleep "$secs" && kill "$pid" 2>/dev/null ) &
local watcher=$!
wait "$pid" 2>/dev/null
local rc=$?
kill "$watcher" 2>/dev/null; wait "$watcher" 2>/dev/null
return $rc
fi
}
echo "============================================="
echo " Local CI Integration Test"
echo " Image tag: $IMAGE_TAG"
echo "============================================="
cleanup() {
echo ""
echo "🧹 Cleaning up..."
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" down --volumes --remove-orphans 2>/dev/null || true
rm -f "$COMPOSE_FILE"
}
trap cleanup EXIT
# --- Generate compose file (same as ci.yml heredoc) ---
cat > "$COMPOSE_FILE" << EOF
name: ${PROJECT_NAME}
services:
sirius-postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sirius_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
sirius-rabbitmq:
image: rabbitmq:3-management
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 5s
timeout: 5s
retries: 10
sirius-valkey:
image: valkey/valkey:latest
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
sirius-ui:
image: ${REGISTRY}/${IMAGE_NAMESPACE}/sirius-ui:${IMAGE_TAG}
environment:
- NODE_ENV=production
- SKIP_ENV_VALIDATION=1
- DATABASE_URL=postgresql://postgres:postgres@sirius-postgres:5432/sirius_test
- NEXTAUTH_SECRET=test-secret
- INITIAL_ADMIN_PASSWORD=test-admin-password
- NEXTAUTH_URL=http://localhost:3000
- SIRIUS_API_URL=http://sirius-api:9001
- NEXT_PUBLIC_SIRIUS_API_URL=http://localhost:9001
- SIRIUS_API_KEY=ci-placeholder-api-key
depends_on:
sirius-postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 -O /dev/null http://127.0.0.1:3000/"]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
sirius-api:
image: ${REGISTRY}/${IMAGE_NAMESPACE}/sirius-api:${IMAGE_TAG}
environment:
- GO_ENV=production
- API_PORT=9001
- POSTGRES_HOST=sirius-postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=sirius_test
- POSTGRES_PORT=5432
- VALKEY_HOST=sirius-valkey
- VALKEY_PORT=6379
- RABBITMQ_URL=amqp://guest:guest@sirius-rabbitmq:5672/
- LOG_LEVEL=info
- SIRIUS_API_KEY=ci-placeholder-api-key
depends_on:
sirius-postgres:
condition: service_healthy
sirius-rabbitmq:
condition: service_healthy
sirius-valkey:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 -O /dev/null http://127.0.0.1:9001/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
sirius-engine:
image: ${REGISTRY}/${IMAGE_NAMESPACE}/sirius-engine:${IMAGE_TAG}
environment:
- GO_ENV=production
- ENGINE_MAIN_PORT=5174
- GRPC_AGENT_PORT=50051
- POSTGRES_HOST=sirius-postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=sirius_test
- POSTGRES_PORT=5432
- VALKEY_HOST=sirius-valkey
- VALKEY_PORT=6379
- RABBITMQ_URL=amqp://guest:guest@sirius-rabbitmq:5672/
- LOG_LEVEL=info
- SIRIUS_API_KEY=ci-placeholder-api-key
- SIRIUS_API_URL=http://sirius-api:9001
depends_on:
sirius-postgres:
condition: service_healthy
sirius-rabbitmq:
condition: service_healthy
sirius-valkey:
condition: service_healthy
healthcheck:
test: ["CMD", "bash", "-c", "echo > /dev/tcp/127.0.0.1/50051"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
EOF
echo "📄 Generated $COMPOSE_FILE"
# --- 1. Infrastructure ---
echo ""
echo "🔧 Starting infrastructure services..."
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d sirius-postgres sirius-rabbitmq sirius-valkey
echo "⏳ Waiting for infrastructure to become healthy (timeout: 90s)..."
INFRA_START=$SECONDS
while true; do
HEALTHY_COUNT=$(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps --format json 2>/dev/null | \
python3 -c "
import sys, json
svcs = [json.loads(l) for l in sys.stdin if l.strip()]
infra = [s for s in svcs if s['Service'] in ('sirius-postgres','sirius-rabbitmq','sirius-valkey')]
healthy = [s for s in infra if s.get('Health','') == 'healthy']
print(len(healthy))
" 2>/dev/null || echo "0")
if [ "$HEALTHY_COUNT" -eq 3 ] 2>/dev/null; then break; fi
if [ $((SECONDS - INFRA_START)) -ge 90 ]; then
echo "❌ TIMEOUT: Infrastructure services did not become healthy within 90s"
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
exit 1
fi
sleep 3
done
echo "✅ Infrastructure healthy"
# --- 2. Application services ---
SERVICES_TO_TEST="sirius-api sirius-engine sirius-ui"
echo ""
echo "🚀 Starting application services: $SERVICES_TO_TEST"
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d $SERVICES_TO_TEST
for SVC in $SERVICES_TO_TEST; do
case $SVC in
sirius-api) HEALTH_CMD="wget -q --tries=1 -O /dev/null http://127.0.0.1:9001/health" ;;
sirius-engine) HEALTH_CMD="bash -c 'echo > /dev/tcp/127.0.0.1/50051'" ;;
sirius-ui) HEALTH_CMD="wget -q --tries=1 -O /dev/null http://127.0.0.1:3000/" ;;
*) continue ;;
esac
echo "⏳ Waiting for $SVC (timeout: 180s)..."
SVC_START=$SECONDS
SVC_OK=0
while true; do
if docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T "$SVC" \
sh -c "$HEALTH_CMD" 2>/dev/null; then
SVC_OK=1; break
fi
if [ $((SECONDS - SVC_START)) -ge 180 ]; then break; fi
sleep 5
done
if [ "$SVC_OK" -eq 0 ]; then
echo "❌ TIMEOUT: $SVC never became healthy"
echo ""
echo "=== Container status ==="
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
echo ""
echo "=== $SVC logs (last 50 lines) ==="
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" logs --tail=50 "$SVC"
echo ""
echo "=== $SVC container inspect ==="
docker inspect "${PROJECT_NAME}-${SVC}-1" 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
if data:
s = data[0].get('State', {})
print(f' Status: {s.get(\"Status\")}')
print(f' Running: {s.get(\"Running\")}')
print(f' ExitCode: {s.get(\"ExitCode\")}')
h = s.get('Health', {})
if h:
print(f' Health: {h.get(\"Status\")}')
for log in (h.get('Log') or [])[-3:]:
print(f' [{log.get(\"ExitCode\")}] {log.get(\"Output\",\"\")[:200]}')
" 2>/dev/null || true
exit 1
fi
echo "$SVC is healthy"
done
# --- 3. Final assertions ---
echo ""
echo "📊 Service status:"
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
echo ""
echo "🏥 Running final health assertions..."
FAILED=0
for SVC in $SERVICES_TO_TEST; do
case $SVC in
sirius-api) HEALTH_CMD="wget -q --tries=1 -O /dev/null http://127.0.0.1:9001/health" ;;
sirius-engine) HEALTH_CMD="bash -c 'echo > /dev/tcp/127.0.0.1/50051'" ;;
sirius-ui) HEALTH_CMD="wget -q --tries=1 -O /dev/null http://127.0.0.1:3000/" ;;
*) continue ;;
esac
if docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T "$SVC" \
sh -c "$HEALTH_CMD" 2>/dev/null; then
echo "$SVC passed"
else
echo "$SVC FAILED"
FAILED=1
fi
done
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "❌ INTEGRATION TEST FAILED"
echo ""
echo "=== Failure diagnostics ==="
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
echo ""
for SVC in sirius-engine sirius-api sirius-ui; do
echo "--- $SVC logs (last 30 lines) ---"
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" logs --tail=30 "$SVC" 2>/dev/null || true
echo ""
done
exit 1
fi
echo ""
echo "============================================="
echo " ✅ LOCAL CI INTEGRATION TEST PASSED"
echo "============================================="
echo ""
echo "All services healthy. This matches what ci.yml will do in GitHub Actions."
echo "Safe to push and trigger the real CI pipeline."
+215
View File
@@ -0,0 +1,215 @@
# SiriusScan Docker Testing Makefile
# Provides easy commands for testing and development
.PHONY: help build test test-build test-health test-integration test-runtime-contract test-public-ghcr test-release-gates test-all test-security validate-ci-overhaul clean logs lint-docs lint-docs-quick lint-agents lint-agents-quick lint-agent-index regenerate-agents regenerate-agent check-agent-sync
# Default target
help:
@echo "SiriusScan Docker Testing Commands"
@echo "=================================="
@echo ""
@echo "Build Commands:"
@echo " build-base Build base configuration"
@echo " build-dev Build development configuration"
@echo " build-prod Build production configuration"
@echo " build-all Build all configurations"
@echo ""
@echo "Test Commands:"
@echo " test-build Test individual container builds"
@echo " test-health Test service health checks"
@echo " test-integration Test service integration"
@echo " test-runtime-contract Verify runtime auth/binary contracts"
@echo " test-public-ghcr Verify anonymous GHCR access for compose images"
@echo " test-release-gates Run release-blocking validation checks"
@echo " test-security Run security/auth penetration tests"
@echo " test-all Run all tests (build, health, integration, runtime auth contract)"
@echo " validate-ci-overhaul Validate base images and CI pipeline changes"
@echo ""
@echo "Development Commands:"
@echo " dev Start development environment"
@echo " prod Start production environment"
@echo " stop Stop all services"
@echo " restart Restart all services"
@echo ""
@echo "Utility Commands:"
@echo " clean Clean up containers and images"
@echo " logs Show logs for all services"
@echo " status Show status of all services"
@echo ""
@echo "Documentation Commands:"
@echo " lint-docs Run full documentation linting"
@echo " lint-docs-quick Run quick documentation checks"
@echo " lint-index Check documentation index completeness"
@echo ""
@echo "Agent Identity Commands:"
@echo " regenerate-agents Regenerate all agent identities"
@echo " regenerate-agent Regenerate specific agent (PRODUCT=name)"
@echo " check-agent-sync Check if agent identities need regeneration"
@echo " lint-agents Run full agent identity linting"
@echo " lint-agents-quick Run quick agent checks"
@echo " lint-agent-index Check agent identity index completeness"
# Build commands
build-base:
@echo "🔨 Building base configuration..."
docker compose config --quiet
@echo "✅ Base configuration is valid"
build-dev:
@echo "🔨 Building development configuration..."
docker compose -f docker-compose.yaml -f docker-compose.dev.yaml config --quiet
@echo "✅ Development configuration is valid"
build-prod:
@echo "🔨 Building production configuration..."
docker compose config --quiet
@echo "✅ Production configuration is valid (base docker-compose.yaml)"
build-all: build-base build-dev build-prod
@echo "🎉 All configurations are valid!"
# Test commands
test-build:
@echo "🧪 Running build tests..."
./test-build.sh
test-health:
@echo "🏥 Running health tests..."
./test-health.sh
test-integration:
@echo "🔗 Running integration tests..."
./test-integration.sh
test-runtime-contract:
@echo "🧷 Verifying runtime auth/binary contracts..."
@cd ../.. && \
POSTGRES_PASSWORD=$${POSTGRES_PASSWORD:-test-postgres-password} \
NEXTAUTH_SECRET=$${NEXTAUTH_SECRET:-test-nextauth-secret} \
INITIAL_ADMIN_PASSWORD=$${INITIAL_ADMIN_PASSWORD:-test-admin-password} \
DATABASE_URL=$${DATABASE_URL:-postgresql://postgres:test-postgres-password@sirius-postgres:5432/sirius} \
mkdir -p secrets && \
printf '%s\n' "$${SIRIUS_INTERNAL_API_KEY_TEST_VALUE:-test-api-key}" > secrets/sirius_api_key.txt && \
docker compose up -d && \
bash scripts/verify-runtime-auth-contract.sh && \
docker compose down
test-public-ghcr:
@echo "🌍 Verifying anonymous GHCR access for compose-rendered images..."
@cd ../.. && bash scripts/verify-ghcr-public-access.sh "$${IMAGE_TAG:-latest}"
test-release-gates: test-build test-integration test-runtime-contract
@echo "✅ Release-gating contract checks passed!"
test-security:
@echo "🔒 Running security penetration tests..."
cd ../security && go run .
@echo "✅ Security tests completed!"
test-all: test-build test-health test-integration test-runtime-contract
@echo "🎉 All tests completed (including runtime auth contract)!"
# Development commands
dev:
@echo "🚀 Starting development environment..."
docker compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d
@echo "✅ Development environment started"
@echo "🌐 UI: http://localhost:3000"
@echo "🔌 API: http://localhost:9001"
@echo "⚙️ Engine: http://localhost:5174"
prod:
@echo "🚀 Starting production environment..."
docker compose up -d
@echo "✅ Production environment started (base docker-compose.yaml is production-ready)"
@echo "🌐 UI: http://localhost:3000"
@echo "🔌 API: http://localhost:9001"
@echo "⚙️ Engine: http://localhost:5174"
stop:
@echo "🛑 Stopping all services..."
docker compose down
@echo "✅ All services stopped"
restart: stop dev
@echo "🔄 Services restarted"
# Utility commands
clean:
@echo "🧹 Cleaning up containers and images..."
docker compose down --volumes --remove-orphans
docker system prune -f
@echo "✅ Cleanup completed"
logs:
@echo "📋 Showing logs for all services..."
docker compose logs -f
status:
@echo "📊 Service Status:"
docker compose ps
# Quick test for CI/CD
ci-test: build-all test-build
@echo "✅ CI/CD tests passed!"
# Validate CI/CD overhaul (base images + all app containers)
validate-ci-overhaul:
@echo "🔬 Validating CI/CD overhaul (base images, app builds, compose config)..."
../../scripts/validate-ci-overhaul.sh
@echo "✅ CI/CD overhaul validation passed!"
# Full validation
validate: build-all test-all
@echo "🎉 Full validation completed successfully!"
# Documentation linting commands
lint-docs:
@echo "📚 Running full documentation linting..."
../../scripts/documentation/lint-docs.sh
@echo "✅ Documentation linting completed!"
lint-docs-quick:
@echo "📚 Running quick documentation checks..."
../../scripts/documentation/lint-quick.sh
@echo "✅ Quick documentation checks completed!"
lint-index:
@echo "📚 Running index completeness linting..."
../../scripts/documentation/lint-index.sh
@echo "✅ Index linting completed!"
# Agent identity linting commands
lint-agents:
@echo "🤖 Running full agent identity linting..."
cd ../../scripts/agent-identities && npm run validate
@echo "✅ Agent identity linting completed!"
lint-agents-quick:
@echo "🤖 Running quick agent checks..."
cd ../../scripts/agent-identities && npm run validate
@echo "✅ Quick agent checks completed!"
lint-agent-index:
@echo "🤖 Checking agent identity index..."
cd ../../scripts/agent-identities && npm run check-sync
@echo "✅ Agent index linting completed!"
# Agent identity generation commands
regenerate-agents:
@echo "🔄 Regenerating agent identities..."
cd ../../scripts/agent-identities && npm run generate -- --all
@echo "✅ Agent identities regenerated!"
regenerate-agent:
@echo "🔄 Regenerating $(PRODUCT) agent identity..."
cd ../../scripts/agent-identities && npm run generate -- --product=$(PRODUCT)
@echo "$(PRODUCT) agent identity regenerated!"
check-agent-sync:
@echo "🔍 Checking agent identity sync status..."
cd ../../scripts/agent-identities && npm run check-sync
# Full validation including documentation and agents
validate-all: build-all test-all lint-docs lint-agents
@echo "🎉 Complete validation including documentation and agent identities completed successfully!"
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
# SiriusScan Container Build Test Script
# Tests individual container builds and reports results
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Check if we're in container-testing directory or testing directory
if [[ "$SCRIPT_DIR" == *"/container-testing" ]]; then
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
else
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
fi
LOG_DIR="$PROJECT_ROOT/testing/logs"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
LOG_FILE="$LOG_DIR/build_test_$TIMESTAMP.log"
# Required compose variables for strict startup contract.
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-test-postgres-password}"
export NEXTAUTH_SECRET="${NEXTAUTH_SECRET:-test-nextauth-secret}"
export INITIAL_ADMIN_PASSWORD="${INITIAL_ADMIN_PASSWORD:-test-admin-password}"
export DATABASE_URL="${DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD:-test-postgres-password}@sirius-postgres:5432/sirius}"
INTERNAL_API_KEY_TEST_VALUE="${SIRIUS_INTERNAL_API_KEY_TEST_VALUE:-test-api-key}"
# Base compose mounts ./secrets/sirius_api_key.txt — ensure a placeholder exists for `docker compose config` in CI/local runs.
if [ ! -f "$PROJECT_ROOT/secrets/sirius_api_key.txt" ]; then
mkdir -p "$PROJECT_ROOT/secrets"
printf '%s\n' "$INTERNAL_API_KEY_TEST_VALUE" > "$PROJECT_ROOT/secrets/sirius_api_key.txt"
fi
# Create logs directory
mkdir -p "$LOG_DIR"
# Logging function
log() {
echo -e "$1" | tee -a "$LOG_FILE"
}
# Test result tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Test function
run_test() {
local test_name="$1"
local test_command="$2"
local expected_exit_code="${3:-0}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
log "${BLUE}🧪 Testing: $test_name${NC}"
log "Command: $test_command"
if eval "$test_command" >> "$LOG_FILE" 2>&1; then
if [ $? -eq $expected_exit_code ]; then
log "${GREEN}✅ PASSED: $test_name${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
return 0
else
log "${RED}❌ FAILED: $test_name (unexpected exit code: $?)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
else
log "${RED}❌ FAILED: $test_name (command failed)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
}
# Main test execution
main() {
log "${BLUE}🚀 Starting SiriusScan Container Build Tests${NC}"
log "Timestamp: $(date)"
log "Project Root: $PROJECT_ROOT"
log "Log File: $LOG_FILE"
log ""
cd "$PROJECT_ROOT"
# Test 1: Base Docker Compose Configuration
run_test "Base Docker Compose Config" "docker compose config --quiet"
# Test 2: Development Docker Compose Configuration
run_test "Development Docker Compose Config" "docker compose -f docker-compose.yaml -f docker-compose.dev.yaml config --quiet"
# Test 3: Source Build Docker Compose Configuration
run_test "Source Build Docker Compose Config" "docker compose -f docker-compose.yaml -f docker-compose.build.yaml config --quiet"
# Test 4: sirius-postgres entrypoint contract
run_test "sirius-postgres Entrypoint Contract" "docker build -t sirius-postgres:test ./sirius-postgres/ && docker run --rm --entrypoint /bin/sh sirius-postgres:test -lc 'test -x /usr/local/bin/start-with-monitor.sh && /bin/sh -n /usr/local/bin/start-with-monitor.sh'"
# Test 5: sirius-ui Production Build (base docker-compose.yaml is production-ready)
run_test "sirius-ui Production Build" "docker build -t sirius-ui:test ./sirius-ui/ --target production"
# Test 6: sirius-api Runner Build
run_test "sirius-api Runner Build" "docker build -t sirius-api:test ./sirius-api/ --target runner"
# Test 7: logging clients include API key auth on protected logs routes (file/env loader + engine patch)
run_test "Logging API Key Contract" "python3 -c \"from pathlib import Path; api_handler = Path('sirius-api/handlers/log_handler.go').read_text(); engine_dockerfile = Path('sirius-engine/Dockerfile').read_text(); assert 'infraauth.LoadSiriusAPIKey' in api_handler; assert 'GO_API_COMMIT_SHA=v0.0.18' in engine_dockerfile; assert 'git apply' not in engine_dockerfile\""
# Test 7b: internal API key _FILE + env loader (Go)
run_test "Internal API Key Loader Unit Tests" "( cd sirius-api && go test ./internal/infraauth/... -count=1 )"
# Test 8: sirius-engine Development Build
run_test "sirius-engine Development Build" "docker build -t sirius-engine:dev ./sirius-engine/ --target development"
# Test 9: sirius-engine Runtime Build
run_test "sirius-engine Runtime Build" "docker build -t sirius-engine:runtime ./sirius-engine/ --target runtime"
# Test 10: sirius-engine runtime startup contract binaries
run_test "sirius-engine Runtime Binary Contract" "docker run --rm --entrypoint /bin/bash sirius-engine:runtime -lc 'command -v bash >/dev/null && command -v curl >/dev/null && command -v psql >/dev/null && command -v pkill >/dev/null && command -v nmap >/dev/null && command -v rustscan >/dev/null && command -v pwsh >/dev/null'"
# Test 11: sirius-engine entrypoint syntax contract
run_test "sirius-engine Entrypoint Syntax Contract" "docker run --rm --entrypoint /bin/bash sirius-engine:runtime -lc '/bin/bash -n /start-enhanced.sh && grep -q \"validate_required_binary \\\"psql\\\"\" /start-enhanced.sh'"
# Test 11b: sirius-engine internal API key contract is file-backed only
run_test "sirius-engine Internal API Key File Contract" "python3 -c \"from pathlib import Path; data = Path('sirius-engine/start-enhanced.sh').read_text(); assert 'SIRIUS_API_KEY_FILE is required' in data; assert 'SIRIUS_API_KEY differs from mounted sirius_api_key secret' not in data\""
# Test 12: sirius-ui Development Build
run_test "sirius-ui Development Build" "docker build -t sirius-ui:dev ./sirius-ui/ --target development"
# Test 12b: sirius-ui startup scripts require readable secret files
run_test "sirius-ui Internal API Key File Contract" "python3 -c \"from pathlib import Path; prod = Path('sirius-ui/start-prod.sh').read_text(); dev = Path('sirius-ui/start-dev.sh').read_text(); assert 'require_readable_file \\\"SIRIUS_API_KEY_FILE\\\"' in prod; assert 'require_readable_file \\\"SIRIUS_API_KEY_FILE\\\"' in dev; assert 'require_env \\\"SIRIUS_API_KEY\\\"' not in prod + dev\""
# Test 13: sirius-api Development Build
run_test "sirius-api Development Build" "docker build -t sirius-api:dev ./sirius-api/ --target development"
# Cleanup test images
log "${YELLOW}🧹 Cleaning up test images...${NC}"
docker rmi sirius-postgres:test sirius-ui:test sirius-api:test sirius-engine:dev sirius-engine:runtime sirius-ui:dev sirius-api:dev 2>/dev/null || true
# Summary
log ""
log "${BLUE}📊 Test Summary${NC}"
log "Total Tests: $TOTAL_TESTS"
log "Passed: ${GREEN}$PASSED_TESTS${NC}"
log "Failed: ${RED}$FAILED_TESTS${NC}"
if [ $FAILED_TESTS -eq 0 ]; then
log "${GREEN}🎉 All tests passed!${NC}"
exit 0
else
log "${RED}💥 Some tests failed. Check the log file: $LOG_FILE${NC}"
exit 1
fi
}
# Run main function
main "$@"
+228
View File
@@ -0,0 +1,228 @@
#!/bin/bash
# SiriusScan Container Health Test Script
# Tests service health checks and basic functionality
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Check if we're in container-testing directory or testing directory
if [[ "$SCRIPT_DIR" == *"/container-testing" ]]; then
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
else
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
fi
LOG_DIR="$PROJECT_ROOT/testing/logs"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
LOG_FILE="$LOG_DIR/health_test_$TIMESTAMP.log"
# Required compose variables for strict startup contract.
export SIRIUS_API_KEY="${SIRIUS_API_KEY:-test-api-key}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-test-postgres-password}"
export NEXTAUTH_SECRET="${NEXTAUTH_SECRET:-test-nextauth-secret}"
export INITIAL_ADMIN_PASSWORD="${INITIAL_ADMIN_PASSWORD:-test-admin-password}"
# Environment variables for configuration
TEST_TIMEOUT="${TEST_TIMEOUT:-60}" # Default 60 seconds
TEST_RETRIES="${TEST_RETRIES:-60}" # Default 60 retries (2 seconds each = 120 seconds total)
LOG_LEVEL="${LOG_LEVEL:-info}" # Default info level
# Create logs directory
mkdir -p "$LOG_DIR"
# Logging function
log() {
echo -e "$1" | tee -a "$LOG_FILE"
}
# Test result tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Wait for service to be ready
wait_for_service() {
local service_name="$1"
local port="$2"
local max_attempts=$TEST_RETRIES
local attempt=1
local sleep_interval=2
log "${YELLOW}⏳ Waiting for $service_name to be ready on port $port...${NC}"
log "Configuration: $max_attempts attempts, ${sleep_interval}s interval (total timeout: $((max_attempts * sleep_interval))s)"
while [ $attempt -le $max_attempts ]; do
# Use appropriate health endpoint based on service
local health_url="http://localhost:$port"
if [ "$service_name" = "sirius-api" ]; then
health_url="http://localhost:$port/health"
fi
if curl -s -f "$health_url" > /dev/null 2>&1; then
log "${GREEN}$service_name is ready!${NC}"
return 0
fi
if [ $((attempt % 10)) -eq 0 ] || [ "$LOG_LEVEL" = "debug" ]; then
log "Attempt $attempt/$max_attempts: $service_name not ready yet..."
fi
sleep $sleep_interval
attempt=$((attempt + 1))
done
log "${RED}$service_name failed to start within $((max_attempts * sleep_interval)) seconds${NC}"
log "${YELLOW}💡 Tip: Increase timeout with TEST_RETRIES environment variable (e.g., TEST_RETRIES=120)${NC}"
return 1
}
# Test function
run_test() {
local test_name="$1"
local test_command="$2"
local expected_exit_code="${3:-0}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
log "${BLUE}🧪 Testing: $test_name${NC}"
log "Command: $test_command"
if eval "$test_command" >> "$LOG_FILE" 2>&1; then
if [ $? -eq $expected_exit_code ]; then
log "${GREEN}✅ PASSED: $test_name${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
return 0
else
log "${RED}❌ FAILED: $test_name (unexpected exit code: $?)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
else
log "${RED}❌ FAILED: $test_name (command failed)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
}
# Health check function
check_service_health() {
local service_name="$1"
local health_url="$2"
log "${BLUE}🏥 Checking $service_name health...${NC}"
if curl -s -f "$health_url" > /dev/null 2>&1; then
log "${GREEN}$service_name health check passed${NC}"
return 0
else
log "${RED}$service_name health check failed${NC}"
return 1
fi
}
# Main test execution
main() {
local environment="${1:-base}"
log "${BLUE}🚀 Starting SiriusScan Container Health Tests${NC}"
log "Environment: $environment"
log "Timestamp: $(date)"
log "Project Root: $PROJECT_ROOT"
log "Log File: $LOG_FILE"
log "Test Configuration: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
log ""
cd "$PROJECT_ROOT"
# Adjust timeout for development environment (API takes longer to download dependencies)
if [ "$environment" = "dev" ] && [ "$TEST_RETRIES" = "60" ]; then
log "${YELLOW}🔧 Development mode detected - increasing timeout for API dependency downloads${NC}"
TEST_RETRIES=120 # 4 minutes total timeout for dev mode
log "Adjusted timeout: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
fi
# Start services based on environment
case $environment in
"dev")
log "${YELLOW}🚀 Starting development environment...${NC}"
docker compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d
;;
"prod")
log "${YELLOW}🚀 Starting production environment...${NC}"
docker compose up -d
;;
*)
log "${YELLOW}🚀 Starting base environment...${NC}"
docker compose up -d
;;
esac
# Wait for services to start
log "${YELLOW}⏳ Waiting for services to start...${NC}"
sleep 10
# Test 1: Check if all containers are running
run_test "All Containers Running" "docker compose ps --format 'table {{.Name}}\t{{.Status}}' | grep -v 'NAME' | grep 'Up' | wc -l | tr -d ' ' | grep -q '^6$'"
# Test 2: sirius-ui health check
wait_for_service "sirius-ui" "3000"
run_test "sirius-ui Health Check" "curl -s -f http://localhost:3000/ | grep -q 'html'"
# Test 3: sirius-api health check (using correct /health endpoint)
# Note: API takes time to download dependencies in dev mode, so we'll check if it's at least running
run_test "sirius-api Container Running" "docker compose ps sirius-api | grep -q 'Up'"
run_test "sirius-api Health Check" "curl -s -f http://localhost:9001/health | grep -q 'healthy' || echo 'API health endpoint not ready yet (still downloading dependencies)'"
# Test 4: sirius-engine health check (gRPC server on port 50051)
run_test "sirius-engine Container Running" "docker compose ps sirius-engine | grep -q 'Up'"
run_test "sirius-engine gRPC Health Check" "nc -z localhost 50051 && echo 'gRPC server is listening on port 50051'"
run_test "sirius-engine Process Check" "docker exec sirius-engine ps aux | grep -q 'start-enhanced.sh' || echo 'Engine process not found, but container is running'"
# Test 5: Database connectivity
run_test "PostgreSQL Connectivity" "docker exec sirius-postgres pg_isready -U postgres"
# Test 6: Redis connectivity
run_test "Valkey Connectivity" "docker exec sirius-valkey redis-cli ping | grep -q PONG"
# Test 7: RabbitMQ connectivity
run_test "RabbitMQ Connectivity" "docker exec sirius-rabbitmq rabbitmqctl status | grep -q 'RabbitMQ version'"
# Test 8: Port accessibility
run_test "Port 3000 Accessible" "curl -s -f http://localhost:3000 | grep -q 'html'"
run_test "Port 9001 Accessible" "curl -s -f http://localhost:9001/health | grep -q 'healthy' || (docker compose ps sirius-api | grep -q 'Up' && echo 'Port 9001 container running')"
run_test "Port 5174 Accessible" "nc -z localhost 5174"
# Summary
log ""
log "${BLUE}📊 Health Test Summary${NC}"
log "Total Tests: $TOTAL_TESTS"
log "Passed: ${GREEN}$PASSED_TESTS${NC}"
log "Failed: ${RED}$FAILED_TESTS${NC}"
if [ $FAILED_TESTS -eq 0 ]; then
log "${GREEN}🎉 All health tests passed!${NC}"
exit 0
else
log "${RED}💥 Some health tests failed. Check the log file: $LOG_FILE${NC}"
exit 1
fi
}
# Cleanup function
cleanup() {
log "${YELLOW}🧹 Cleaning up...${NC}"
docker compose down
}
# Trap to ensure cleanup on exit
trap cleanup EXIT
# Run main function
main "$@"
+235
View File
@@ -0,0 +1,235 @@
#!/bin/bash
# SiriusScan Simple Integration Test Script
# Tests only endpoints that actually exist and work
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
LOG_DIR="$PROJECT_ROOT/testing/logs"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
LOG_FILE="$LOG_DIR/integration_simple_test_$TIMESTAMP.log"
# Environment variables for configuration
TEST_RETRIES="${TEST_RETRIES:-60}" # Default 60 retries (2 seconds each = 120 seconds total)
LOG_LEVEL="${LOG_LEVEL:-info}" # Default info level
# Create logs directory
mkdir -p "$LOG_DIR"
# Logging function
log() {
echo -e "$1" | tee -a "$LOG_FILE"
}
# Test result tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Test function
run_test() {
local test_name="$1"
local test_command="$2"
local expected_exit_code="${3:-0}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
log "${BLUE}🧪 Testing: $test_name${NC}"
log "Command: $test_command"
if eval "$test_command" >> "$LOG_FILE" 2>&1; then
if [ $? -eq $expected_exit_code ]; then
log "${GREEN}✅ PASSED: $test_name${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
return 0
else
log "${RED}❌ FAILED: $test_name (unexpected exit code: $?)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
else
log "${RED}❌ FAILED: $test_name (command failed)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
}
# Wait for service to be ready
wait_for_service() {
local service_name="$1"
local port="$2"
local max_attempts=$TEST_RETRIES
local attempt=1
local sleep_interval=2
log "${YELLOW}⏳ Waiting for $service_name to be ready on port $port...${NC}"
log "Configuration: $max_attempts attempts, ${sleep_interval}s interval (total timeout: $((max_attempts * sleep_interval))s)"
while [ $attempt -le $max_attempts ]; do
# Use appropriate health endpoint based on service
local health_url="http://localhost:$port"
if [ "$service_name" = "sirius-api" ]; then
health_url="http://localhost:$port/health"
fi
if curl -s -f "$health_url" > /dev/null 2>&1; then
log "${GREEN}$service_name is ready!${NC}"
return 0
fi
if [ $((attempt % 10)) -eq 0 ] || [ "$LOG_LEVEL" = "debug" ]; then
log "Attempt $attempt/$max_attempts: $service_name not ready yet..."
fi
sleep $sleep_interval
attempt=$((attempt + 1))
done
log "${RED}$service_name failed to start within $((max_attempts * sleep_interval)) seconds${NC}"
log "${YELLOW}💡 Tip: Increase timeout with TEST_RETRIES environment variable (e.g., TEST_RETRIES=120)${NC}"
return 1
}
# Test core functionality
test_core_functionality() {
log "${BLUE}🔧 Testing Core Functionality${NC}"
# Test 1: All containers are running
run_test "All Containers Running" "docker compose ps --format 'table {{.Name}}\t{{.Status}}' | grep -v 'NAME' | grep 'Up' | wc -l | tr -d ' ' | grep -q '^6$'"
# Test 2: UI is accessible
run_test "UI Accessibility" "curl -s -f http://localhost:3000 | grep -q 'html'"
# Test 3: API health endpoint
run_test "API Health Endpoint" "curl -s -f http://localhost:9001/health | grep -q 'healthy'"
# Test 4: API hosts endpoint (should return empty array)
run_test "API Hosts Endpoint" "curl -s -f http://localhost:9001/host | grep -q '\\[\\]' || echo 'Hosts endpoint responding'"
# Test 5: Engine gRPC server
run_test "Engine gRPC Server" "nc -z localhost 50051 && echo 'gRPC server is listening on port 50051'"
# Test 6: Database connectivity
run_test "PostgreSQL Connectivity" "docker exec sirius-postgres pg_isready -U postgres"
# Test 7: Cache connectivity
run_test "Valkey Connectivity" "docker exec sirius-valkey redis-cli ping | grep -q PONG"
# Test 8: Message queue connectivity
run_test "RabbitMQ Connectivity" "docker exec sirius-rabbitmq rabbitmqctl status | grep -q 'RabbitMQ version'"
}
# Test service communication
test_service_communication() {
log "${BLUE}🔗 Testing Service Communication${NC}"
# Test UI -> API communication
run_test "UI to API Health Check" "curl -s -f http://localhost:3000 | grep -q 'html'"
# Test API -> Engine communication (via RabbitMQ, not HTTP)
run_test "API Health Status" "curl -s http://localhost:9001/health | grep -q 'healthy'"
# Test Engine -> Database communication (gRPC server is ready)
run_test "Engine gRPC Ready" "nc -z localhost 50051 && echo 'Engine gRPC server ready'"
}
# Test error handling
test_error_handling() {
log "${BLUE}⚠️ Testing Error Handling${NC}"
# Test 404 handling
run_test "UI 404 Error Handling" "curl -s -I http://localhost:3000/nonexistent | grep -q '404'"
run_test "API 404 Error Handling" "curl -s -I http://localhost:9001/nonexistent | grep -q '404'"
# Test invalid API requests
run_test "Invalid API Request Handling" "curl -s -X POST http://localhost:9001/host -H 'Content-Type: application/json' -d '{}' | grep -q 'error\\|invalid' || echo 'API error handling working'"
}
# Main test execution
main() {
local environment="${1:-base}"
log "${BLUE}🚀 Starting SiriusScan Simple Integration Tests${NC}"
log "Environment: $environment"
log "Timestamp: $(date)"
log "Project Root: $PROJECT_ROOT"
log "Log File: $LOG_FILE"
log "Test Configuration: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
log ""
cd "$PROJECT_ROOT"
# Adjust timeout for development environment (API takes longer to download dependencies)
if [ "$environment" = "dev" ] && [ "$TEST_RETRIES" = "60" ]; then
log "${YELLOW}🔧 Development mode detected - increasing timeout for API dependency downloads${NC}"
TEST_RETRIES=120 # 4 minutes total timeout for dev mode
log "Adjusted timeout: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
fi
# Start services based on environment
case $environment in
"dev")
log "${YELLOW}🚀 Starting development environment...${NC}"
docker compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d
;;
"prod")
log "${YELLOW}🚀 Starting production environment...${NC}"
docker compose up -d
;;
*)
log "${YELLOW}🚀 Starting base environment...${NC}"
docker compose up -d
;;
esac
# Wait for services to start
log "${YELLOW}⏳ Waiting for services to start...${NC}"
sleep 15
# Wait for individual services
wait_for_service "sirius-ui" "3000"
wait_for_service "sirius-api" "9001"
# Note: sirius-engine is a gRPC server, not HTTP, so we check port connectivity instead
run_test "Engine gRPC Connectivity" "nc -z localhost 50051 && echo 'gRPC server is ready'"
# Run integration tests
test_core_functionality
test_service_communication
test_error_handling
# Summary
log ""
log "${BLUE}📊 Integration Test Summary${NC}"
log "Total Tests: $TOTAL_TESTS"
log "Passed: ${GREEN}$PASSED_TESTS${NC}"
log "Failed: ${RED}$FAILED_TESTS${NC}"
if [ $FAILED_TESTS -eq 0 ]; then
log "${GREEN}🎉 All integration tests passed!${NC}"
exit 0
else
log "${RED}💥 Some integration tests failed. Check the log file: $LOG_FILE${NC}"
exit 1
fi
}
# Cleanup function
cleanup() {
log "${YELLOW}🧹 Cleaning up...${NC}"
docker compose down
}
# Trap to ensure cleanup on exit
trap cleanup EXIT
# Run main function
main "$@"
+269
View File
@@ -0,0 +1,269 @@
#!/bin/bash
# SiriusScan Integration Test Script
# Tests service integration and end-to-end functionality
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Check if we're in container-testing directory or testing directory
if [[ "$SCRIPT_DIR" == *"/container-testing" ]]; then
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
else
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
fi
LOG_DIR="$PROJECT_ROOT/testing/logs"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
LOG_FILE="$LOG_DIR/integration_test_$TIMESTAMP.log"
# Required compose variables for strict startup contract.
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-test-postgres-password}"
export NEXTAUTH_SECRET="${NEXTAUTH_SECRET:-test-nextauth-secret}"
export INITIAL_ADMIN_PASSWORD="${INITIAL_ADMIN_PASSWORD:-test-admin-password}"
INTERNAL_API_KEY_TEST_VALUE="${SIRIUS_INTERNAL_API_KEY_TEST_VALUE:-test-api-key}"
# Environment variables for configuration
TEST_TIMEOUT="${TEST_TIMEOUT:-60}" # Default 60 seconds
TEST_RETRIES="${TEST_RETRIES:-60}" # Default 60 retries (2 seconds each = 120 seconds total)
LOG_LEVEL="${LOG_LEVEL:-info}" # Default info level
# Create logs directory
mkdir -p "$LOG_DIR"
# Base compose mounts ./secrets/sirius_api_key.txt — ensure a placeholder exists
# before `docker compose up` in local and CI test runs.
if [ ! -f "$PROJECT_ROOT/secrets/sirius_api_key.txt" ]; then
mkdir -p "$PROJECT_ROOT/secrets"
printf '%s\n' "$INTERNAL_API_KEY_TEST_VALUE" > "$PROJECT_ROOT/secrets/sirius_api_key.txt"
fi
# Logging function
log() {
echo -e "$1" | tee -a "$LOG_FILE"
}
# Test result tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Test function
run_test() {
local test_name="$1"
local test_command="$2"
local expected_exit_code="${3:-0}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
log "${BLUE}🧪 Testing: $test_name${NC}"
log "Command: $test_command"
if eval "$test_command" >> "$LOG_FILE" 2>&1; then
if [ $? -eq $expected_exit_code ]; then
log "${GREEN}✅ PASSED: $test_name${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
return 0
else
log "${RED}❌ FAILED: $test_name (unexpected exit code: $?)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
else
log "${RED}❌ FAILED: $test_name (command failed)${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
}
# Wait for service to be ready
wait_for_service() {
local service_name="$1"
local port="$2"
local max_attempts=$TEST_RETRIES
local attempt=1
local sleep_interval=2
log "${YELLOW}⏳ Waiting for $service_name to be ready on port $port...${NC}"
log "Configuration: $max_attempts attempts, ${sleep_interval}s interval (total timeout: $((max_attempts * sleep_interval))s)"
while [ $attempt -le $max_attempts ]; do
# Use appropriate health endpoint based on service
local health_url="http://localhost:$port"
if [ "$service_name" = "sirius-api" ]; then
health_url="http://localhost:$port/health"
fi
if curl -s -f "$health_url" > /dev/null 2>&1; then
log "${GREEN}$service_name is ready!${NC}"
return 0
fi
if [ $((attempt % 10)) -eq 0 ] || [ "$LOG_LEVEL" = "debug" ]; then
log "Attempt $attempt/$max_attempts: $service_name not ready yet..."
fi
sleep $sleep_interval
attempt=$((attempt + 1))
done
log "${RED}$service_name failed to start within $((max_attempts * sleep_interval)) seconds${NC}"
log "${YELLOW}💡 Tip: Increase timeout with TEST_RETRIES environment variable (e.g., TEST_RETRIES=120)${NC}"
return 1
}
# Test API endpoints
test_api_endpoints() {
log "${BLUE}🔗 Testing API Endpoints${NC}"
# Test sirius-api endpoints (only test endpoints that actually exist)
run_test "API Health Endpoint" "curl -s -f http://localhost:9001/health | grep -q 'healthy'"
run_test "API Hosts Endpoint" "curl -s -f http://localhost:9001/host | grep -q '\\[\\]' || echo 'Hosts endpoint responding'"
run_test "API Vulnerabilities Endpoint" "curl -s -f http://localhost:9001/vulnerability/test | grep -q 'error\\|not found' || echo 'Vulnerability endpoint responding'"
# Test sirius-engine gRPC server
run_test "Engine gRPC Server" "nc -z localhost 50051 && echo 'gRPC server is listening on port 50051'"
run_test "Engine Container Running" "docker compose ps sirius-engine | grep -q 'Up'"
run_test "Engine Process Check" "docker exec sirius-engine ps aux | grep -q 'start-enhanced.sh' || echo 'Engine process not found, but container is running'"
}
# Test database operations
test_database_operations() {
log "${BLUE}🗄️ Testing Database Operations${NC}"
# Test PostgreSQL operations
run_test "PostgreSQL Connection" "docker exec sirius-postgres psql -U postgres -d sirius -c 'SELECT 1;'"
run_test "PostgreSQL Tables Exist" "docker exec sirius-postgres psql -U postgres -d sirius -c '\\dt' | grep -q 'hosts\\|vulnerabilities\\|ports'"
# Test Valkey operations
run_test "Valkey Set/Get" "docker exec sirius-valkey redis-cli set test_key 'test_value' && docker exec sirius-valkey redis-cli get test_key | grep -q 'test_value'"
run_test "Valkey Keys Command" "docker exec sirius-valkey redis-cli keys '*' | grep -q 'test_key'"
}
# Test service communication
test_service_communication() {
log "${BLUE}🔗 Testing Service Communication${NC}"
# Test UI -> API communication (using actual endpoints)
run_test "UI to API Health Check" "curl -s -f http://localhost:3000 | grep -q 'html'"
run_test "UI Accessibility" "curl -s -f http://localhost:3000 | grep -q 'Sirius\\|html' || echo 'UI is responding'"
# Test API -> Engine communication (via RabbitMQ, not HTTP)
run_test "API Health Status" "curl -s http://localhost:9001/health | grep -q 'healthy'"
run_test "API Hosts Endpoint" "curl -s http://localhost:9001/host | grep -q '\\[\\]' || echo 'API hosts endpoint responding'"
# Test Engine -> Database communication (gRPC server is ready)
run_test "Engine gRPC Ready" "nc -z localhost 50051 && echo 'Engine gRPC server ready'"
}
# Validate scanner script-linkage contracts in ValKey state.
test_scanner_linkage_contract() {
log "${BLUE}🧩 Testing Scanner Script-Linkage Contract${NC}"
run_test "NSE Manifest Exists in ValKey" "docker exec sirius-valkey redis-cli EXISTS nse:manifest | rg -q '^1$'"
run_test "NSE Manifest Contains Scripts Block" "docker exec sirius-valkey redis-cli --raw GET nse:manifest | rg -q '\"scripts\"'"
run_test "NSE Manifest Contains Script Entries" "docker exec sirius-valkey redis-cli --raw GET nse:manifest | python3 -c 'import json,sys; manifest=json.load(sys.stdin); scripts=manifest.get(\"scripts\", {}); count=len(scripts) if isinstance(scripts, (dict, list)) else 0; raise SystemExit(0 if count > 0 else 1)'"
}
# Note: Authentication and UI functionality tests removed
# These are not necessary for container health validation
# Test error handling
test_error_handling() {
log "${BLUE}⚠️ Testing Error Handling${NC}"
# Test 404 handling
run_test "UI 404 Error Handling" "curl -s -I http://localhost:3000/nonexistent | grep -q '404'"
run_test "API Error Handling" "curl -s -I http://localhost:9001/nonexistent | grep -Eq '401|404'"
# Test invalid API requests (using actual endpoints)
run_test "Invalid API Request Handling" "curl -s -X POST http://localhost:9001/host -H 'Content-Type: application/json' -d '{}' | grep -q 'error\\|invalid' || echo 'API error handling working'"
}
# Main test execution
main() {
local environment="${1:-base}"
log "${BLUE}🚀 Starting SiriusScan Integration Tests${NC}"
log "Environment: $environment"
log "Timestamp: $(date)"
log "Project Root: $PROJECT_ROOT"
log "Log File: $LOG_FILE"
log "Test Configuration: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
log ""
cd "$PROJECT_ROOT"
# Adjust timeout for development environment (API takes longer to download dependencies)
if [ "$environment" = "dev" ] && [ "$TEST_RETRIES" = "60" ]; then
log "${YELLOW}🔧 Development mode detected - increasing timeout for API dependency downloads${NC}"
TEST_RETRIES=120 # 4 minutes total timeout for dev mode
log "Adjusted timeout: $TEST_RETRIES retries, $((TEST_RETRIES * 2))s total timeout"
fi
# Start services based on environment
case $environment in
"dev")
log "${YELLOW}🚀 Starting development environment...${NC}"
docker compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d
;;
"prod")
log "${YELLOW}🚀 Starting production environment...${NC}"
docker compose up -d
;;
*)
log "${YELLOW}🚀 Starting base environment...${NC}"
docker compose up -d
;;
esac
# Wait for services to start
log "${YELLOW}⏳ Waiting for services to start...${NC}"
sleep 15
# Wait for individual services
wait_for_service "sirius-ui" "3000"
wait_for_service "sirius-api" "9001"
# Note: sirius-engine is a gRPC server, not HTTP, so we check port connectivity instead
run_test "Engine gRPC Connectivity" "nc -z localhost 50051 && echo 'gRPC server is ready'"
# Run integration tests (only essential container health tests)
test_api_endpoints
test_database_operations
test_service_communication
test_scanner_linkage_contract
test_error_handling
# Summary
log ""
log "${BLUE}📊 Integration Test Summary${NC}"
log "Total Tests: $TOTAL_TESTS"
log "Passed: ${GREEN}$PASSED_TESTS${NC}"
log "Failed: ${RED}$FAILED_TESTS${NC}"
if [ $FAILED_TESTS -eq 0 ]; then
log "${GREEN}🎉 All integration tests passed!${NC}"
exit 0
else
log "${RED}💥 Some integration tests failed. Check the log file: $LOG_FILE${NC}"
exit 1
fi
}
# Cleanup function
cleanup() {
log "${YELLOW}🧹 Cleaning up...${NC}"
docker compose down
}
# Trap to ensure cleanup on exit
trap cleanup EXIT
# Run main function
main "$@"
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Quick timeout test script
# Tests the new timeout configuration
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}🧪 Testing Container Health Timeout Configuration${NC}"
echo ""
# Test 1: Default timeout
echo -e "${YELLOW}Test 1: Default timeout (60 retries = 120s)${NC}"
export TEST_RETRIES=60
export LOG_LEVEL=debug
timeout 5s ./test-health.sh dev 2>&1 | head -20 || echo "Timeout test completed (expected)"
echo ""
# Test 2: Extended timeout
echo -e "${YELLOW}Test 2: Extended timeout (120 retries = 240s)${NC}"
export TEST_RETRIES=120
export LOG_LEVEL=debug
timeout 5s ./test-health.sh dev 2>&1 | head -20 || echo "Timeout test completed (expected)"
echo ""
# Test 3: Environment variable override
echo -e "${YELLOW}Test 3: Environment variable override${NC}"
export TEST_RETRIES=30
export LOG_LEVEL=debug
timeout 5s ./test-health.sh dev 2>&1 | head -20 || echo "Timeout test completed (expected)"
echo ""
echo -e "${GREEN}✅ Timeout configuration tests completed${NC}"
echo ""
echo -e "${BLUE}💡 Usage examples:${NC}"
echo " # Use default timeout (120s for dev, 60s for others)"
echo " ./test-health.sh dev"
echo ""
echo " # Override timeout to 4 minutes"
echo " TEST_RETRIES=120 ./test-health.sh dev"
echo ""
echo " # Enable debug logging"
echo " LOG_LEVEL=debug ./test-health.sh dev"
echo ""
echo " # Quick test with short timeout"
echo " TEST_RETRIES=10 ./test-health.sh dev"
@@ -0,0 +1,292 @@
// Package scanner_storage_contract verifies that every producer and
// consumer of the scanner Valkey schema agrees on the wire format.
//
// The contract lives in github.com/SiriusScan/go-api/sirius/store/templates.
// Each writer (sirius-api, app-scanner, app-agent) and reader (sirius-api,
// app-agent, sirius-ui-via-tRPC) routes through that package after PR 7,
// so exercising the helpers end-to-end against a single in-memory KV is
// sufficient to detect any drift in:
//
// - canonical key shapes ("template:custom:<id>", "nse:script:<id>", ...)
// - JSON envelope and meta projections
// - canonicalization rules for script IDs
package scanner_storage_contract_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/SiriusScan/go-api/sirius/store"
"github.com/SiriusScan/go-api/sirius/store/templates"
)
// fakeKV satisfies the narrow kvReader/kvWriter surface the templates
// package uses; it stands in for valkey for cross-pair contract checks.
type fakeKV struct {
mu sync.Mutex
m map[string]string
}
func newFakeKV() *fakeKV { return &fakeKV{m: map[string]string{}} }
func (f *fakeKV) GetValue(_ context.Context, key string) (store.ValkeyResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.m[key]
if !ok {
return store.ValkeyResponse{}, errors.New("not found")
}
return store.ValkeyResponse{Message: store.ValkeyValue{Value: v}}, nil
}
func (f *fakeKV) SetValue(_ context.Context, key, value string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.m[key] = value
return nil
}
func (f *fakeKV) DeleteValue(_ context.Context, key string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.m, key)
return nil
}
func sampleTemplate() *templates.TemplateRecord {
body := []byte("id: contract-template\n")
now := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
return &templates.TemplateRecord{
ID: "contract-template",
Version: "v1",
Checksum: templates.SHA256Hex(body),
Size: int64(len(body)),
Severity: "high",
Platforms: []string{"linux"},
DetectionType: "agent",
Author: "contract-suite",
Created: now,
Updated: now,
VulnerabilityIDs: []string{"CVE-2026-0001"},
IsCustom: true,
Content: body,
Metadata: map[string]string{"source": "contract-test"},
}
}
// TestContract_TemplateWriterReaderPairs walks every producer/consumer
// pair for agent templates. Each pair must end up reading the exact
// envelope the writer handed to the shared helper.
func TestContract_TemplateWriterReaderPairs(t *testing.T) {
pairs := []struct {
writer string
reader string
}{
{"sirius-api", "app-agent"},
{"app-agent", "sirius-api"},
{"sirius-api", "sirius-api"},
}
for _, p := range pairs {
t.Run(p.writer+"->"+p.reader, func(t *testing.T) {
kv := newFakeKV()
ctx := context.Background()
rec := sampleTemplate()
if err := templates.WriteTemplate(ctx, kv, rec); err != nil {
t.Fatalf("[%s] write: %v", p.writer, err)
}
got, err := templates.ReadTemplate(ctx, kv, rec.ID)
if err != nil {
t.Fatalf("[%s] read: %v", p.reader, err)
}
if got == nil {
t.Fatalf("[%s] read returned nil for id %q", p.reader, rec.ID)
}
if got.ID != rec.ID || got.Severity != rec.Severity || !bytes.Equal(got.Content, rec.Content) {
t.Fatalf("[%s -> %s] envelope mismatch:\n got: %+v\nwant: %+v", p.writer, p.reader, got, rec)
}
if got.Checksum != rec.Checksum {
t.Fatalf("[%s -> %s] checksum mismatch: got %q want %q", p.writer, p.reader, got.Checksum, rec.Checksum)
}
meta, err := templates.ReadTemplateMeta(ctx, kv, rec.ID)
if err != nil {
t.Fatalf("[%s] read meta: %v", p.reader, err)
}
if meta == nil {
t.Fatalf("[%s] missing meta record", p.reader)
}
if len(meta.Content) != 0 {
t.Fatalf("[%s] meta record must omit Content (got %d bytes)", p.reader, len(meta.Content))
}
if meta.Checksum != rec.Checksum {
t.Fatalf("[%s] meta checksum mismatch: got %q want %q", p.reader, meta.Checksum, rec.Checksum)
}
if meta.IsCustom != rec.IsCustom {
t.Fatalf("[%s] meta IsCustom drift: got %v want %v", p.reader, meta.IsCustom, rec.IsCustom)
}
})
}
}
// TestContract_TemplateKeyShape locks the on-disk key namespaces.
func TestContract_TemplateKeyShape(t *testing.T) {
cases := []struct {
id string
isCustom bool
want string
}{
{"foo", false, "template:standard:foo"},
{"foo", true, "template:custom:foo"},
{"my-yaml-rule", true, "template:custom:my-yaml-rule"},
}
for _, c := range cases {
if got := templates.AgentTemplateKey(c.id, c.isCustom); got != c.want {
t.Fatalf("AgentTemplateKey(%q, %v) = %q, want %q", c.id, c.isCustom, got, c.want)
}
}
if got := templates.AgentTemplateMetaKey("foo"); got != "template:meta:foo" {
t.Fatalf("meta key = %q", got)
}
}
// TestContract_NseScriptCanonicalization is the regression net for the
// PR 1 / PR 7 bug: app-scanner used to write "nse:script:foo.nse" while
// the UI looked up "nse:script:foo". Both sides MUST go through
// templates.NseScriptKey, which canonicalizes for them.
func TestContract_NseScriptCanonicalization(t *testing.T) {
ctx := context.Background()
kv := newFakeKV()
// Producer: app-scanner imitator writes a `.nse`-suffixed id.
rec := &templates.NseScriptRecord{
Content: "description = [[shellshock]]\n",
Metadata: templates.NseScriptMeta{
Author: "contract-suite",
Tags: []string{"vuln", "exploit"},
Description: "Detects CVE-2014-6271",
},
UpdatedAt: time.Now().Unix(),
}
if err := templates.WriteNseScript(ctx, kv, "http-shellshock.nse", rec); err != nil {
t.Fatalf("write: %v", err)
}
// Consumer: ui-via-api imitator looks up the canonical id.
got, err := templates.ReadNseScript(ctx, kv, "http-shellshock")
if err != nil {
t.Fatalf("read canonical: %v", err)
}
if got == nil || got.Content != rec.Content || got.Metadata.Description != rec.Metadata.Description {
t.Fatalf("canonical read returned %+v", got)
}
// Suffix-tolerant lookup must yield the same record.
gotSuffixed, err := templates.ReadNseScript(ctx, kv, "http-shellshock.nse")
if err != nil {
t.Fatalf("read suffixed: %v", err)
}
if gotSuffixed == nil || gotSuffixed.Content != rec.Content {
t.Fatalf("suffixed read returned %+v", gotSuffixed)
}
for k := range kv.m {
if strings.HasSuffix(k, ".nse") {
t.Fatalf("non-canonical key persisted: %q", k)
}
}
}
// TestContract_NseManifestCanonicalization asserts that the manifest
// writer canonicalizes its map keys, so a producer that loads scripts
// from disk with file extensions still produces a manifest the UI can
// index by canonical id.
func TestContract_NseManifestCanonicalization(t *testing.T) {
ctx := context.Background()
kv := newFakeKV()
m := &templates.NseManifest{
Name: "sirius-nse",
Version: "v0.0.1",
Scripts: map[string]templates.NseManifestEntry{
"http-shellshock.nse": {Name: "http-shellshock", Path: "http-shellshock.nse", Protocol: "tcp"},
"smb-vuln.nse": {Name: "smb-vuln", Path: "smb-vuln.nse", Protocol: "tcp"},
},
}
if err := templates.WriteNseManifest(ctx, kv, m); err != nil {
t.Fatalf("write manifest: %v", err)
}
got, err := templates.ReadNseManifest(ctx, kv)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
if got == nil {
t.Fatal("manifest missing")
}
for key := range got.Scripts {
if strings.HasSuffix(key, ".nse") {
t.Fatalf("manifest key not canonicalized: %q", key)
}
}
if _, ok := got.Scripts["http-shellshock"]; !ok {
t.Fatalf("expected canonical http-shellshock key, got %v", got.Scripts)
}
}
// TestContract_TemplateWireShape pins the JSON envelope so a careless
// rename of a TemplateRecord field doesn't silently break consumers
// that read the bytes off the wire. If you intentionally change the
// schema you must bump go-api and update this test in the same change
// set, per the drift policy in
// documentation/dev/architecture/README.scanner-storage.md.
func TestContract_TemplateWireShape(t *testing.T) {
rec := sampleTemplate()
data, err := templates.EncodeTemplate(rec)
if err != nil {
t.Fatal(err)
}
var generic map[string]any
if err := json.Unmarshal(data, &generic); err != nil {
t.Fatalf("envelope is not valid JSON: %v", err)
}
required := []string{
"id", "version", "checksum", "size", "severity", "platforms",
"detection_type", "author", "created", "updated",
"vulnerability_ids", "is_custom", "content",
}
for _, k := range required {
if _, ok := generic[k]; !ok {
t.Fatalf("envelope missing required field %q", k)
}
}
}
// TestContract_NseScriptWireShape pins the script JSON envelope.
func TestContract_NseScriptWireShape(t *testing.T) {
rec := &templates.NseScriptRecord{
Content: "description = [[x]]\n",
Metadata: templates.NseScriptMeta{Author: "a", Tags: []string{"t"}, Description: "d"},
UpdatedAt: 42,
}
data, err := templates.EncodeNseScript(rec)
if err != nil {
t.Fatal(err)
}
var generic map[string]any
if err := json.Unmarshal(data, &generic); err != nil {
t.Fatalf("script envelope is not valid JSON: %v", err)
}
for _, k := range []string{"content", "metadata", "updatedAt"} {
if _, ok := generic[k]; !ok {
t.Fatalf("script envelope missing required field %q", k)
}
}
}
@@ -0,0 +1,10 @@
module github.com/SiriusScan/sirius-scanner-storage-contract
go 1.24.0
require github.com/SiriusScan/go-api v0.0.18
require (
github.com/valkey-io/valkey-go v1.0.60 // indirect
golang.org/x/sys v0.31.0 // indirect
)
@@ -0,0 +1,16 @@
github.com/SiriusScan/go-api v0.0.18 h1:O6MLBFY2ssONkReMLT3LlLYFGBhKoWp5VOKXsTxOzOI=
github.com/SiriusScan/go-api v0.0.18/go.mod h1:3uw5dE8qotMGDkF7AF3pwVSxcLNJY7ZI8csTXEmkOkE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/valkey-io/valkey-go v1.0.60 h1:idh959D20H5n7D/kwEdTKNaMn5+4HpZTn7bLXnAhQIw=
github.com/valkey-io/valkey-go v1.0.60/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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")
}