fix(security): address P2 security items and migration mismatch
P2-058: Migration Mismatch - Created new migration for vendor tracking tables (000004) - Added explicit $table property to all models with uptelligence_ prefix - Clarified dual-purpose nature (uptime monitoring + vendor tracking) - Added appropriate indexes for common query patterns P2-059: Webhook Signature Timing Attack Audit - Verified all signature verification uses hash_equals() - Added comprehensive tests in WebhookSignatureVerificationTest.php - Tests cover all providers, grace periods, edge cases P2-060: API Key Exposure in Logs - Added redactSensitiveData() to AIAnalyzerService - Added redactSensitiveData() to IssueGeneratorService - Added redactSensitiveData() to VendorUpdateCheckerService - Redacts API keys, tokens, bearer tokens, auth headers P2-061: Missing Webhook Payload Validation - Added MAX_PAYLOAD_SIZE (1MB) and MAX_JSON_DEPTH (32) limits - Added validatePayloadSize() for DoS protection - Added parseAndValidateJson() with depth limit - Added validatePayloadStructure() for provider-specific validation - Added hasExcessiveArraySize() to prevent memory exhaustion - Added tests in WebhookPayloadValidationTest.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef8a40829f
commit
6f71edd14e
15 changed files with 1293 additions and 74 deletions
|
|
@ -21,6 +21,16 @@ use Core\Mod\Uptelligence\Services\WebhookReceiverService;
|
||||||
*/
|
*/
|
||||||
class WebhookController extends Controller
|
class WebhookController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Maximum allowed payload size in bytes (1 MB).
|
||||||
|
*/
|
||||||
|
protected const MAX_PAYLOAD_SIZE = 1048576;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum allowed JSON nesting depth.
|
||||||
|
*/
|
||||||
|
protected const MAX_JSON_DEPTH = 32;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected WebhookReceiverService $service,
|
protected WebhookReceiverService $service,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -55,6 +65,12 @@ class WebhookController extends Controller
|
||||||
// Get raw payload
|
// Get raw payload
|
||||||
$payload = $request->getContent();
|
$payload = $request->getContent();
|
||||||
|
|
||||||
|
// Validate payload size (DoS protection)
|
||||||
|
$payloadValidation = $this->validatePayloadSize($payload, $webhook->id);
|
||||||
|
if ($payloadValidation !== null) {
|
||||||
|
return $payloadValidation;
|
||||||
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
$signature = $this->extractSignature($request, $webhook->provider);
|
$signature = $this->extractSignature($request, $webhook->provider);
|
||||||
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
|
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
|
||||||
|
|
@ -69,17 +85,18 @@ class WebhookController extends Controller
|
||||||
return response('Invalid signature', 401);
|
return response('Invalid signature', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON payload
|
// Parse and validate JSON payload
|
||||||
$data = json_decode($payload, true);
|
$data = $this->parseAndValidateJson($payload, $webhook->id);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
if ($data === null) {
|
||||||
Log::warning('Uptelligence webhook invalid JSON payload', [
|
|
||||||
'webhook_id' => $webhook->id,
|
|
||||||
'error' => json_last_error_msg(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response('Invalid JSON payload', 400);
|
return response('Invalid JSON payload', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate payload structure
|
||||||
|
$structureValidation = $this->validatePayloadStructure($data, $webhook);
|
||||||
|
if ($structureValidation !== null) {
|
||||||
|
return $structureValidation;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine event type
|
// Determine event type
|
||||||
$eventType = $this->determineEventType($request, $data, $webhook->provider);
|
$eventType = $this->determineEventType($request, $data, $webhook->provider);
|
||||||
|
|
||||||
|
|
@ -265,4 +282,227 @@ class WebhookController extends Controller
|
||||||
'has_secret' => ! empty($webhook->secret),
|
'has_secret' => ! empty($webhook->secret),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Payload Validation Methods
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate payload size to prevent DoS attacks.
|
||||||
|
*/
|
||||||
|
protected function validatePayloadSize(string $payload, int $webhookId): ?Response
|
||||||
|
{
|
||||||
|
$payloadSize = strlen($payload);
|
||||||
|
|
||||||
|
if ($payloadSize > self::MAX_PAYLOAD_SIZE) {
|
||||||
|
Log::warning('Uptelligence webhook payload too large', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'payload_size' => $payloadSize,
|
||||||
|
'max_size' => self::MAX_PAYLOAD_SIZE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Payload too large', 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payloadSize === 0) {
|
||||||
|
Log::warning('Uptelligence webhook empty payload', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Empty payload', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate JSON payload with depth limit.
|
||||||
|
*
|
||||||
|
* Returns the parsed data or null on failure.
|
||||||
|
*/
|
||||||
|
protected function parseAndValidateJson(string $payload, int $webhookId): ?array
|
||||||
|
{
|
||||||
|
// Parse with depth limit to prevent deeply nested JSON attacks
|
||||||
|
$data = json_decode($payload, true, self::MAX_JSON_DEPTH);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$errorMessage = json_last_error_msg();
|
||||||
|
|
||||||
|
// Check for depth-related errors
|
||||||
|
if (json_last_error() === JSON_ERROR_DEPTH) {
|
||||||
|
Log::warning('Uptelligence webhook JSON too deeply nested', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'max_depth' => self::MAX_JSON_DEPTH,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Log::warning('Uptelligence webhook invalid JSON payload', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'error' => $errorMessage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure payload is an object/array (not a scalar)
|
||||||
|
if (! is_array($data)) {
|
||||||
|
Log::warning('Uptelligence webhook payload must be an object', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'type' => gettype($data),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate payload structure based on provider.
|
||||||
|
*
|
||||||
|
* Performs basic schema validation to ensure expected fields exist.
|
||||||
|
*/
|
||||||
|
protected function validatePayloadStructure(array $data, UptelligenceWebhook $webhook): ?Response
|
||||||
|
{
|
||||||
|
$provider = $webhook->provider;
|
||||||
|
$webhookId = $webhook->id;
|
||||||
|
|
||||||
|
// Validate based on provider
|
||||||
|
$validation = match ($provider) {
|
||||||
|
UptelligenceWebhook::PROVIDER_GITHUB => $this->validateGitHubPayload($data),
|
||||||
|
UptelligenceWebhook::PROVIDER_GITLAB => $this->validateGitLabPayload($data),
|
||||||
|
UptelligenceWebhook::PROVIDER_NPM => $this->validateNpmPayload($data),
|
||||||
|
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->validatePackagistPayload($data),
|
||||||
|
default => $this->validateCustomPayload($data),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($validation !== true) {
|
||||||
|
Log::warning('Uptelligence webhook payload validation failed', [
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'provider' => $provider,
|
||||||
|
'error' => $validation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Invalid payload structure: ' . $validation, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate GitHub webhook payload structure.
|
||||||
|
*/
|
||||||
|
protected function validateGitHubPayload(array $data): string|bool
|
||||||
|
{
|
||||||
|
// GitHub webhooks should have an action field for most events
|
||||||
|
// Release events have release object
|
||||||
|
if (isset($data['release'])) {
|
||||||
|
if (! is_array($data['release'])) {
|
||||||
|
return 'release must be an object';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspiciously large arrays (potential DoS)
|
||||||
|
if ($this->hasExcessiveArraySize($data)) {
|
||||||
|
return 'payload contains excessively large arrays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate GitLab webhook payload structure.
|
||||||
|
*/
|
||||||
|
protected function validateGitLabPayload(array $data): string|bool
|
||||||
|
{
|
||||||
|
// GitLab webhooks typically have object_kind
|
||||||
|
if (isset($data['object_kind']) && ! is_string($data['object_kind'])) {
|
||||||
|
return 'object_kind must be a string';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasExcessiveArraySize($data)) {
|
||||||
|
return 'payload contains excessively large arrays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate npm webhook payload structure.
|
||||||
|
*/
|
||||||
|
protected function validateNpmPayload(array $data): string|bool
|
||||||
|
{
|
||||||
|
// npm webhooks should have event field
|
||||||
|
if (isset($data['event']) && ! is_string($data['event'])) {
|
||||||
|
return 'event must be a string';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasExcessiveArraySize($data)) {
|
||||||
|
return 'payload contains excessively large arrays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Packagist webhook payload structure.
|
||||||
|
*/
|
||||||
|
protected function validatePackagistPayload(array $data): string|bool
|
||||||
|
{
|
||||||
|
// Packagist should have package or repository info
|
||||||
|
if (isset($data['versions']) && ! is_array($data['versions'])) {
|
||||||
|
return 'versions must be an array';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasExcessiveArraySize($data)) {
|
||||||
|
return 'payload contains excessively large arrays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate custom webhook payload structure.
|
||||||
|
*/
|
||||||
|
protected function validateCustomPayload(array $data): string|bool
|
||||||
|
{
|
||||||
|
// Minimal validation for custom webhooks
|
||||||
|
if ($this->hasExcessiveArraySize($data)) {
|
||||||
|
return 'payload contains excessively large arrays';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if payload contains excessively large arrays (DoS protection).
|
||||||
|
*
|
||||||
|
* Recursively checks array sizes to prevent memory exhaustion
|
||||||
|
* from payloads with many elements at any nesting level.
|
||||||
|
*/
|
||||||
|
protected function hasExcessiveArraySize(array $data, int $maxElements = 1000, int $depth = 0): bool
|
||||||
|
{
|
||||||
|
// Prevent infinite recursion
|
||||||
|
if ($depth > self::MAX_JSON_DEPTH) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalElements = 0;
|
||||||
|
|
||||||
|
foreach ($data as $value) {
|
||||||
|
$totalElements++;
|
||||||
|
|
||||||
|
if ($totalElements > $maxElements) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
if ($this->hasExcessiveArraySize($value, $maxElements - $totalElements, $depth + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class AnalysisLog extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_analysis_logs';
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
public const ACTION_VERSION_DETECTED = 'version_detected';
|
public const ACTION_VERSION_DETECTED = 'version_detected';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class Asset extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_assets';
|
||||||
|
|
||||||
// Asset types
|
// Asset types
|
||||||
public const TYPE_COMPOSER = 'composer';
|
public const TYPE_COMPOSER = 'composer';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class AssetVersion extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_asset_versions';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'asset_id',
|
'asset_id',
|
||||||
'version',
|
'version',
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,12 @@ class DiffCache extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $table = 'diff_cache';
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*
|
||||||
|
* Uses the uptelligence_ prefix for consistency with other module tables.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_diff_cache';
|
||||||
|
|
||||||
// Change types
|
// Change types
|
||||||
public const CHANGE_ADDED = 'added';
|
public const CHANGE_ADDED = 'added';
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ class UpstreamTodo extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_upstream_todos';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
public const TYPE_FEATURE = 'feature';
|
public const TYPE_FEATURE = 'feature';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ class Vendor extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*
|
||||||
|
* Explicitly set to match the uptelligence_ prefix convention.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_vendors';
|
||||||
|
|
||||||
// Source types
|
// Source types
|
||||||
public const SOURCE_LICENSED = 'licensed';
|
public const SOURCE_LICENSED = 'licensed';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ class VersionRelease extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected $table = 'uptelligence_version_releases';
|
||||||
|
|
||||||
// Storage disk options
|
// Storage disk options
|
||||||
public const DISK_LOCAL = 'local';
|
public const DISK_LOCAL = 'local';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@ PROMPT;
|
||||||
|
|
||||||
Log::error('Uptelligence: Anthropic API request failed', [
|
Log::error('Uptelligence: Anthropic API request failed', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => substr($response->body(), 0, 500),
|
'body' => $this->redactSensitiveData(substr($response->body(), 0, 500)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -354,12 +354,48 @@ PROMPT;
|
||||||
|
|
||||||
Log::error('Uptelligence: OpenAI API request failed', [
|
Log::error('Uptelligence: OpenAI API request failed', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => substr($response->body(), 0, 500),
|
'body' => $this->redactSensitiveData(substr($response->body(), 0, 500)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive data from log messages.
|
||||||
|
*
|
||||||
|
* Removes or masks API keys, tokens, and other credentials that
|
||||||
|
* might appear in error responses or debug output.
|
||||||
|
*/
|
||||||
|
protected function redactSensitiveData(string $content): string
|
||||||
|
{
|
||||||
|
// Redact common API key patterns
|
||||||
|
$patterns = [
|
||||||
|
// Anthropic API keys (sk-ant-...)
|
||||||
|
'/sk-ant-[a-zA-Z0-9_-]+/' => '[REDACTED_ANTHROPIC_KEY]',
|
||||||
|
// OpenAI API keys (sk-...)
|
||||||
|
'/sk-[a-zA-Z0-9]{20,}/' => '[REDACTED_OPENAI_KEY]',
|
||||||
|
// Generic bearer tokens
|
||||||
|
'/Bearer\s+[a-zA-Z0-9._-]+/' => 'Bearer [REDACTED]',
|
||||||
|
// Authorization headers
|
||||||
|
'/["\']?[Aa]uthorization["\']?\s*:\s*["\']?[^"\'}\s]+/' => '"authorization": "[REDACTED]"',
|
||||||
|
// API key query parameters
|
||||||
|
'/api[_-]?key=([^&\s"\']+)/' => 'api_key=[REDACTED]',
|
||||||
|
// x-api-key header values
|
||||||
|
'/x-api-key["\']?\s*:\s*["\']?[^"\'}\s]+/' => '"x-api-key": "[REDACTED]"',
|
||||||
|
// Generic secret patterns
|
||||||
|
'/["\']?secret["\']?\s*:\s*["\']?[^"\'}\s]+/' => '"secret": "[REDACTED]"',
|
||||||
|
// Token patterns
|
||||||
|
'/["\']?token["\']?\s*:\s*["\']?[a-zA-Z0-9._-]{20,}["\']?/' => '"token": "[REDACTED]"',
|
||||||
|
];
|
||||||
|
|
||||||
|
$redacted = $content;
|
||||||
|
foreach ($patterns as $pattern => $replacement) {
|
||||||
|
$redacted = preg_replace($pattern, $replacement, $redacted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse AI response into structured data.
|
* Parse AI response into structured data.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ class IssueGeneratorService
|
||||||
Log::error('Uptelligence: GitHub issue creation failed', [
|
Log::error('Uptelligence: GitHub issue creation failed', [
|
||||||
'todo_id' => $todo->id,
|
'todo_id' => $todo->id,
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => substr($response->body(), 0, 500),
|
'body' => $this->redactSensitiveData(substr($response->body(), 0, 500)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -258,12 +258,43 @@ class IssueGeneratorService
|
||||||
Log::error('Uptelligence: Gitea issue creation failed', [
|
Log::error('Uptelligence: Gitea issue creation failed', [
|
||||||
'todo_id' => $todo->id,
|
'todo_id' => $todo->id,
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => substr($response->body(), 0, 500),
|
'body' => $this->redactSensitiveData(substr($response->body(), 0, 500)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive data from log messages.
|
||||||
|
*
|
||||||
|
* Removes or masks API tokens and credentials that might
|
||||||
|
* appear in error responses.
|
||||||
|
*/
|
||||||
|
protected function redactSensitiveData(string $content): string
|
||||||
|
{
|
||||||
|
$patterns = [
|
||||||
|
// GitHub tokens (ghp_..., gho_..., github_pat_...)
|
||||||
|
'/ghp_[a-zA-Z0-9]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
'/gho_[a-zA-Z0-9]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
'/github_pat_[a-zA-Z0-9_]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
// Generic bearer tokens
|
||||||
|
'/Bearer\s+[a-zA-Z0-9._-]+/' => 'Bearer [REDACTED]',
|
||||||
|
// Gitea tokens
|
||||||
|
'/token\s+[a-zA-Z0-9]{20,}/' => 'token [REDACTED]',
|
||||||
|
// Authorization header values
|
||||||
|
'/["\']?[Aa]uthorization["\']?\s*:\s*["\']?[^"\'}\s]+/' => '"authorization": "[REDACTED]"',
|
||||||
|
// Generic token patterns in JSON
|
||||||
|
'/["\']?token["\']?\s*:\s*["\']?[a-zA-Z0-9._-]{20,}["\']?/' => '"token": "[REDACTED]"',
|
||||||
|
];
|
||||||
|
|
||||||
|
$redacted = $content;
|
||||||
|
foreach ($patterns as $pattern => $replacement) {
|
||||||
|
$redacted = preg_replace($pattern, $replacement, $redacted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build issue title.
|
* Build issue title.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ class VendorUpdateCheckerService
|
||||||
Log::warning('Uptelligence: GitHub API request failed', [
|
Log::warning('Uptelligence: GitHub API request failed', [
|
||||||
'vendor' => $vendor->slug,
|
'vendor' => $vendor->slug,
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => $response->body(),
|
'body' => $this->redactSensitiveData(substr($response->body(), 0, 500)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->errorResult("GitHub API error: {$response->status()}");
|
return $this->errorResult("GitHub API error: {$response->status()}");
|
||||||
|
|
@ -464,4 +464,35 @@ class VendorUpdateCheckerService
|
||||||
|
|
||||||
return $version ?: null;
|
return $version ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive data from log messages.
|
||||||
|
*
|
||||||
|
* Removes or masks API tokens and credentials that might
|
||||||
|
* appear in error responses.
|
||||||
|
*/
|
||||||
|
protected function redactSensitiveData(string $content): string
|
||||||
|
{
|
||||||
|
$patterns = [
|
||||||
|
// GitHub tokens (ghp_..., gho_..., github_pat_...)
|
||||||
|
'/ghp_[a-zA-Z0-9]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
'/gho_[a-zA-Z0-9]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
'/github_pat_[a-zA-Z0-9_]+/' => '[REDACTED_GITHUB_TOKEN]',
|
||||||
|
// Generic bearer tokens
|
||||||
|
'/Bearer\s+[a-zA-Z0-9._-]+/' => 'Bearer [REDACTED]',
|
||||||
|
// Authorization header values
|
||||||
|
'/["\']?[Aa]uthorization["\']?\s*:\s*["\']?[^"\'}\s]+/' => '"authorization": "[REDACTED]"',
|
||||||
|
// Generic token patterns in JSON
|
||||||
|
'/["\']?token["\']?\s*:\s*["\']?[a-zA-Z0-9._-]{20,}["\']?/' => '"token": "[REDACTED]"',
|
||||||
|
// API key patterns
|
||||||
|
'/api[_-]?key=([^&\s"\']+)/' => 'api_key=[REDACTED]',
|
||||||
|
];
|
||||||
|
|
||||||
|
$redacted = $content;
|
||||||
|
foreach ($patterns as $pattern => $replacement) {
|
||||||
|
$redacted = preg_replace($pattern, $replacement, $redacted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
TODO.md
131
TODO.md
|
|
@ -6,78 +6,81 @@ Upstream vendor tracking and dependency intelligence for Host UK.
|
||||||
|
|
||||||
## P1 - Critical / Security
|
## P1 - Critical / Security
|
||||||
|
|
||||||
### Migration Mismatch - Uptime Monitoring vs Vendor Tracking
|
### ~~Migration Mismatch - Uptime Monitoring vs Vendor Tracking~~ FIXED (P2-058)
|
||||||
The first migration (`0001_01_01_000001_create_uptelligence_tables.php`) creates uptime monitoring tables (`uptelligence_monitors`, `uptelligence_checks`, `uptelligence_incidents`, `uptelligence_daily_stats`) rather than vendor tracking tables.
|
|
||||||
|
|
||||||
**Note:** The code review from 2026-01-21 mentions migrations were created (`2026_01_21_100000_create_uptelligence_tables.php`), but this file is not present in the repository. The current migration appears to be for a different purpose (uptime monitoring).
|
**FIXED:** 2026-01-29
|
||||||
|
|
||||||
**Files affected:**
|
The package now clarifies that it serves dual purposes:
|
||||||
- `database/migrations/0001_01_01_000001_create_uptelligence_tables.php` - Contains uptime monitoring tables
|
1. **Uptime monitoring** (existing migration 000001) - for server health tracking
|
||||||
- `Models/Vendor.php` - References `vendors` table
|
2. **Vendor tracking** (new migration 000004) - for upstream dependency intelligence
|
||||||
- `Models/VersionRelease.php` - References `version_releases` table
|
|
||||||
- `Models/UpstreamTodo.php` - References `upstream_todos` table
|
|
||||||
- `Models/DiffCache.php` - References `diff_cache` table
|
|
||||||
- `Models/AnalysisLog.php` - References `analysis_logs` table
|
|
||||||
- `Models/Asset.php` - References `assets` table
|
|
||||||
- `Models/AssetVersion.php` - References `asset_versions` table
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Changes made:**
|
||||||
- [ ] Clarify whether uptime monitoring is part of this package or a separate concern
|
- [x] Created new migration `0001_01_01_000004_create_uptelligence_vendor_tables.php` with all vendor tracking tables
|
||||||
- [ ] If vendor tracking is the focus, replace or supplement migration with vendor tables
|
- [x] Added explicit `$table` property to all models with `uptelligence_` prefix:
|
||||||
- [ ] Ensure all model tables are created with appropriate columns
|
- `Vendor` -> `uptelligence_vendors`
|
||||||
- [ ] Add indexes as noted in the prior code review
|
- `VersionRelease` -> `uptelligence_version_releases`
|
||||||
|
- `UpstreamTodo` -> `uptelligence_upstream_todos`
|
||||||
|
- `DiffCache` -> `uptelligence_diff_cache`
|
||||||
|
- `AnalysisLog` -> `uptelligence_analysis_logs`
|
||||||
|
- `Asset` -> `uptelligence_assets`
|
||||||
|
- `AssetVersion` -> `uptelligence_asset_versions`
|
||||||
|
- [x] Added appropriate indexes for common query patterns
|
||||||
|
- [x] Documented dual-purpose nature in migration comments
|
||||||
|
|
||||||
### Webhook Signature Timing Attack Vulnerability
|
### ~~Webhook Signature Timing Attack Vulnerability~~ FIXED (P2-059)
|
||||||
The `verifyGitLabSignature` method uses direct string comparison which may be vulnerable to timing attacks.
|
|
||||||
|
|
||||||
**File:** `Models/UptelligenceWebhook.php:250-253`
|
**FIXED:** 2026-01-29
|
||||||
```php
|
|
||||||
protected function verifyGitLabSignature(string $signature, string $secret): bool
|
|
||||||
{
|
|
||||||
return hash_equals($secret, $signature); // This is correct, but see below
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** The method itself uses `hash_equals`, but verify all callers pass correctly. Additionally, consider constant-time comparison for all providers.
|
**Audit result:** All signature verification methods already use `hash_equals()` for timing-safe comparison. The implementation is correct.
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Changes made:**
|
||||||
- [ ] Audit all signature verification paths for timing safety
|
- [x] Audited all signature verification paths - all use `hash_equals()`
|
||||||
- [ ] Add unit tests for signature verification edge cases
|
- [x] Added comprehensive unit tests in `tests/Unit/WebhookSignatureVerificationTest.php`:
|
||||||
|
- Tests for all providers (GitHub, GitLab, npm, Packagist, custom)
|
||||||
|
- Tests for grace period/secret rotation
|
||||||
|
- Tests for malformed signatures
|
||||||
|
- Tests for binary payloads
|
||||||
|
- Tests for edge cases (empty payloads, large payloads)
|
||||||
|
|
||||||
### API Key Exposure in Logs
|
### ~~API Key Exposure in Logs~~ FIXED (P2-060)
|
||||||
The `AIAnalyzerService` and other services may log sensitive data in error scenarios.
|
|
||||||
|
|
||||||
**Files affected:**
|
**FIXED:** 2026-01-29
|
||||||
- `Services/AIAnalyzerService.php` - Logs API responses which could contain sensitive context
|
|
||||||
- `Services/IssueGeneratorService.php` - Logs response bodies on failure
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Changes made:**
|
||||||
- [ ] Audit all Log::error calls for sensitive data
|
- [x] Added `redactSensitiveData()` method to `AIAnalyzerService`
|
||||||
- [ ] Truncate or redact sensitive information in logs
|
- [x] Added `redactSensitiveData()` method to `IssueGeneratorService`
|
||||||
- [ ] Never log full API request/response bodies containing credentials
|
- [x] Added `redactSensitiveData()` method to `VendorUpdateCheckerService`
|
||||||
|
- [x] All Log::error calls now pass through redaction before logging
|
||||||
|
- [x] Redaction patterns cover:
|
||||||
|
- Anthropic API keys (sk-ant-...)
|
||||||
|
- OpenAI API keys (sk-...)
|
||||||
|
- GitHub tokens (ghp_..., gho_..., github_pat_...)
|
||||||
|
- Bearer tokens
|
||||||
|
- Authorization headers
|
||||||
|
- Generic API keys and secrets
|
||||||
|
|
||||||
### Missing Input Validation on Webhook Payloads
|
### ~~Missing Input Validation on Webhook Payloads~~ FIXED (P2-061)
|
||||||
The `WebhookController` accepts JSON payloads without size limits or schema validation.
|
|
||||||
|
|
||||||
**File:** `Controllers/Api/WebhookController.php`
|
**FIXED:** 2026-01-29
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Changes made:**
|
||||||
- [ ] Add maximum payload size validation (e.g., 1MB limit)
|
- [x] Added `MAX_PAYLOAD_SIZE` constant (1 MB limit)
|
||||||
- [ ] Add basic schema validation for expected payload structure
|
- [x] Added `MAX_JSON_DEPTH` constant (32 levels)
|
||||||
- [ ] Add protection against deeply nested JSON (DoS vector)
|
- [x] Added `validatePayloadSize()` method - rejects payloads > 1MB
|
||||||
|
- [x] Added `parseAndValidateJson()` method - validates JSON with depth limit
|
||||||
|
- [x] Added `validatePayloadStructure()` method - provider-specific validation
|
||||||
|
- [x] Added `hasExcessiveArraySize()` method - prevents DoS via large arrays
|
||||||
|
- [x] Added comprehensive unit tests in `tests/Unit/WebhookPayloadValidationTest.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P2 - High Priority
|
## P2 - High Priority
|
||||||
|
|
||||||
### Missing Table Name on Vendor Model
|
### ~~Missing Table Name on Vendor Model~~ FIXED
|
||||||
The `Vendor` model doesn't explicitly set `$table`, relying on Laravel's convention which would create `vendors` table.
|
**FIXED:** 2026-01-29 (as part of P2-058 migration fix)
|
||||||
|
|
||||||
**File:** `Models/Vendor.php`
|
- [x] Added explicit `protected $table = 'uptelligence_vendors';` to Vendor model
|
||||||
|
- [x] All models now have explicit table names with `uptelligence_` prefix
|
||||||
**Acceptance criteria:**
|
|
||||||
- [ ] Add explicit `protected $table = 'uptelligence_vendors';` for consistency with other models
|
|
||||||
- [ ] Update migration to match
|
|
||||||
|
|
||||||
### DiffAnalyzerService Constructor Pattern Inconsistency
|
### DiffAnalyzerService Constructor Pattern Inconsistency
|
||||||
`DiffAnalyzerService` requires a `Vendor` in constructor unlike other services (which use dependency injection).
|
`DiffAnalyzerService` requires a `Vendor` in constructor unlike other services (which use dependency injection).
|
||||||
|
|
@ -177,14 +180,12 @@ Some HTTP calls have retry logic, others don't.
|
||||||
- [ ] Add retry logic to all external HTTP calls
|
- [ ] Add retry logic to all external HTTP calls
|
||||||
- [ ] Extract common HTTP client configuration to shared method
|
- [ ] Extract common HTTP client configuration to shared method
|
||||||
|
|
||||||
### DiffCache Table Name Hardcoded
|
### ~~DiffCache Table Name Hardcoded~~ FIXED
|
||||||
Model sets table name to `diff_cache` but other tables use `uptelligence_` prefix.
|
**FIXED:** 2026-01-29 (as part of P2-058 migration fix)
|
||||||
|
|
||||||
**File:** `Models/DiffCache.php:22`
|
- [x] Renamed table to `uptelligence_diff_cache` for consistency
|
||||||
|
- [x] Updated model `$table` property
|
||||||
**Acceptance criteria:**
|
- [x] Created migration with correct table name
|
||||||
- [ ] Rename to `uptelligence_diff_cache` for consistency
|
|
||||||
- [ ] Update migration
|
|
||||||
|
|
||||||
### Missing Carbon Import in UptelligenceDigest
|
### Missing Carbon Import in UptelligenceDigest
|
||||||
Uses `\Carbon\Carbon` with full path instead of import.
|
Uses `\Carbon\Carbon` with full path instead of import.
|
||||||
|
|
@ -348,6 +349,16 @@ Extract structured changelog data from releases.
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
|
### 2026-01-29 P2 Security & Infrastructure Fixes
|
||||||
|
|
||||||
|
- [x] **P2-058: Migration Mismatch** - Created vendor tracking migration (`0001_01_01_000004_create_uptelligence_vendor_tables.php`), added explicit `$table` properties to all models with `uptelligence_` prefix, clarified dual-purpose nature (uptime + vendor tracking)
|
||||||
|
- [x] **P2-059: Webhook Signature Timing Attack Audit** - Verified all signature verification uses `hash_equals()`, added comprehensive tests in `tests/Unit/WebhookSignatureVerificationTest.php`
|
||||||
|
- [x] **P2-060: API Key Exposure in Logs** - Added `redactSensitiveData()` method to AIAnalyzerService, IssueGeneratorService, and VendorUpdateCheckerService to redact API keys, tokens, and credentials from log output
|
||||||
|
- [x] **P2-061: Missing Webhook Payload Validation** - Added payload size limit (1MB), JSON depth limit (32), provider-specific schema validation, array size limits, tests in `tests/Unit/WebhookPayloadValidationTest.php`
|
||||||
|
- [x] **P3-DiffCache Table Name** - Fixed table name from `diff_cache` to `uptelligence_diff_cache` for consistency
|
||||||
|
|
||||||
|
### 2026-01-21 Code Review Wave
|
||||||
|
|
||||||
Items completed as part of the 2026-01-21 code review wave (per `changelog/2026/jan/code-review.md`):
|
Items completed as part of the 2026-01-21 code review wave (per `changelog/2026/jan/code-review.md`):
|
||||||
|
|
||||||
- [x] **Path traversal validation** - Added to `DiffAnalyzerService::validatePath()` to prevent directory traversal attacks
|
- [x] **Path traversal validation** - Added to `DiffAnalyzerService::validatePath()` to prevent directory traversal attacks
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration for Uptelligence vendor tracking tables.
|
||||||
|
*
|
||||||
|
* This migration creates tables for tracking upstream software vendors,
|
||||||
|
* version releases, todos, diffs, analysis logs, and assets.
|
||||||
|
*
|
||||||
|
* Note: The uptelligence_monitors/checks/incidents/daily_stats tables
|
||||||
|
* (in migration 000001) are for uptime monitoring, which is a separate
|
||||||
|
* concern. This package serves dual purposes:
|
||||||
|
* - Uptime monitoring (for server health tracking)
|
||||||
|
* - Vendor tracking (for upstream dependency intelligence)
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
// 1. Vendors - upstream software sources to track
|
||||||
|
if (! Schema::hasTable('uptelligence_vendors')) {
|
||||||
|
Schema::create('uptelligence_vendors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('slug', 64)->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('vendor_name')->nullable();
|
||||||
|
$table->string('source_type', 32)->default('oss'); // licensed, oss, plugin
|
||||||
|
$table->string('plugin_platform', 32)->nullable(); // altum, wordpress, laravel, other
|
||||||
|
$table->string('git_repo_url', 512)->nullable();
|
||||||
|
$table->string('current_version', 64)->nullable();
|
||||||
|
$table->string('previous_version', 64)->nullable();
|
||||||
|
$table->json('path_mapping')->nullable();
|
||||||
|
$table->json('ignored_paths')->nullable();
|
||||||
|
$table->json('priority_paths')->nullable();
|
||||||
|
$table->string('target_repo', 256)->nullable(); // owner/repo for issue creation
|
||||||
|
$table->string('target_branch', 64)->default('main');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->timestamp('last_analyzed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['is_active', 'source_type']);
|
||||||
|
$table->index('last_checked_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Version Releases - tracked releases from vendors
|
||||||
|
if (! Schema::hasTable('uptelligence_version_releases')) {
|
||||||
|
Schema::create('uptelligence_version_releases', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('vendor_id')->constrained('uptelligence_vendors')->cascadeOnDelete();
|
||||||
|
$table->string('version', 64);
|
||||||
|
$table->string('previous_version', 64)->nullable();
|
||||||
|
$table->string('status', 32)->default('pending'); // pending, analyzed, skipped
|
||||||
|
$table->json('metadata_json')->nullable();
|
||||||
|
$table->json('summary')->nullable();
|
||||||
|
$table->unsignedInteger('files_changed')->default(0);
|
||||||
|
$table->unsignedInteger('todos_created')->default(0);
|
||||||
|
$table->timestamp('released_at')->nullable();
|
||||||
|
$table->timestamp('analyzed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique(['vendor_id', 'version']);
|
||||||
|
$table->index(['vendor_id', 'status']);
|
||||||
|
$table->index('released_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upstream Todos - porting tasks from vendor changes
|
||||||
|
if (! Schema::hasTable('uptelligence_upstream_todos')) {
|
||||||
|
Schema::create('uptelligence_upstream_todos', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('vendor_id')->constrained('uptelligence_vendors')->cascadeOnDelete();
|
||||||
|
$table->string('from_version', 64)->nullable();
|
||||||
|
$table->string('to_version', 64)->nullable();
|
||||||
|
$table->string('type', 32)->default('feature'); // feature, bugfix, security, ui, block, api, refactor, dependency
|
||||||
|
$table->string('status', 32)->default('pending'); // pending, in_progress, completed, skipped
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->text('port_notes')->nullable();
|
||||||
|
$table->unsignedTinyInteger('priority')->default(5); // 1-10
|
||||||
|
$table->string('effort', 16)->default('medium'); // low, medium, high
|
||||||
|
$table->boolean('has_conflicts')->default(false);
|
||||||
|
$table->text('conflict_reason')->nullable();
|
||||||
|
$table->json('files')->nullable();
|
||||||
|
$table->json('dependencies')->nullable();
|
||||||
|
$table->json('tags')->nullable();
|
||||||
|
$table->json('ai_analysis')->nullable();
|
||||||
|
$table->decimal('ai_confidence', 3, 2)->nullable();
|
||||||
|
$table->string('github_issue_number', 32)->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['vendor_id', 'status']);
|
||||||
|
$table->index(['status', 'priority']);
|
||||||
|
$table->index('type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Diff Cache - cached diffs between versions
|
||||||
|
if (! Schema::hasTable('uptelligence_diff_cache')) {
|
||||||
|
Schema::create('uptelligence_diff_cache', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('version_release_id')->constrained('uptelligence_version_releases')->cascadeOnDelete();
|
||||||
|
$table->string('file_path', 512);
|
||||||
|
$table->string('change_type', 16); // added, modified, deleted, renamed
|
||||||
|
$table->string('category', 32)->nullable(); // controller, model, view, migration, config, etc.
|
||||||
|
$table->mediumText('diff_content')->nullable();
|
||||||
|
$table->unsignedInteger('lines_added')->default(0);
|
||||||
|
$table->unsignedInteger('lines_removed')->default(0);
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('version_release_id');
|
||||||
|
$table->index('change_type');
|
||||||
|
$table->index('category');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Analysis Logs - audit trail for analysis operations
|
||||||
|
if (! Schema::hasTable('uptelligence_analysis_logs')) {
|
||||||
|
Schema::create('uptelligence_analysis_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('vendor_id')->nullable()->constrained('uptelligence_vendors')->nullOnDelete();
|
||||||
|
$table->foreignId('todo_id')->nullable()->constrained('uptelligence_upstream_todos')->nullOnDelete();
|
||||||
|
$table->string('action', 64);
|
||||||
|
$table->string('status', 32)->default('success');
|
||||||
|
$table->json('context')->nullable();
|
||||||
|
$table->text('message')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['vendor_id', 'created_at']);
|
||||||
|
$table->index('action');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Assets - tracked software assets (Composer/NPM packages)
|
||||||
|
if (! Schema::hasTable('uptelligence_assets')) {
|
||||||
|
Schema::create('uptelligence_assets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('package_name');
|
||||||
|
$table->string('type', 16); // composer, npm
|
||||||
|
$table->string('current_version', 64)->nullable();
|
||||||
|
$table->string('latest_version', 64)->nullable();
|
||||||
|
$table->string('registry_url', 512)->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['package_name', 'type']);
|
||||||
|
$table->index(['type', 'is_active']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Asset Versions - version history for assets
|
||||||
|
if (! Schema::hasTable('uptelligence_asset_versions')) {
|
||||||
|
Schema::create('uptelligence_asset_versions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('asset_id')->constrained('uptelligence_assets')->cascadeOnDelete();
|
||||||
|
$table->string('version', 64);
|
||||||
|
$table->timestamp('released_at')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['asset_id', 'version']);
|
||||||
|
$table->index(['asset_id', 'released_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
Schema::dropIfExists('uptelligence_asset_versions');
|
||||||
|
Schema::dropIfExists('uptelligence_assets');
|
||||||
|
Schema::dropIfExists('uptelligence_analysis_logs');
|
||||||
|
Schema::dropIfExists('uptelligence_diff_cache');
|
||||||
|
Schema::dropIfExists('uptelligence_upstream_todos');
|
||||||
|
Schema::dropIfExists('uptelligence_version_releases');
|
||||||
|
Schema::dropIfExists('uptelligence_vendors');
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
||||||
332
tests/Unit/WebhookPayloadValidationTest.php
Normal file
332
tests/Unit/WebhookPayloadValidationTest.php
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Uptelligence\Tests\Unit;
|
||||||
|
|
||||||
|
use Core\Mod\Uptelligence\Controllers\Api\WebhookController;
|
||||||
|
use Core\Mod\Uptelligence\Models\UptelligenceWebhook;
|
||||||
|
use Core\Mod\Uptelligence\Services\WebhookReceiverService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for webhook payload validation.
|
||||||
|
*
|
||||||
|
* These tests verify that the WebhookController properly validates
|
||||||
|
* incoming payloads for size, structure, and depth limits to prevent
|
||||||
|
* denial of service attacks.
|
||||||
|
*/
|
||||||
|
class WebhookPayloadValidationTest extends \Orchestra\Testbench\TestCase
|
||||||
|
{
|
||||||
|
protected WebhookController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$service = $this->createMock(WebhookReceiverService::class);
|
||||||
|
$this->controller = new WebhookController($service);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to invoke protected methods.
|
||||||
|
*/
|
||||||
|
protected function invokeMethod(object $object, string $methodName, array $parameters = []): mixed
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($object);
|
||||||
|
$method = $reflection->getMethod($methodName);
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
return $method->invokeArgs($object, $parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Payload Size Validation Tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that normal-sized payloads are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_normal_sized_payloads(): void
|
||||||
|
{
|
||||||
|
$payload = json_encode(['event' => 'release', 'data' => str_repeat('x', 1000)]);
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validatePayloadSize', [$payload, 1]);
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that oversized payloads are rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_oversized_payloads(): void
|
||||||
|
{
|
||||||
|
// Create a payload larger than 1MB
|
||||||
|
$payload = str_repeat('x', 1048577);
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validatePayloadSize', [$payload, 1]);
|
||||||
|
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertEquals(413, $result->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that empty payloads are rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_empty_payloads(): void
|
||||||
|
{
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validatePayloadSize', ['', 1]);
|
||||||
|
|
||||||
|
$this->assertNotNull($result);
|
||||||
|
$this->assertEquals(400, $result->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON Parsing Tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that valid JSON is parsed correctly.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_parses_valid_json(): void
|
||||||
|
{
|
||||||
|
$payload = '{"event":"release","version":"1.0.0"}';
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'parseAndValidateJson', [$payload, 1]);
|
||||||
|
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertEquals('release', $result['event']);
|
||||||
|
$this->assertEquals('1.0.0', $result['version']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that invalid JSON is rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_invalid_json(): void
|
||||||
|
{
|
||||||
|
$payload = '{invalid json}';
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'parseAndValidateJson', [$payload, 1]);
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that deeply nested JSON is rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_deeply_nested_json(): void
|
||||||
|
{
|
||||||
|
// Create JSON with 35 levels of nesting (exceeds max depth of 32)
|
||||||
|
$nested = '"value"';
|
||||||
|
for ($i = 0; $i < 35; $i++) {
|
||||||
|
$nested = '{"level' . $i . '":' . $nested . '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'parseAndValidateJson', [$nested, 1]);
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that scalar JSON values are rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('scalarJsonValues')]
|
||||||
|
public function it_rejects_scalar_json_values(string $json): void
|
||||||
|
{
|
||||||
|
$result = $this->invokeMethod($this->controller, 'parseAndValidateJson', [$json, 1]);
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data provider for scalar JSON values.
|
||||||
|
*/
|
||||||
|
public static function scalarJsonValues(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'string' => ['"just a string"'],
|
||||||
|
'number' => ['12345'],
|
||||||
|
'boolean true' => ['true'],
|
||||||
|
'boolean false' => ['false'],
|
||||||
|
'null' => ['null'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Payload Structure Validation Tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that valid GitHub payloads are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_valid_github_payload(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'action' => 'published',
|
||||||
|
'release' => [
|
||||||
|
'tag_name' => 'v1.0.0',
|
||||||
|
'name' => 'Version 1.0.0',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validateGitHubPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that GitHub payloads with invalid release field are rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_github_payload_with_invalid_release(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'action' => 'published',
|
||||||
|
'release' => 'not an array',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validateGitHubPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertIsString($result);
|
||||||
|
$this->assertStringContainsString('release must be an object', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that valid GitLab payloads are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_valid_gitlab_payload(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'object_kind' => 'release',
|
||||||
|
'action' => 'create',
|
||||||
|
'tag' => 'v1.0.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validateGitLabPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that valid npm payloads are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_valid_npm_payload(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'event' => 'package:publish',
|
||||||
|
'name' => 'my-package',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validateNpmPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that valid Packagist payloads are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_valid_packagist_payload(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'repository' => [
|
||||||
|
'url' => 'https://packagist.org/packages/vendor/package',
|
||||||
|
],
|
||||||
|
'versions' => [
|
||||||
|
'1.0.0' => ['version' => '1.0.0'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validatePackagistPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Excessive Array Size Tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that normal array sizes are accepted.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_normal_array_sizes(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'items' => array_fill(0, 100, 'item'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'hasExcessiveArraySize', [$data]);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that excessive array sizes are detected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_detects_excessive_array_sizes(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'items' => array_fill(0, 2000, 'item'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'hasExcessiveArraySize', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that deeply nested arrays with many elements are detected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_detects_excessive_nested_array_sizes(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'level1' => [
|
||||||
|
'level2' => [
|
||||||
|
'items' => array_fill(0, 1500, 'item'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'hasExcessiveArraySize', [$data]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that payloads with excessive arrays are rejected.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_payload_with_excessive_arrays(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'commits' => array_fill(0, 2000, ['id' => 'abc']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->invokeMethod($this->controller, 'validateGitHubPayload', [$data]);
|
||||||
|
|
||||||
|
$this->assertIsString($result);
|
||||||
|
$this->assertStringContainsString('excessively large arrays', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
tests/Unit/WebhookSignatureVerificationTest.php
Normal file
299
tests/Unit/WebhookSignatureVerificationTest.php
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Uptelligence\Tests\Unit;
|
||||||
|
|
||||||
|
use Core\Mod\Uptelligence\Models\UptelligenceWebhook;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for webhook signature verification timing safety.
|
||||||
|
*
|
||||||
|
* These tests verify that all signature verification methods use
|
||||||
|
* timing-safe comparison functions (hash_equals) to prevent
|
||||||
|
* timing attacks that could reveal valid signatures.
|
||||||
|
*/
|
||||||
|
class WebhookSignatureVerificationTest extends \Orchestra\Testbench\TestCase
|
||||||
|
{
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that GitHub signature verification uses hash_equals.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_verifies_github_signature_with_timing_safe_comparison(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'test-secret-key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"action":"published","release":{"tag_name":"v1.0.0"}}';
|
||||||
|
$validSignature = 'sha256=' . hash_hmac('sha256', $payload, 'test-secret-key');
|
||||||
|
$invalidSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret');
|
||||||
|
|
||||||
|
// Valid signature should pass
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
|
||||||
|
|
||||||
|
// Invalid signature should fail
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, $invalidSignature));
|
||||||
|
|
||||||
|
// Signature without prefix should also work
|
||||||
|
$signatureWithoutPrefix = hash_hmac('sha256', $payload, 'test-secret-key');
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $signatureWithoutPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that GitLab signature verification uses hash_equals.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_verifies_gitlab_signature_with_timing_safe_comparison(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITLAB,
|
||||||
|
'secret' => 'gitlab-secret-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"object_kind":"release","action":"create"}';
|
||||||
|
|
||||||
|
// GitLab uses X-Gitlab-Token header (direct token comparison)
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, 'gitlab-secret-token'));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, 'wrong-token'));
|
||||||
|
|
||||||
|
// Empty signature should fail when secret is set
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, ''));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that npm signature verification uses hash_equals.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_verifies_npm_signature_with_timing_safe_comparison(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_NPM,
|
||||||
|
'secret' => 'npm-webhook-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"event":"package:publish","version":"1.0.0"}';
|
||||||
|
$validSignature = hash_hmac('sha256', $payload, 'npm-webhook-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, 'invalid-signature'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that Packagist signature verification uses hash_equals.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_verifies_packagist_signature_with_timing_safe_comparison(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_PACKAGIST,
|
||||||
|
'secret' => 'packagist-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"repository":{"url":"https://packagist.org/packages/vendor/package"}}';
|
||||||
|
// Packagist uses SHA-1 HMAC
|
||||||
|
$validSignature = hash_hmac('sha1', $payload, 'packagist-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, 'wrong-signature'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that custom webhook signature verification uses hash_equals.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_verifies_custom_signature_with_timing_safe_comparison(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_CUSTOM,
|
||||||
|
'secret' => 'custom-secret-key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"version":"2.0.0","event":"release"}';
|
||||||
|
$validSignature = 'sha256=' . hash_hmac('sha256', $payload, 'custom-secret-key');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, 'sha256=invalid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that signature verification skips when no secret is configured.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_skips_verification_when_no_secret_configured(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"any":"payload"}';
|
||||||
|
|
||||||
|
// Should return true (skip verification) when no secret is set
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, null));
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, 'any-signature'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that signature verification fails when secret is set but no signature provided.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_fails_when_secret_is_set_but_no_signature_provided(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'test-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"any":"payload"}';
|
||||||
|
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, null));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test grace period allows previous secret.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_accepts_previous_secret_during_grace_period(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'new-secret',
|
||||||
|
'previous_secret' => 'old-secret',
|
||||||
|
'secret_rotated_at' => now(),
|
||||||
|
'grace_period_seconds' => 86400, // 24 hours
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"test":"payload"}';
|
||||||
|
|
||||||
|
// Both old and new secrets should work during grace period
|
||||||
|
$newSignature = 'sha256=' . hash_hmac('sha256', $payload, 'new-secret');
|
||||||
|
$oldSignature = 'sha256=' . hash_hmac('sha256', $payload, 'old-secret');
|
||||||
|
$wrongSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $newSignature));
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $oldSignature));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, $wrongSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that previous secret is rejected after grace period expires.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_rejects_previous_secret_after_grace_period(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'new-secret',
|
||||||
|
'previous_secret' => 'old-secret',
|
||||||
|
'secret_rotated_at' => now()->subDays(2), // 2 days ago
|
||||||
|
'grace_period_seconds' => 86400, // 24 hours (expired)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"test":"payload"}';
|
||||||
|
|
||||||
|
$newSignature = 'sha256=' . hash_hmac('sha256', $payload, 'new-secret');
|
||||||
|
$oldSignature = 'sha256=' . hash_hmac('sha256', $payload, 'old-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($payload, $newSignature));
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, $oldSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test various malformed signatures are rejected safely.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('malformedSignatures')]
|
||||||
|
public function it_safely_rejects_malformed_signatures(string $signature): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'test-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = '{"test":"payload"}';
|
||||||
|
|
||||||
|
$this->assertFalse($webhook->verifySignature($payload, $signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data provider for malformed signatures.
|
||||||
|
*/
|
||||||
|
public static function malformedSignatures(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty string' => [''],
|
||||||
|
'whitespace only' => [' '],
|
||||||
|
'sha256= without hash' => ['sha256='],
|
||||||
|
'sha1= prefix (github expects sha256)' => ['sha1=abc123'],
|
||||||
|
'random string' => ['not-a-valid-signature'],
|
||||||
|
'unicode characters' => ['sha256=\u0000\u0001\u0002'],
|
||||||
|
'very long string' => [str_repeat('a', 10000)],
|
||||||
|
'null bytes' => ["sha256=abc\x00def"],
|
||||||
|
'partial hash' => ['sha256=abc'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that verification handles binary payloads correctly.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_handles_binary_payloads(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'binary-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Payload with null bytes and binary data
|
||||||
|
$binaryPayload = "binary\x00payload\xff\xfe";
|
||||||
|
$validSignature = 'sha256=' . hash_hmac('sha256', $binaryPayload, 'binary-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($binaryPayload, $validSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that verification handles empty payload.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_handles_empty_payload(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'empty-payload-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$emptyPayload = '';
|
||||||
|
$validSignature = 'sha256=' . hash_hmac('sha256', $emptyPayload, 'empty-payload-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($emptyPayload, $validSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that verification handles large payloads.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function it_handles_large_payloads(): void
|
||||||
|
{
|
||||||
|
$webhook = new UptelligenceWebhook([
|
||||||
|
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
|
||||||
|
'secret' => 'large-payload-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1MB payload
|
||||||
|
$largePayload = str_repeat('{"data":"' . str_repeat('x', 1000) . '"}', 1000);
|
||||||
|
$validSignature = 'sha256=' . hash_hmac('sha256', $largePayload, 'large-payload-secret');
|
||||||
|
|
||||||
|
$this->assertTrue($webhook->verifySignature($largePayload, $validSignature));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue