Skip to content
Merged
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
30 changes: 24 additions & 6 deletions inc/Channels/CliChannelRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* detach?: bool,
* timeout?: int,
* env?: array<string, string>,
* env_from?: array<string, string>,
* cwd?: string|null,
* }
*/
Expand Down Expand Up @@ -188,18 +189,35 @@ public static function normalize_entry( array $config ): ?array {
$normalized_env[ $env_key ] = (string) $env_value;
}

$env_from = $config['env_from'] ?? array();
if ( ! is_array($env_from) ) {
$env_from = array();
}
$normalized_env_from = array();
foreach ( $env_from as $env_key => $source_env_key ) {
if ( ! is_string($env_key) || '' === $env_key || ! is_scalar($source_env_key) ) {
continue;
}

$source_env_key = trim( (string) $source_env_key );
if ( '' !== $source_env_key ) {
$normalized_env_from[ $env_key ] = $source_env_key;
}
}

$cwd = $config['cwd'] ?? null;
if ( null !== $cwd && ( ! is_string($cwd) || '' === $cwd ) ) {
$cwd = null;
}

return array(
'command' => $command,
'args' => $normalized_args,
'detach' => $detach,
'timeout' => $timeout,
'env' => $normalized_env,
'cwd' => $cwd,
'command' => $command,
'args' => $normalized_args,
'detach' => $detach,
'timeout' => $timeout,
'env' => $normalized_env,
'env_from' => $normalized_env_from,
'cwd' => $cwd,
);
}

Expand Down
72 changes: 56 additions & 16 deletions inc/Channels/CliChannelTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
namespace DataMachineCode\Channels;

use DataMachineCode\Environment;
use DataMachineCode\Support\SecretRedactor;
use WP_Error;

defined('ABSPATH') || exit;
Expand Down Expand Up @@ -163,13 +164,16 @@ public static function execute( array $input ) {
$detach = (bool) ( $config['detach'] ?? true );
$timeout = isset($config['timeout']) && is_int($config['timeout']) ? $config['timeout'] : self::DEFAULT_TIMEOUT_SECONDS;
$cwd = isset($config['cwd']) && is_string($config['cwd']) && '' !== $config['cwd'] ? $config['cwd'] : null;
$env = self::build_env_map(isset($config['env']) && is_array($config['env']) ? $config['env'] : array());
$env = self::build_env_map(
isset($config['env']) && is_array($config['env']) ? $config['env'] : array(),
isset($config['env_from']) && is_array($config['env_from']) ? $config['env_from'] : array()
);

if ( $detach ) {
return self::dispatch_detached($channel, $recipient, $command_args, $cwd, $env);
return self::dispatch_detached($channel, $recipient, $command_args, $cwd, $env['values']);
}

return self::dispatch_sync($channel, $recipient, $command_args, $cwd, $env, $timeout);
return self::dispatch_sync($channel, $recipient, $command_args, $cwd, $env['values'], $env['secrets'], $timeout);
}

/**
Expand Down Expand Up @@ -232,7 +236,7 @@ private static function dispatch_detached( string $channel, string $recipient, a
* @param int $timeout Timeout in seconds.
* @return array<string, mixed>|WP_Error
*/
private static function dispatch_sync( string $channel, string $recipient, array $argv, ?string $cwd, ?array $env, int $timeout ) {
private static function dispatch_sync( string $channel, string $recipient, array $argv, ?string $cwd, ?array $env, array $secrets, int $timeout ) {
$descriptors = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
Expand Down Expand Up @@ -328,8 +332,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
array(
'channel' => $channel,
'recipient' => $recipient,
'stdout' => self::truncate_output($stdout),
'stderr' => self::truncate_output($stderr),
'stdout' => self::truncate_output($stdout, $secrets),
'stderr' => self::truncate_output($stderr, $secrets),
'duration_ms' => $duration_ms,
)
);
Expand All @@ -343,8 +347,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
'channel' => $channel,
'recipient' => $recipient,
'exit_code' => $exit_code,
'stdout' => self::truncate_output($stdout),
'stderr' => self::truncate_output($stderr),
'stdout' => self::truncate_output($stdout, $secrets),
'stderr' => self::truncate_output($stderr, $secrets),
'duration_ms' => $duration_ms,
)
);
Expand All @@ -359,8 +363,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
'mode' => 'sync',
'exit_code' => $exit_code,
'duration_ms' => $duration_ms,
'stdout' => self::truncate_output($stdout),
'stderr' => self::truncate_output($stderr),
'stdout' => self::truncate_output($stdout, $secrets),
'stderr' => self::truncate_output($stderr, $secrets),
),
);
}
Expand Down Expand Up @@ -415,10 +419,12 @@ private static function open_process( array $argv, array $descriptors, ?string $
* the inherited PATH if it provides one.
*
* @param array<string, string> $configured Configured env map.
* @return array<string, string>
* @param array<string, string> $env_from Child env name => parent env name.
* @return array{values:array<string,string>,secrets:string[]}
*/
private static function build_env_map( array $configured ): array {
$env = array();
private static function build_env_map( array $configured, array $env_from ): array {
$env = array();
$secrets = array();

$parent_path = getenv('PATH');
if ( is_string($parent_path) && '' !== $parent_path ) {
Expand All @@ -427,9 +433,42 @@ private static function build_env_map( array $configured ): array {

foreach ( $configured as $key => $value ) {
$env[ $key ] = $value;
if ( self::is_secret_like_env_key( (string) $key ) ) {
$secrets[] = $value;
}
}

return $env;
foreach ( $env_from as $target_key => $source_key ) {
$value = self::parent_env( (string) $source_key );
if ( '' === $value ) {
continue;
}

$env[ $target_key ] = $value;
if ( self::is_secret_like_env_key( (string) $target_key ) || self::is_secret_like_env_key( (string) $source_key ) ) {
$secrets[] = $value;
}
}

return array(
'values' => $env,
'secrets' => array_values(array_unique(array_filter($secrets, static fn( string $secret ): bool => strlen(trim($secret)) >= 8))),
);
}

/**
* Read a trimmed parent environment variable.
*/
private static function parent_env( string $name ): string {
$value = getenv($name);
return is_string($value) ? trim($value) : '';
}

/**
* Determine whether an environment key conventionally carries a secret.
*/
private static function is_secret_like_env_key( string $key ): bool {
return 1 === preg_match('/(?:^|_)(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|AUTH|COOKIE|NONCE)(?:_|$)/i', $key);
}

/**
Expand All @@ -438,8 +477,9 @@ private static function build_env_map( array $configured ): array {
* @param string $output Captured output.
* @return string Truncated output.
*/
private static function truncate_output( string $output ): string {
$limit = 8192;
private static function truncate_output( string $output, array $secrets = array() ): string {
$output = SecretRedactor::redact($output, $secrets);
$limit = 8192;
if ( strlen($output) <= $limit ) {
return $output;
}
Expand Down
10 changes: 1 addition & 9 deletions inc/Support/RunArtifactPrSectionRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ private static function sanitizeValue( mixed $value ): mixed {
}

if ( is_string($value) ) {
return self::redactSecretLikeString($value);
return SecretRedactor::redact($value);
}

return $value;
Expand All @@ -354,14 +354,6 @@ private static function isSecretLikeKey( string $key ): bool {
return 1 === preg_match('/(authorization|credential|secret|token|password|passwd|private[_-]?key|api[_-]?key|github[_-]?pat|bearer)/i', $key);
}

private static function redactSecretLikeString( string $value ): string {
$value = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/', '[redacted]', $value) ?? $value;
$value = preg_replace('/\b(sk-[A-Za-z0-9_-]{12,})\b/', '[redacted]', $value) ?? $value;
$value = preg_replace('/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/', '[redacted]', $value) ?? $value;

return preg_replace('/\b(authorization|token|password|secret|api[_-]?key)\s*[:=]\s*\S+/i', '$1: [redacted]', $value) ?? $value;
}

/**
* @param array<int|string, mixed> $value Source array.
* @param array<int, string> $keys Candidate keys.
Expand Down
46 changes: 46 additions & 0 deletions inc/Support/SecretRedactor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
/**
* Shared secret redaction helpers.
*
* @package DataMachineCode\Support
*/

namespace DataMachineCode\Support;

defined('ABSPATH') || exit;

class SecretRedactor {

/**
* Redact secret-looking values from arbitrary text.
*/
public static function redact( string $value, array $secrets = array() ): string {
foreach ( self::normalize_secrets($secrets) as $secret ) {
$value = str_replace($secret, '[redacted]', $value);
}

$value = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/', '[redacted]', $value) ?? $value;
$value = preg_replace('/\b(sk-[A-Za-z0-9_-]{12,})\b/', '[redacted]', $value) ?? $value;
$value = preg_replace('/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/', '[redacted]', $value) ?? $value;
$value = preg_replace('/\b(Bearer)\s+[A-Za-z0-9._~+\/-]{8,}/i', '$1 [redacted]', $value) ?? $value;

return preg_replace('/\b(authorization|token|password|secret|api[_-]?key)\s*[:=]\s*\S+/i', '$1: [redacted]', $value) ?? $value;
}

/**
* Normalize explicit secret values that must be redacted.
*
* @param array<int,string> $secrets Secret values.
* @return string[]
*/
private static function normalize_secrets( array $secrets ): array {
$values = array();
foreach ( $secrets as $value ) {
if ( strlen(trim($value)) >= 8 ) {
$values[] = trim($value);
}
}

return array_values(array_unique($values));
}
}
89 changes: 82 additions & 7 deletions tests/smoke-cli-channel-transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function get_option( string $key, mixed $default_value = false ): mixed
}

include __DIR__ . '/../inc/Environment.php';
include __DIR__ . '/../inc/Support/SecretRedactor.php';
include __DIR__ . '/../inc/Channels/CliChannelRegistry.php';
include __DIR__ . '/../inc/Channels/CliChannelTransport.php';

Expand All @@ -112,13 +113,22 @@ function get_option( string $key, mixed $default_value = false ): mixed

// Resolve standard stub binaries. Bail with a clear diagnostic if
// the host is missing them — the runtime needs real subprocess capability.
$echo_bin = '/bin/echo';
$true_bin = '/bin/true';
$false_bin = '/bin/false';
$sleep_bin = '/bin/sleep';
foreach ( array( $echo_bin, $true_bin, $false_bin, $sleep_bin ) as $candidate ) {
if (! is_executable($candidate) ) {
echo " [SKIP] stub binary {$candidate} not present; smoke cannot run on this host\n";
$resolve_bin = static function ( array $candidates ): ?string {
foreach ( $candidates as $candidate ) {
if (is_executable($candidate) ) {
return $candidate;
}
}
return null;
};
$echo_bin = $resolve_bin(array( '/bin/echo', '/usr/bin/echo' ));
$true_bin = $resolve_bin(array( '/bin/true', '/usr/bin/true' ));
$false_bin = $resolve_bin(array( '/bin/false', '/usr/bin/false' ));
$sleep_bin = $resolve_bin(array( '/bin/sleep', '/usr/bin/sleep' ));
$php_bin = PHP_BINARY;
foreach ( array( $echo_bin, $true_bin, $false_bin, $sleep_bin, $php_bin ) as $candidate ) {
if (! is_string($candidate) || ! is_executable($candidate) ) {
echo " [SKIP] required stub binary not present; smoke cannot run on this host\n";
exit(0);
}
}
Expand All @@ -136,12 +146,14 @@ function get_option( string $key, mixed $default_value = false ): mixed
'args' => array( '--', '{recipient}', '{message}' ),
'detach' => false,
'timeout' => 5,
'env_from' => array( 'CHILD_SECRET_TOKEN' => 'PARENT_SECRET_TOKEN' ),
);
$normalized = \DataMachineCode\Channels\CliChannelRegistry::normalize_entry($valid_entry);
$assert('valid entry normalizes', is_array($normalized) && $normalized['command'] === $echo_bin);
$assert('normalized entry has args array', is_array($normalized['args'] ?? null) && count($normalized['args']) === 3);
$assert('normalized entry preserves detach false', false === ( $normalized['detach'] ?? null ));
$assert('normalized entry preserves timeout', 5 === ( $normalized['timeout'] ?? null ));
$assert('normalized entry preserves generic env_from references', array( 'CHILD_SECRET_TOKEN' => 'PARENT_SECRET_TOKEN' ) === ( $normalized['env_from'] ?? null ));

$bad_no_command = \DataMachineCode\Channels\CliChannelRegistry::normalize_entry(array( 'args' => array() ));
$assert('missing command is rejected', null === $bad_no_command);
Expand Down Expand Up @@ -380,6 +392,69 @@ function get_option( string $key, mixed $default_value = false ): mixed
$assert('detached metadata mode=detached', 'detached' === ( $metadata['mode'] ?? null ));
}

// ---------------------------------------------------------------
// Generic env projection: caller-provided parent env references are
// projected into child env and secret-like values are redacted.
// ---------------------------------------------------------------

putenv('DMC_TEST_PARENT_BASE_URL=https://runtime.example/v1');
putenv('DMC_TEST_PARENT_SECRET_TOKEN=caller-secret-token-1234567890');
$datamachine_code_test_options['datamachine_code_cli_channels'] = array(
'projected-env' => array(
'command' => $php_bin,
'args' => array(
'-r',
'$ok = getenv("CHILD_BASE_URL") === "https://runtime.example/v1" && getenv("CHILD_API_TOKEN") === "caller-secret-token-1234567890"; echo "CHILD_BASE_URL=" . getenv("CHILD_BASE_URL") . "\n"; echo "CHILD_API_TOKEN=" . getenv("CHILD_API_TOKEN") . "\n"; exit($ok ? 0 : 7);',
),
'detach' => false,
'timeout' => 5,
'env' => array(
'STATIC_ENV' => 'static-value',
),
'env_from' => array(
'CHILD_BASE_URL' => 'DMC_TEST_PARENT_BASE_URL',
'CHILD_API_TOKEN' => 'DMC_TEST_PARENT_SECRET_TOKEN',
),
),
'static-env' => array(
'command' => $php_bin,
'args' => array(
'-r',
'exit(getenv("STATIC_SECRET_TOKEN") === "static-secret-value" ? 0 : 9);',
),
'detach' => false,
'timeout' => 5,
'env' => array(
'STATIC_SECRET_TOKEN' => 'static-secret-value',
),
),
);

$projected = \DataMachineCode\Channels\CliChannelTransport::execute(
array(
'channel' => 'projected-env',
'recipient' => 'r',
'message' => 'm',
)
);
$assert('projected env dispatch succeeds', is_array($projected) && true === ( $projected['sent'] ?? false ));
if (is_array($projected) ) {
$stdout = (string) ( $projected['metadata']['stdout'] ?? '' );
$assert('caller-provided base URL reference is projected', str_contains($stdout, 'CHILD_BASE_URL=https://runtime.example/v1'));
$assert('projected secret value is redacted from captured stdout', str_contains($stdout, '[redacted]') && ! str_contains($stdout, 'caller-secret-token-1234567890'));
}

putenv('DMC_TEST_PARENT_BASE_URL');
putenv('DMC_TEST_PARENT_SECRET_TOKEN');
$static_env = \DataMachineCode\Channels\CliChannelTransport::execute(
array(
'channel' => 'static-env',
'recipient' => 'r',
'message' => 'm',
)
);
$assert('static configured env continues to work', is_array($static_env) && true === ( $static_env['sent'] ?? false ));

// ---------------------------------------------------------------
// Summary
// ---------------------------------------------------------------
Expand Down
Loading
Loading