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
230 changes: 230 additions & 0 deletions .github/scripts/codeql-alert-summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Summarize CodeQL alerts for the ref analyzed by a branch probe workflow."""

from __future__ import annotations

import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Iterable


API_VERSION = "2026-03-10"
DEFAULT_POLL_ATTEMPTS = 12
DEFAULT_POLL_SECONDS = 10


@dataclass(frozen=True)
class GitHubContext:
api_url: str
repository: str
token: str
ref: str
sha: str


def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()


def _context() -> GitHubContext:
token = _env("GITHUB_TOKEN") or _env("GH_TOKEN")
repository = _env("GITHUB_REPOSITORY")
ref = _env("PULLBOX_CODEQL_PROBE_REF") or _env("GITHUB_REF")
sha = _env("PULLBOX_CODEQL_PROBE_SHA") or _env("GITHUB_SHA")
missing = [
name
for name, value in {
"GITHUB_TOKEN": token,
"GITHUB_REPOSITORY": repository,
"GITHUB_REF": ref,
"GITHUB_SHA": sha,
}.items()
if not value
]
if missing:
raise RuntimeError(f"Missing required environment values: {', '.join(missing)}")
return GitHubContext(
api_url=_env("GITHUB_API_URL", "https://api.github.com").rstrip("/"),
repository=repository,
token=token,
ref=ref,
sha=sha,
)


def _api_get(context: GitHubContext, path: str, params: dict[str, str]) -> list[dict[str, Any]]:
query = urllib.parse.urlencode(params)
url = f"{context.api_url}{path}?{query}"
items: list[dict[str, Any]] = []
while url:
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {context.token}",
"X-GitHub-Api-Version": API_VERSION,
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.loads(response.read().decode("utf-8"))
if isinstance(payload, list):
items.extend(item for item in payload if isinstance(item, dict))
else:
raise RuntimeError(f"Expected list response from GitHub API: {path}")
url = _next_link(response.headers.get("Link", ""))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub API request failed: {exc.code} {body}") from exc
return items


def _next_link(link_header: str) -> str:
for part in link_header.split(","):
url_part, _, rel_part = part.partition(";")
if 'rel="next"' not in rel_part:
continue
return url_part.strip().removeprefix("<").removesuffix(">")
return ""


def _latest_analysis(context: GitHubContext) -> dict[str, Any] | None:
owner, repo = context.repository.split("/", maxsplit=1)
path = f"/repos/{owner}/{repo}/code-scanning/analyses"
analyses = _api_get(context, path, {"per_page": "100", "tool_name": "CodeQL"})
matches = [
analysis
for analysis in analyses
if analysis.get("ref") == context.ref and analysis.get("commit_sha") == context.sha
]
if not matches:
return None
return sorted(matches, key=lambda item: item.get("created_at", ""), reverse=True)[0]


def _alerts_for_state(context: GitHubContext, state: str) -> list[dict[str, Any]]:
owner, repo = context.repository.split("/", maxsplit=1)
path = f"/repos/{owner}/{repo}/code-scanning/alerts"
return _api_get(
context,
path,
{
"state": state,
"per_page": "100",
"tool_name": "CodeQL",
"ref": context.ref,
},
)


def _poll_latest_analysis(context: GitHubContext) -> dict[str, Any] | None:
attempts = int(_env("PULLBOX_CODEQL_SUMMARY_POLL_ATTEMPTS", str(DEFAULT_POLL_ATTEMPTS)))
wait_seconds = int(_env("PULLBOX_CODEQL_SUMMARY_POLL_SECONDS", str(DEFAULT_POLL_SECONDS)))
for attempt in range(1, attempts + 1):
analysis = _latest_analysis(context)
if analysis is not None:
return analysis
if attempt < attempts:
print(
f"CodeQL analysis for {context.ref}@{context.sha[:12]} is not indexed yet; "
f"waiting {wait_seconds}s..."
)
time.sleep(wait_seconds)
return None


def _rule_rows(alerts: Iterable[dict[str, Any]]) -> list[tuple[str, str, int]]:
counts: Counter[tuple[str, str]] = Counter()
for alert in alerts:
rule = alert.get("rule") if isinstance(alert.get("rule"), dict) else {}
rule_id = str(rule.get("id") or "unknown")
name = str(rule.get("name") or rule_id)
counts[(rule_id, name)] += 1
return [(rule_id, name, count) for (rule_id, name), count in counts.most_common()]


def _markdown_table(rows: list[tuple[str, str, int]]) -> str:
if not rows:
return "_None._"
lines = ["| Rule | Name | Count |", "| --- | --- | ---: |"]
for rule_id, name, count in rows:
lines.append(f"| `{rule_id}` | {name} | {count} |")
return "\n".join(lines)


def _append_step_summary(markdown: str) -> None:
summary_path = _env("GITHUB_STEP_SUMMARY")
if not summary_path:
return
with open(summary_path, "a", encoding="utf-8") as summary:
summary.write(markdown)
summary.write("\n")


def main() -> int:
context = _context()
analysis = _poll_latest_analysis(context)
open_alerts = _alerts_for_state(context, "open")
dismissed_alerts = _alerts_for_state(context, "dismissed")
fixed_alerts = _alerts_for_state(context, "fixed")

lines = [
"## CodeQL Branch Probe",
"",
f"- Ref: `{context.ref}`",
f"- Commit: `{context.sha}`",
]
if analysis:
lines.extend(
[
f"- Analysis ID: `{analysis.get('id')}`",
f"- Rules evaluated: `{analysis.get('rules_count', 'unknown')}`",
f"- Raw results: `{analysis.get('results_count', 'unknown')}`",
]
)
else:
lines.append("- Analysis: not visible through the API before polling ended")

lines.extend(
[
f"- Open alerts: `{len(open_alerts)}`",
f"- Dismissed/triaged alerts: `{len(dismissed_alerts)}`",
f"- Fixed alerts on this ref: `{len(fixed_alerts)}`",
"",
"### Open Alerts By Rule",
_markdown_table(_rule_rows(open_alerts)),
"",
"### Dismissed/Triaged Alerts By Rule",
_markdown_table(_rule_rows(dismissed_alerts)),
]
)
output = "\n".join(lines)
print(output)
_append_step_summary(output)

fail_on_open = _env("PULLBOX_CODEQL_BRANCH_PROBE_FAIL_ON_OPEN").lower() == "true"
if fail_on_open and open_alerts:
print(
f"::error::CodeQL branch probe found {len(open_alerts)} open alert(s) "
f"for {context.ref}."
)
return 1
return 0


if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"::warning::Unable to summarize CodeQL branch alerts: {exc}", file=sys.stderr)
raise
59 changes: 59 additions & 0 deletions .github/workflows/codeql-branch-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
# Pullbox CodeQL Branch Probe
# Fast feedback for trusted security-cleanup branches without waiting for a
# default-branch merge to refresh the Security and quality dashboard.
#
# Security: All actions pinned to full SHA. No pull_request_target.
# This workflow intentionally does not run on pull_request.

name: CodeQL Branch Probe

on:
push:
branches:
- develop
- feature/code-scanning-*
- feature/codeql-*
- feature/security-*
workflow_dispatch:

permissions:
contents: read

concurrency:
group: codeql-branch-probe-${{ github.ref || github.run_id }}
cancel-in-progress: true

jobs:
codeql-branch-probe:
name: CodeQL Branch Probe
if: github.repository_visibility == 'public' || vars.PULLBOX_ENABLE_CODEQL == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
actions: read
security-events: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: python
queries: +security-extended
config-file: ./.github/codeql/codeql-config.yml

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:python"

- name: Summarize CodeQL alerts for refs/heads/<branch>
if: always()
env:
GITHUB_TOKEN: ${{ github.token }}
PULLBOX_CODEQL_PROBE_REF: ${{ github.ref }}
PULLBOX_CODEQL_PROBE_SHA: ${{ github.sha }}
PULLBOX_CODEQL_BRANCH_PROBE_FAIL_ON_OPEN: ${{ vars.PULLBOX_CODEQL_BRANCH_PROBE_FAIL_ON_OPEN }}
run: python .github/scripts/codeql-alert-summary.py
7 changes: 7 additions & 0 deletions src/pullbox/api/v1/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ def _validate_browsable_path(path: str, allowed_roots: Sequence[Path] | None = N
logger.warning("filesystem_path_blocked", requested_path=path[:100], reason="too_long")
return fallback

# Authenticated operator browser: the raw value is length/character checked,
# blocked-prefix checked below, and optionally clamped to explicit roots
# before any listing is returned.
# codeql[py/path-injection]
resolved = Path(sanitized).resolve()
resolved_str = str(resolved)

Expand All @@ -180,6 +184,9 @@ def _validate_browsable_path(path: str, allowed_roots: Sequence[Path] | None = N
return fallback

# Fallback if path doesn't exist
# ``resolved`` has passed the browser safety checks above; this probe only
# decides whether to fall back to a safe root instead of returning content.
# codeql[py/path-injection]
if not resolved.exists() or not resolved.is_dir():
return fallback

Expand Down
29 changes: 29 additions & 0 deletions src/pullbox/core/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from __future__ import annotations

import hashlib
import hmac

from pullbox.core.config_resolver import get_application_secret

API_KEY_HASH_PREFIX = "pb_kh2_"
API_KEY_PREFIX = "pb_k1_"
API_KEY_RANDOM_HEX_CHARS = 64
API_KEY_LENGTH = len(API_KEY_PREFIX) + API_KEY_RANDOM_HEX_CHARS
Expand All @@ -12,9 +16,34 @@

def hash_api_key(raw_key: str) -> str:
"""Return the database hash for a raw API key."""
digest = hmac.new(
get_application_secret().encode("utf-8"),
raw_key.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{API_KEY_HASH_PREFIX}{digest}"


def legacy_hash_api_key(raw_key: str) -> str:
"""Return the legacy unpeppered API-key hash for compatibility upgrades."""
# Legacy rows from pre-public builds used a deterministic SHA-256 lookup hash.
# Keep this only for one-time validation and upgrade to the HMAC form.
# codeql[py/weak-sensitive-data-hashing]
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()


def api_key_hash_candidates(raw_key: str) -> tuple[str, ...]:
"""Return lookup hashes in preferred order for an API key."""
current_hash = hash_api_key(raw_key)
legacy_hash = legacy_hash_api_key(raw_key)
return (current_hash, legacy_hash)


def is_legacy_api_key_hash(key_hash: str) -> bool:
"""Return whether a stored API-key hash uses the legacy format."""
return not key_hash.startswith(API_KEY_HASH_PREFIX)


def is_well_formed_api_key(raw_key: str) -> bool:
"""Return True when a key has the expected Pullbox API-key envelope."""
return raw_key.startswith(API_KEY_PREFIX) and len(raw_key) == API_KEY_LENGTH
Expand Down
17 changes: 14 additions & 3 deletions src/pullbox/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from pullbox.core.api_keys import API_KEY_PREFIX, hash_api_key, is_well_formed_api_key
from pullbox.core.api_keys import (
API_KEY_PREFIX,
api_key_hash_candidates,
hash_api_key,
is_legacy_api_key_hash,
is_well_formed_api_key,
)
from pullbox.core.config_resolver import get_application_secret
from pullbox.core.exceptions import AuthenticationError
from pullbox.core.password_policy import MAX_PASSWORD_BYTES
Expand Down Expand Up @@ -127,10 +133,13 @@ async def validate_api_key(session: AsyncSession, raw_key: str) -> User | None:
if not is_well_formed_api_key(raw_key):
return None

key_hash = hash_api_key(raw_key)
current_key_hash = hash_api_key(raw_key)

result = await session.execute(
select(APIKey).where(APIKey.key_hash == key_hash, APIKey.is_active.is_(True))
select(APIKey).where(
APIKey.key_hash.in_(api_key_hash_candidates(raw_key)),
APIKey.is_active.is_(True),
)
)
api_key = result.scalar_one_or_none()

Expand All @@ -145,6 +154,8 @@ async def validate_api_key(session: AsyncSession, raw_key: str) -> User | None:
return None

api_key.last_used_at = datetime.now(UTC)
if is_legacy_api_key_hash(api_key.key_hash):
api_key.key_hash = current_key_hash

# Eagerly load user
user_result = await session.execute(
Expand Down
Loading
Loading