Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All @@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand Down Expand Up @@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All @@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand Down Expand Up @@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading