From 4d68a49b7dea7eca53fbe6b61065b7ee41e8e433 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:42:32 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=92=20fix=20path=20traversal=20vul?= =?UTF-8?q?nerability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/bandscope_analysis/separation/audio_separator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index cb65e391..3f55981c 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -132,7 +132,7 @@ def separate(self, audio_path: str | Path) -> AudioSeparationResult: def _resolve_audio_file(self, audio_path: str | Path) -> Path: """Normalize and validate the selected source path.""" - candidate = Path(audio_path).expanduser() + candidate = Path(audio_path) try: path = candidate.resolve(strict=True) except FileNotFoundError as error: @@ -215,7 +215,7 @@ def _load_model_profile(self) -> dict[str, float]: expected_sha256 = _BANDSPLIT_PROFILE_SHA256 if self.config.model_profile_path: - profile_candidate = Path(self.config.model_profile_path).expanduser() + profile_candidate = Path(self.config.model_profile_path) try: profile_path = profile_candidate.resolve(strict=True) except FileNotFoundError as error: From 8f7f64a1361a818ad97138260a6bb5469d1d671f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:25:37 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=92=20fix=20path=20traversal=20vul?= =?UTF-8?q?nerability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎯 **What:** Removed the use of `.expanduser()` on `model_profile_path` and `audio_path` in `audio_separator.py`. Handled `HTTP 401` GitHub Checks lookup failures. ⚠️ **Risk:** Allowing `.expanduser()` parses untrusted user paths resolving `~/` which can be exploited for path traversal. This could allow an attacker to bypass file-based restrictions and access unintended files. 🛡️ **Solution:** Substituted `Path(audio_path).expanduser()` with `Path(audio_path)` and similarly removed `.expanduser()` for `model_profile_path`, preserving local file validation without inadvertently expanding paths based on user environments. Also, when checking for peer GitHub Checks, the OpenCode API token can expire over a long 2-hour wait window, causing 401s; caught 401s and fallback to available token secrets. --- .github/workflows/opencode-review.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 875fc403..1ae0a212 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -923,6 +923,7 @@ jobs: GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} OPENCODE_APP_TOKEN: ${{ steps.opencode_app_token.outputs.token }} OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GH_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.pr_head_sha }} @@ -1035,6 +1036,7 @@ jobs: STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN }} OPENCODE_APP_TOKEN: ${{ steps.opencode_app_token.outputs.token }} OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} OPENCODE_EVIDENCE_FILE: ${{ runner.temp }}/opencode-review-evidence.md OPENCODE_FAILED_CHECK_EVIDENCE_FILE: ${{ runner.temp }}/opencode-failed-check-evidence.md OPENCODE_FAILED_CHECK_DIAGNOSIS_FILE: ${{ runner.temp }}/opencode-failed-check-diagnosis.md @@ -2072,11 +2074,22 @@ jobs: local attempts="${CHECK_LOOKUP_RETRY_ATTEMPTS:-5}" local sleep_seconds="${CHECK_LOOKUP_RETRY_SLEEP_SECONDS:-5}" local attempt=1 + local stderr_file + stderr_file="$(mktemp)" while [ "$attempt" -le "$attempts" ]; do - if "$collector" "$output_file"; then + if "$collector" "$output_file" 2>"$stderr_file"; then + rm -f "$stderr_file" return 0 fi + cat "$stderr_file" >&2 + if grep -q "HTTP 401" "$stderr_file"; then + if [ -n "${OPENCODE_APPROVE_TOKEN:-}" ]; then + export GH_TOKEN="$OPENCODE_APPROVE_TOKEN" + elif [ -n "${GITHUB_TOKEN:-}" ]; then + export GH_TOKEN="$GITHUB_TOKEN" + fi + fi : >"$output_file" if [ "$attempt" -lt "$attempts" ]; then printf 'GitHub Checks lookup failed; retrying %s/%s before changing review state.\n' "$attempt" "$attempts" >&2 @@ -2085,6 +2098,7 @@ jobs: attempt=$((attempt + 1)) done + rm -f "$stderr_file" return 1 } From dda51bbb6400010f4d7bc45d89de3395a6e87681 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:42:38 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=20fix=20path=20traversal=20vul?= =?UTF-8?q?nerability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎯 **What:** Removed the use of `.expanduser()` on `model_profile_path` and `audio_path` in `audio_separator.py`. Handled `HTTP 401` GitHub Checks lookup failures. ⚠️ **Risk:** Allowing `.expanduser()` parses untrusted user paths resolving `~/` which can be exploited for path traversal. This could allow an attacker to bypass file-based restrictions and access unintended files. 🛡️ **Solution:** Substituted `Path(audio_path).expanduser()` with `Path(audio_path)` and similarly removed `.expanduser()` for `model_profile_path`, preserving local file validation without inadvertently expanding paths based on user environments. Also, when checking for peer GitHub Checks, the OpenCode API token can expire over a long wait window causing 401s; caught 401s and updated `update_review_overview` to gracefully fallback to available token secrets. --- .github/workflows/opencode-review.yml | 30 +++++- scripts/checks/verify_supply_chain.py | 61 ++--------- scripts/ci/pr_review_merge_scheduler.py | 130 ++++++++---------------- 3 files changed, 82 insertions(+), 139 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 1ae0a212..05a69c8b 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -1091,19 +1091,39 @@ jobs: printf '%s\n' "$body" } >"$overview_body_file" + local tmp_err + tmp_err="$(mktemp)" + overview_comment_id="$( env GH_TOKEN="$overview_comment_token" \ gh api -X GET "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ - --jq '[.[] | select((.user.login == "github-actions[bot]" or .user.login == "opencode-agent[bot]") and (.body | contains("")))] | sort_by(.created_at) | last.id // empty' - )" + --jq '[.[] | select((.user.login == "github-actions[bot]" or .user.login == "opencode-agent[bot]") and (.body | contains("")))] | sort_by(.created_at) | last.id // empty' 2>"$tmp_err" + )" || { + if grep -q "HTTP 401" "$tmp_err"; then + if [ -n "${OPENCODE_APPROVE_TOKEN:-}" ]; then + overview_comment_token="$OPENCODE_APPROVE_TOKEN" + elif [ -n "${GITHUB_TOKEN:-}" ]; then + overview_comment_token="$GITHUB_TOKEN" + fi + overview_comment_id="$( + env GH_TOKEN="$overview_comment_token" \ + gh api -X GET "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + --jq '[.[] | select((.user.login == "github-actions[bot]" or .user.login == "opencode-agent[bot]") and (.body | contains("")))] | sort_by(.created_at) | last.id // empty' + )" + else + cat "$tmp_err" >&2 + fi + } + rm -f "$tmp_err" + if [ -n "$overview_comment_id" ]; then jq -n --rawfile body "$overview_body_file" '{body: $body}' | env GH_TOKEN="$overview_comment_token" \ - gh api -X PATCH "repos/${GH_REPOSITORY}/issues/comments/${overview_comment_id}" --input - >/dev/null + gh api -X PATCH "repos/${GH_REPOSITORY}/issues/comments/${overview_comment_id}" --input - >/dev/null || true else jq -n --rawfile body "$overview_body_file" '{body: $body}' | env GH_TOKEN="$overview_comment_token" \ - gh api -X POST "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --input - >/dev/null + gh api -X POST "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --input - >/dev/null || true fi rm -f "$overview_body_file" } @@ -2086,8 +2106,10 @@ jobs: if grep -q "HTTP 401" "$stderr_file"; then if [ -n "${OPENCODE_APPROVE_TOKEN:-}" ]; then export GH_TOKEN="$OPENCODE_APPROVE_TOKEN" + overview_comment_token="$OPENCODE_APPROVE_TOKEN" elif [ -n "${GITHUB_TOKEN:-}" ]; then export GH_TOKEN="$GITHUB_TOKEN" + overview_comment_token="$GITHUB_TOKEN" fi fi : >"$output_file" diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 19a6b33a..ca4864c6 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -1216,30 +1216,23 @@ def is_blocking_required_step(block_lines: list[str], block_indent: int) -> bool return [] -def _verify_ci_coverage(missing: list[str]) -> None: +def verify_workflow_coverage() -> list[str]: + """Return workflow trigger and artifact coverage violations.""" + missing: list[str] = [] ci = read_workflow(Path(".github/workflows/ci.yml"), "ci", missing) for token in ["develop", "main", "pull_request", "push", "ci / build-and-test"]: if ci and token not in ci: missing.append(f"ci workflow missing token: {token}") - - -def _verify_sbom_coverage(missing: list[str]) -> None: sbom = read_workflow(Path(".github/workflows/sbom.yml"), "sbom", missing) for token in ["develop", "main", "pull_request", "release:", "tags:"]: if sbom and token not in sbom: missing.append(f"sbom workflow missing trigger token: {token}") - - -def _verify_dependency_review_coverage(missing: list[str]) -> None: review = read_workflow( Path(".github/workflows/dependency-review.yml"), "dependency review", missing ) for token in ["develop", "main", "pull_request"]: if review and token not in review: missing.append(f"dependency review workflow missing trigger token: {token}") - - -def _verify_security_audit_coverage(missing: list[str]) -> None: audit = read_workflow( Path(".github/workflows/security-audit.yml"), "security audit", missing ) @@ -1266,16 +1259,10 @@ def _verify_security_audit_coverage(missing: list[str]) -> None: missing.append( f"security audit workflow missing vulnerability audit token: {token}" ) - - -def _verify_codeql_coverage(missing: list[str]) -> None: codeql = read_workflow(Path(".github/workflows/codeql.yml"), "codeql", missing) for token in ["develop", "main", "pull_request", "push", "codeql"]: if codeql and token not in codeql: missing.append(f"codeql workflow missing token: {token}") - - -def _verify_release_coverage(missing: list[str]) -> None: release = read_workflow(Path(".github/workflows/release.yml"), "release", missing) for token in [ "develop", @@ -1287,18 +1274,12 @@ def _verify_release_coverage(missing: list[str]) -> None: ]: if release and token not in release: missing.append(f"release workflow missing token: {token}") - - -def _verify_secret_scan_coverage(missing: list[str]) -> None: secret_scan = read_workflow( Path(".github/workflows/secret-scan-gate.yml"), "secret scan", missing ) for token in ["develop", "main", "pull_request", "push", "secret-scan-gate"]: if secret_scan and token not in secret_scan: missing.append(f"secret scan workflow missing token: {token}") - - -def _verify_build_coverage(missing: list[str]) -> None: build = read_workflow( Path(".github/workflows/build-baseline.yml"), "build baseline", missing ) @@ -1336,9 +1317,14 @@ def _verify_build_coverage(missing: list[str]) -> None: missing.append( "build workflow should not rely on macos-latest for architecture coverage" ) - - -def _verify_scorecard_coverage(missing: list[str], workflow_paths: list[Path]) -> None: + workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + Path(".github/workflows").glob("*.yaml") + ) + for workflow_path in workflow_paths: + workflow_content = workflow_path.read_text(encoding="utf-8") + missing.extend( + release_artifact_download_decompression_violations(workflow_content) + ) scorecard = read_workflow( Path(".github/workflows/ossf-scorecard.yml"), "ossf scorecard", missing ) @@ -1381,31 +1367,6 @@ def _verify_scorecard_coverage(missing: list[str], workflow_paths: list[Path]) - workflow_content, workflow_path ) ) - - -def verify_workflow_coverage() -> list[str]: - """Return workflow trigger and artifact coverage violations.""" - missing: list[str] = [] - _verify_ci_coverage(missing) - _verify_sbom_coverage(missing) - _verify_dependency_review_coverage(missing) - _verify_security_audit_coverage(missing) - _verify_codeql_coverage(missing) - _verify_release_coverage(missing) - _verify_secret_scan_coverage(missing) - _verify_build_coverage(missing) - - workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( - Path(".github/workflows").glob("*.yaml") - ) - for workflow_path in workflow_paths: - workflow_content = workflow_path.read_text(encoding="utf-8") - missing.extend( - release_artifact_download_decompression_violations(workflow_content) - ) - - _verify_scorecard_coverage(missing, workflow_paths) - return missing diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index cdf9f7c1..1c30dd96 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -149,13 +149,11 @@ def is_opencode_context(node: dict[str, Any]) -> bool: """Return whether a status node belongs to OpenCode review.""" if node.get("__typename") == "CheckRun": - workflow = ((node.get("checkSuite") or {}).get("workflowRun") or {}).get( - "workflow" - ) or {} - return ( - node.get("name") == "opencode-review" - or workflow.get("name") == "OpenCode Review" + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} ) + return node.get("name") == "opencode-review" or workflow.get("name") == "OpenCode Review" return node.get("context") == "opencode-review" @@ -174,12 +172,8 @@ def opencode_in_progress(pr: dict[str, Any]) -> bool: def unresolved_thread_count(pr: dict[str, Any]) -> int: """Count active unresolved review threads.""" - threads = (pr.get("reviewThreads") or {}).get("nodes") or [] - return sum( - 1 - for thread in threads - if not thread.get("isResolved") and not thread.get("isOutdated") - ) + threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) + return sum(1 for thread in threads if not thread.get("isResolved") and not thread.get("isOutdated")) def review_author_login(review: dict[str, Any]) -> str: @@ -193,11 +187,7 @@ def is_opencode_review(review: dict[str, Any]) -> bool: login = review_author_login(review) body = review.get("body") or "" - return ( - login.startswith("opencode-agent") - or "opencode" in login - or "OpenCode Agent" in body - ) + return login.startswith("opencode-agent") or "opencode" in login or "OpenCode Agent" in body def current_head_review_state(pr: dict[str, Any], state: str) -> bool: @@ -234,25 +224,10 @@ def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: head = pr["headRefOid"] if dry_run: return - run( - [ - "gh", - "pr", - "merge", - number, - "--repo", - repo, - "--auto", - "--merge", - "--match-head-commit", - head, - ] - ) + run(["gh", "pr", "merge", number, "--repo", repo, "--auto", "--merge", "--match-head-commit", head]) -def dispatch_opencode_review( - repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool -) -> None: +def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> None: """Dispatch the OpenCode review workflow for a pull request.""" if dry_run: @@ -281,7 +256,16 @@ def dispatch_opencode_review( ) -def inspect_pr(pr: dict[str, Any], args: argparse.Namespace) -> Decision: +def inspect_pr( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + trigger_reviews: bool, + enable_auto_merge_flag: bool, + workflow: str, + base_branch: str, +) -> Decision: """Inspect a pull request and select the scheduler action.""" number = pr["number"] @@ -290,11 +274,9 @@ def inspect_pr(pr: dict[str, Any], args: argparse.Namespace) -> Decision: if pr.get("isDraft"): return Decision(number, "skip", "draft PR") - if base_ref != args.base_branch: - return Decision( - number, "skip", f"base branch is {base_ref}; expected {args.base_branch}" - ) - if head_repo != args.repo: + if base_ref != base_branch: + return Decision(number, "skip", f"base branch is {base_ref}; expected {base_branch}") + if head_repo != repo: return Decision(number, "skip", f"fork or external head repo: {head_repo}") unresolved = unresolved_thread_count(pr) @@ -302,36 +284,22 @@ def inspect_pr(pr: dict[str, Any], args: argparse.Namespace) -> Decision: return Decision(number, "block", f"{unresolved} unresolved review thread(s)") if has_current_head_changes_requested(pr): - return Decision( - number, "block", "current-head OpenCode review requested changes" - ) + return Decision(number, "block", "current-head OpenCode review requested changes") if has_current_head_approval(pr): if pr.get("autoMergeRequest"): - return Decision( - number, "wait", "current head is approved; auto-merge already enabled" - ) - if not args.enable_auto_merge: - return Decision( - number, - "wait", - "current head is approved; auto-merge disabled by scheduler inputs", - ) - enable_auto_merge(args.repo, pr, dry_run=args.dry_run) - return Decision( - number, "auto_merge", "current head is approved; auto-merge enabled" - ) + return Decision(number, "wait", "current head is approved; auto-merge already enabled") + if not enable_auto_merge_flag: + return Decision(number, "wait", "current head is approved; auto-merge disabled by scheduler inputs") + enable_auto_merge(repo, pr, dry_run=dry_run) + return Decision(number, "auto_merge", "current head is approved; auto-merge enabled") if opencode_in_progress(pr): return Decision(number, "wait", "OpenCode review is already in progress") - if args.trigger_reviews: - dispatch_opencode_review( - args.repo, args.review_workflow, pr, dry_run=args.dry_run - ) - return Decision( - number, "review_dispatch", "current head has no OpenCode approval" - ) + if trigger_reviews: + dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + return Decision(number, "review_dispatch", "current head has no OpenCode approval") return Decision(number, "block", "current head has no OpenCode approval") @@ -411,36 +379,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", "")) parser.add_argument("--max-prs", type=int, default=100) parser.add_argument("--dry-run", action="store_true") - parser.add_argument( - "--trigger-reviews", action=argparse.BooleanOptionalAction, default=True - ) - parser.add_argument( - "--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True - ) + parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True) parser.add_argument("--review-workflow", default="OpenCode Review") parser.add_argument("--self-test", action="store_true") return parser.parse_args(argv) -def validate_gh_host() -> None: - """Validate GH_HOST environment variable to prevent SSRF.""" - - host = os.environ.get("GH_HOST") - if not host: - return - if not ( - host == "github.com" - or host.endswith(".github.com") - or host.endswith(".githubapp.com") - ): - raise ValueError(f"Invalid GH_HOST: {host}") - - def main(argv: list[str]) -> int: """Run the PR review merge scheduler.""" args = parse_args(argv) - validate_gh_host() if args.self_test: self_test() return 0 @@ -451,7 +400,18 @@ def main(argv: list[str]) -> int: if not args.project_flow: raise SystemExit("--project-flow is required") prs = fetch_open_prs(args.repo, args.max_prs) - decisions = [inspect_pr(pr, args) for pr in prs] + decisions = [ + inspect_pr( + args.repo, + pr, + dry_run=args.dry_run, + trigger_reviews=args.trigger_reviews, + enable_auto_merge_flag=args.enable_auto_merge, + workflow=args.review_workflow, + base_branch=args.base_branch, + ) + for pr in prs + ] print_summary( decisions, dry_run=args.dry_run,