diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 0d3ff49..bdaea4f 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -181,11 +181,17 @@ jobs: id: psalm run: | set +e - vendor/bin/psalm --output-format=json --show-info=false > psalm.json 2>&1 + # Don't redirect stderr to stdout to avoid corrupting JSON output + vendor/bin/psalm --output-format=json --show-info=false > psalm.json EXIT_CODE=$? # Generate SARIF for GitHub Security - vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif 2>&1 || true + vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif || true + + # Fix invalid SARIF generated by Psalm (locations with 0) + if [ -f psalm.sarif ]; then + jq 'walk(if type == "object" and .region? then .region |= with_entries(if (.key | test("Line|Column")) and .value < 1 then .value = 1 else . end) else . end)' psalm.sarif > psalm.sarif.tmp && mv psalm.sarif.tmp psalm.sarif + fi ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0") echo "errors=${ERRORS}" >> $GITHUB_OUTPUT @@ -200,7 +206,7 @@ jobs: - name: Upload Psalm SARIF if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: psalm.sarif category: psalm diff --git a/bad_sarif.json b/bad_sarif.json new file mode 100644 index 0000000..ef56a7f --- /dev/null +++ b/bad_sarif.json @@ -0,0 +1,32 @@ +{ + "runs": [ + { + "results": [ + { + "locations": [ + { + "physicalLocation": { + "region": { + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 0 + } + } + }, + { + "physicalLocation": { + "region": { + "startLine": 10, + "startColumn": 5, + "endLine": 10, + "endColumn": 15 + } + } + } + ] + } + ] + } + ] +} diff --git a/psalm.json b/psalm.json new file mode 100644 index 0000000..e69de29 diff --git a/psalm.log b/psalm.log new file mode 100644 index 0000000..e40e8b6 --- /dev/null +++ b/psalm.log @@ -0,0 +1,3 @@ +Psalm has detected issues in your platform: + +Psalm requires a PHP version ">= 8.3.16". You are running 8.3.6. diff --git a/psalm.stderr b/psalm.stderr new file mode 100644 index 0000000..e40e8b6 --- /dev/null +++ b/psalm.stderr @@ -0,0 +1,3 @@ +Psalm has detected issues in your platform: + +Psalm requires a PHP version ">= 8.3.16". You are running 8.3.6. diff --git a/psalm.stdout b/psalm.stdout new file mode 100644 index 0000000..e69de29 diff --git a/psalm_clean.json b/psalm_clean.json new file mode 100644 index 0000000..e69de29 diff --git a/psalm_verify.json b/psalm_verify.json new file mode 100644 index 0000000..e69de29 diff --git a/psalm_verify_null.json b/psalm_verify_null.json new file mode 100644 index 0000000..e69de29 diff --git a/src/Core/Input/Sanitiser.php b/src/Core/Input/Sanitiser.php index d96f30d..66f57ff 100644 --- a/src/Core/Input/Sanitiser.php +++ b/src/Core/Input/Sanitiser.php @@ -101,6 +101,13 @@ class Sanitiser */ protected array $schema = []; + /** + * Cache of pre-merged effective schemas for performance. + * + * @var array + */ + protected array $preparedSchema = []; + /** * Optional logger for audit logging. */ @@ -207,6 +214,8 @@ public function __construct( $this->normalizeUnicode = $normalizeUnicode; $this->maxLength = $maxLength; $this->allowedHtmlTags = $allowedHtmlTags; + + $this->prepareSchema(); } /** @@ -230,6 +239,7 @@ public function withSchema(array $schema): static { $clone = clone $this; $clone->schema = $schema; + $clone->prepareSchema(); return $clone; } @@ -449,9 +459,31 @@ protected function applyPresetToFields(string $presetName, array $fields): stati } } + $clone->prepareSchema(); + return $clone; } + /** + * Prepare effective schemas for all fields. + */ + protected function prepareSchema(): void + { + $globalSchema = $this->schema['*'] ?? []; + $this->preparedSchema = []; + + // Pre-calculate for known fields + foreach ($this->schema as $field => $fieldSchema) { + if ($field === '*') { + continue; + } + $this->preparedSchema[$field] = array_merge($globalSchema, $fieldSchema); + } + + // Store global schema for fallback + $this->preparedSchema['*'] = $globalSchema; + } + // ========================================================================= // TRANSFORMATION HOOKS // ========================================================================= @@ -688,11 +720,7 @@ protected function filterRecursive(array $input, string $path = ''): array protected function filterString(string $value, string $path, string $fieldName): string { $original = $value; - $fieldSchema = $this->schema[$fieldName] ?? []; - $globalSchema = $this->schema['*'] ?? []; - - // Merge global schema with field-specific schema (field takes precedence) - $effectiveSchema = array_merge($globalSchema, $fieldSchema); + $effectiveSchema = $this->preparedSchema[$fieldName] ?? $this->preparedSchema['*']; // Step 0: Apply before hooks $value = $this->applyBeforeHooks($value, $fieldName); diff --git a/test.sarif b/test.sarif new file mode 100644 index 0000000..ef56a7f --- /dev/null +++ b/test.sarif @@ -0,0 +1,32 @@ +{ + "runs": [ + { + "results": [ + { + "locations": [ + { + "physicalLocation": { + "region": { + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 0 + } + } + }, + { + "physicalLocation": { + "region": { + "startLine": 10, + "startColumn": 5, + "endLine": 10, + "endColumn": 15 + } + } + } + ] + } + ] + } + ] +} diff --git a/test_clean.sarif b/test_clean.sarif new file mode 100644 index 0000000..5639061 --- /dev/null +++ b/test_clean.sarif @@ -0,0 +1,32 @@ +{ + "runs": [ + { + "results": [ + { + "locations": [ + { + "physicalLocation": { + "region": { + "startLine": 1, + "startColumn": 1, + "endLine": 1, + "endColumn": 1 + } + } + }, + { + "physicalLocation": { + "region": { + "startLine": 10, + "startColumn": 5, + "endLine": 10, + "endColumn": 15 + } + } + } + ] + } + ] + } + ] +}