Skip to content

Commit 9a360e6

Browse files
authored
Contain import report artifacts (#864)
Contain imported and generated report artifacts within approved paths and complete the security remediation series.\n\nAI assistance: OpenAI GPT-5.6 Sol via OpenCode was used to integrate the security series, run verification, and prepare this merge; Chris Huber remains responsible for the change.
1 parent e37ef04 commit 9a360e6

11 files changed

Lines changed: 136 additions & 12 deletions

homeboy-test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"tests/smoke-contact-layout-transformer.php": { "environment": "standalone-php" },
1515
"tests/smoke-diagnostic-loss-classes.php": { "environment": "standalone-php" },
1616
"tests/smoke-entity-materializer-registry.php": { "environment": "standalone-php" },
17+
"tests/smoke-external-report-destinations.php": { "environment": "standalone-php" },
1718
"tests/smoke-export-theme-ability.php": { "environment": "standalone-php" },
1819
"tests/smoke-import-diagnostic-contract.php": { "environment": "standalone-php" },
1920
"tests/smoke-import-disposition.php": { "environment": "standalone-php" },

includes/abilities.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,9 @@ function static_site_importer_ability_export_theme( array $input ): array {
408408
if ( ! function_exists( 'static_site_importer_ability_import' ) ) {
409409
/** Canonical plan-first import dispatcher. */
410410
function static_site_importer_ability_import( array $input ): array {
411+
if ( array_key_exists( 'report', $input ) ) {
412+
return static_site_importer_ability_error( 'static_site_importer_report_destination_forbidden', 'Report destinations are owned by the importer and are not accepted through Abilities.' );
413+
}
411414
$source = isset( $input['source'] ) && is_array( $input['source'] ) ? $input['source'] : array();
412415
$type = (string) ( $source['type'] ?? '' );
413416
$operation = (string) ( $input['operation'] ?? 'apply' );
@@ -483,6 +486,9 @@ function static_site_importer_ability_import( array $input ): array {
483486
);
484487

485488
$args = Static_Site_Importer_Website_Artifact_Import_Input::normalize( $input );
489+
if ( isset( $GLOBALS['_static_site_importer_cli_report_destination'] ) ) {
490+
$args['report'] = (string) $GLOBALS['_static_site_importer_cli_report_destination'];
491+
}
486492
if ( 'plan' === $operation ) {
487493
return static_site_importer_ability_plan_artifact( $artifact, $args, $type, $provenance );
488494
}
@@ -497,6 +503,26 @@ function static_site_importer_ability_import( array $input ): array {
497503
}
498504
}
499505

506+
if ( ! function_exists( 'static_site_importer_cli_import' ) ) {
507+
/** Run an import with the explicit, local WP-CLI report output seam. */
508+
function static_site_importer_cli_import( array $input ): array {
509+
$report = isset( $input['report'] ) ? (string) $input['report'] : '';
510+
unset( $input['report'] );
511+
$source = isset( $input['source'] ) && is_array( $input['source'] ) ? $input['source'] : array();
512+
if ( 'url' === ( $source['type'] ?? '' ) ) {
513+
$input['report'] = $report;
514+
return static_site_importer_ability_import_url_operation( $input, $source );
515+
}
516+
517+
$GLOBALS['_static_site_importer_cli_report_destination'] = $report;
518+
try {
519+
return static_site_importer_ability_import( $input );
520+
} finally {
521+
unset( $GLOBALS['_static_site_importer_cli_report_destination'] );
522+
}
523+
}
524+
}
525+
500526
if ( ! function_exists( 'static_site_importer_ability_files_source' ) ) {
501527
/** Convert an internal website artifact to the public files source shape. */
502528
function static_site_importer_ability_files_source( array $artifact ): array {

includes/class-static-site-importer-theme-generator.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,15 @@ private static function materialize_compiled_website_artifact( array $artifact,
178178
if ( ! empty( $args['write_theme_report_artifacts'] ) ) {
179179
$report_destinations = array_merge( $report_destinations, array( $theme_dir . '/import-report.json', $theme_dir . '/import-validation-result.json', $theme_dir . '/finding-packets.json' ) );
180180
}
181+
$external_report_destinations = array();
181182
if ( ! empty( $args['report'] ) ) {
182-
$report_destinations[] = (string) $args['report'];
183-
$report_destinations[] = trailingslashit( dirname( (string) $args['report'] ) ) . 'import-validation-result.json';
184-
$report_destinations[] = trailingslashit( dirname( (string) $args['report'] ) ) . 'finding-packets.json';
183+
$external_report_destinations[] = (string) $args['report'];
184+
$external_report_destinations[] = trailingslashit( dirname( (string) $args['report'] ) ) . 'import-validation-result.json';
185+
$external_report_destinations[] = trailingslashit( dirname( (string) $args['report'] ) ) . 'finding-packets.json';
186+
$report_destinations = array_merge( $report_destinations, $external_report_destinations );
185187
}
186188
$args['report_destinations'] = $report_destinations;
189+
$args['external_report_destinations'] = $external_report_destinations;
187190
$prepared = Static_Site_Importer_WordPress_Site_Plan_Materializer::prepare( $plan, $args );
188191
if ( 'prepared' !== ( $prepared['status'] ?? '' ) ) {
189192
$receipt = isset( $prepared['receipt'] ) && is_array( $prepared['receipt'] ) ? $prepared['receipt'] : array();
@@ -632,6 +635,11 @@ static function ( array $page ) use ( $receipt ): array {
632635
$external_dir = dirname( $external_report_path );
633636
$external_validation_result_path = trailingslashit( $external_dir ) . 'import-validation-result.json';
634637
$external_finding_packets_path = trailingslashit( $external_dir ) . 'finding-packets.json';
638+
foreach ( array( $external_report_path, $external_validation_result_path, $external_finding_packets_path ) as $path ) {
639+
if ( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $path ) ) {
640+
throw new RuntimeException( 'External report destination changed after preflight.' );
641+
}
642+
}
635643
self::write_plan_projection( $external_report_path, $report );
636644
self::write_plan_projection( $external_validation_result_path, $validation );
637645
self::write_plan_projection( $external_finding_packets_path, $findings );

includes/class-static-site-importer-validation-runtime.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public static function validate_artifact( array $input ) {
9595
'overwrite' => true,
9696
'materialize_dependencies' => true,
9797
'require_proven_dynamic_client_assets' => true,
98+
'report' => (string) $input['report'],
9899
)
99100
);
100101
if ( isset( $input['runtime_lifecycle_phase'] ) ) {

includes/class-static-site-importer-website-artifact-import-input.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class Static_Site_Importer_Website_Artifact_Import_Input {
3434
'seed_entities' => array( 'type' => 'boolean' ),
3535
'products_manifest' => array( 'type' => 'object' ),
3636
'commerce_context' => array( 'type' => 'object' ),
37-
'report' => array( 'type' => 'string' ),
3837
'write_theme_report_artifacts' => array( 'type' => 'boolean' ),
3938
'asset_materialization_policy' => array(
4039
'type' => 'string',
@@ -74,7 +73,6 @@ public static function normalize( array $input, array $defaults = array() ): arr
7473
'seed_entities' => false,
7574
'products_manifest' => array(),
7675
'commerce_context' => array(),
77-
'report' => '',
7876
'write_theme_report_artifacts' => false,
7977
'asset_materialization_policy' => '',
8078
'asset_map' => array(),
@@ -94,7 +92,7 @@ public static function normalize( array $input, array $defaults = array() ): arr
9492
}
9593
}
9694

97-
foreach ( array( 'slug', 'name', 'site_title', 'stale_page_action', 'report', 'asset_materialization_policy', 'client_script_policy' ) as $field ) {
95+
foreach ( array( 'slug', 'name', 'site_title', 'stale_page_action', 'asset_materialization_policy', 'client_script_policy' ) as $field ) {
9896
$values[ $field ] = is_scalar( $values[ $field ] ) ? (string) $values[ $field ] : '';
9997
}
10098
foreach ( array( 'activate', 'overwrite', 'disable_smilies', 'fail_on_quality', 'allow_missing_woocommerce', 'allow_missing_jetpack', 'materialize_dependencies', 'require_proven_dynamic_client_assets', 'seed_entities', 'write_theme_report_artifacts', 'client_script_isolated' ) as $field ) {

includes/class-static-site-importer-wordpress-site-plan-materializer.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public static function prepare( array $plan, array $args = array() ): array {
6060
'skipped' => array(),
6161
'existing_matches' => array( 'pages' => array() ),
6262
'report_destinations' => isset( $args['report_destinations'] ) && is_array( $args['report_destinations'] ) ? $args['report_destinations'] : array(),
63+
'external_report_destinations' => isset( $args['external_report_destinations'] ) && is_array( $args['external_report_destinations'] ) ? $args['external_report_destinations'] : array(),
6364
);
6465

6566
try {
@@ -342,6 +343,7 @@ private static function refresh_prepared_destination( array $prepared ): array {
342343
'skipped' => array(),
343344
'existing_matches' => array( 'pages' => array() ),
344345
'report_destinations' => isset( $args['report_destinations'] ) && is_array( $args['report_destinations'] ) ? $args['report_destinations'] : array(),
346+
'external_report_destinations' => isset( $args['external_report_destinations'] ) && is_array( $args['external_report_destinations'] ) ? $args['external_report_destinations'] : array(),
345347
'theme_dir' => $theme_dir,
346348
'theme' => array(
347349
'slug' => $slug,
@@ -452,6 +454,33 @@ private static function preflight_state( array &$state, bool $overwrite, string
452454
throw new InvalidArgumentException( 'report_destination_not_ready' );
453455
}
454456
}
457+
foreach ( $state['external_report_destinations'] ?? array() as $path ) {
458+
if ( ! self::safe_external_report_destination( $path ) ) {
459+
throw new InvalidArgumentException( 'report_destination_not_ready' );
460+
}
461+
}
462+
}
463+
464+
/**
465+
* External report output is a CLI-only operator seam. Every artifact must be
466+
* a new file directly beneath one existing, physical directory.
467+
*/
468+
public static function safe_external_report_destination( $path ): bool {
469+
if ( ! is_string( $path ) || '' === $path || str_contains( str_replace( '\\', '/', $path ), '/../' ) || str_starts_with( str_replace( '\\', '/', $path ), '../' ) || str_ends_with( str_replace( '\\', '/', $path ), '/..' ) || str_contains( str_replace( '\\', '/', $path ), '/./' ) ) {
470+
return false;
471+
}
472+
$parent = dirname( $path );
473+
if ( ! is_dir( $parent ) || is_link( $path ) || file_exists( $path ) || is_link( $parent ) || ! is_writable( $parent ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Preflights explicit CLI report destinations before atomic local writes.
474+
return false;
475+
}
476+
while ( DIRECTORY_SEPARATOR !== $parent && '.' !== $parent ) {
477+
if ( is_link( $parent ) ) {
478+
return false;
479+
}
480+
$parent = dirname( $parent );
481+
}
482+
483+
return true;
455484
}
456485

457486
/** @param array<string,mixed> $page @param array<string,int> $source_ids */

static-site-importer.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ static function ( array $args, array $assoc_args ): void {
216216
'asset_materialization_policy' => isset( $assoc_args['asset-materialization-policy'] ) ? (string) $assoc_args['asset-materialization-policy'] : '',
217217
);
218218

219-
$result = static_site_importer_ability_import(
219+
$result = static_site_importer_cli_import(
220220
array_merge(
221221
$input,
222222
array( 'source' => static_site_importer_ability_files_source( $input['artifact'] ) )
@@ -254,10 +254,10 @@ static function ( array $args, array $assoc_args ): void {
254254
'allow_missing_woocommerce' => isset( $assoc_args['allow-missing-woocommerce'] ),
255255
'report' => isset( $assoc_args['report'] ) ? (string) $assoc_args['report'] : '',
256256
);
257-
$result = static_site_importer_ability_import( $input );
257+
$result = static_site_importer_cli_import( $input );
258258
while ( ! empty( $result['success'] ) && ! empty( $result['continuation'] ) ) {
259259
$input['source']['import_id'] = (string) ( $result['import_id'] ?? '' );
260-
$result = static_site_importer_ability_import( $input );
260+
$result = static_site_importer_cli_import( $input );
261261
}
262262
if ( empty( $result['success'] ) ) {
263263
$error = isset( $result['error'] ) && is_array( $result['error'] ) ? $result['error'] : array();

test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
{ "path": "tests/smoke-contact-layout-transformer.php", "environment": "standalone-php" },
2020
{ "path": "tests/smoke-diagnostic-loss-classes.php", "environment": "standalone-php" },
2121
{ "path": "tests/smoke-entity-materializer-registry.php", "environment": "standalone-php" },
22+
{ "path": "tests/smoke-external-report-destinations.php", "environment": "standalone-php" },
2223
{ "path": "tests/smoke-export-theme-ability.php", "environment": "standalone-php" },
2324
{ "path": "tests/smoke-import-diagnostic-contract.php", "environment": "standalone-php" },
2425
{ "path": "tests/smoke-import-disposition.php", "environment": "standalone-php" },
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
/**
3+
* Regression coverage for the explicit CLI report destination preflight.
4+
*
5+
* Run from the repository root:
6+
* php tests/smoke-external-report-destinations.php
7+
*
8+
* @package StaticSiteImporter
9+
*/
10+
11+
if ( ! defined( 'ABSPATH' ) ) {
12+
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
13+
}
14+
15+
require_once dirname( __DIR__ ) . '/includes/class-static-site-importer-wordpress-site-plan-materializer.php';
16+
17+
$root = sys_get_temp_dir() . '/ssi-report-destination-' . uniqid( '', true );
18+
mkdir( $root, 0700, true );
19+
$root = (string) realpath( $root );
20+
$safe = $root . '/import-report.json';
21+
22+
$assert = static function ( bool $condition, string $message ): void {
23+
if ( ! $condition ) {
24+
throw new RuntimeException( $message );
25+
}
26+
};
27+
28+
$assert( Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $safe ), 'new report destination should be accepted' );
29+
file_put_contents( $safe, 'existing' );
30+
$assert( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $safe ), 'existing report target must be rejected' );
31+
unlink( $safe );
32+
file_put_contents( $root . '/finding-packets.json', 'existing sidecar' );
33+
$assert( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $root . '/finding-packets.json' ), 'existing report sidecar must be rejected' );
34+
unlink( $root . '/finding-packets.json' );
35+
$assert( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $root . '/missing/import-report.json' ), 'missing report parent must be rejected' );
36+
$assert( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $root . '/../escape.json' ), 'traversal report destination must be rejected' );
37+
38+
if ( function_exists( 'symlink' ) && symlink( sys_get_temp_dir(), $root . '/linked' ) ) {
39+
$assert( ! Static_Site_Importer_WordPress_Site_Plan_Materializer::safe_external_report_destination( $root . '/linked/import-report.json' ), 'symlink report parent must be rejected' );
40+
unlink( $root . '/linked' );
41+
}
42+
43+
rmdir( $root );
44+
echo "External report destination smoke passed.\n";

tests/smoke-rest-import-normalization.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
'seed_entities' => true,
4141
'products_manifest' => array( 'products' => array( array( 'sku' => 'rest-product' ) ) ),
4242
'commerce_context' => array( 'currency' => 'USD' ),
43-
'report' => 'rest-report',
4443
'write_theme_report_artifacts' => true,
4544
'asset_materialization_policy' => 'use_map',
4645
'asset_map' => array( 'logo.svg' => 'https://example.test/logo.svg' ),
@@ -95,6 +94,10 @@ static function ( $disposition, array $seen_artifact, array $seen_input, array $
9594
$assert( 'REST Import' === ( $captured['input']['name'] ?? '' ), 'name-is-rest-sanitized' );
9695
$assert( 'rest-normalization-smoke' === ( $captured['input']['source_metadata']['request_id'] ?? '' ), 'caller-source-metadata-is-preserved' );
9796
$assert( 'static_site_importer_block' === ( $captured['input']['source_metadata']['source'] ?? '' ), 'rest-source-metadata-is-applied' );
97+
$assert( ! array_key_exists( 'report', $captured['input'] ?? array() ), 'rest-rejects-report-destination' );
98+
99+
$rejected_report_params = static_site_importer_rest_import_args( array( 'report' => '/tmp/report.json' ) );
100+
$assert( ! array_key_exists( 'report', $rejected_report_params ), 'rest-schema-rejects-report-destination' );
98101

99102
if ( ! empty( $failures ) ) {
100103
fwrite( STDERR, implode( "\n", $failures ) . "\n" );

0 commit comments

Comments
 (0)