diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 863a910..1ab1f15 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -65,3 +65,24 @@ jobs: echo "::error::Planning/internal docs detected in PR" exit 1 fi + + semgrep: + name: Semgrep (SAST) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install Python 3.12 + run: uv python install 3.12 + + - name: Semgrep scan + run: > + uv run --with semgrep semgrep scan + --config p/python + --config p/security-audit + --config p/command-injection + --error --metrics off src/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 090c581..82a3c9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,12 @@ repos: language: script types: [file] pass_filenames: false + + # Semgrep SAST — same rulesets as the CI security-scan job. Runs via uv + # (ephemeral, no project dep) and scans src/ when any source file changes. + - id: semgrep + name: Semgrep (SAST) + entry: uv run --with semgrep semgrep scan --config p/python --config p/security-audit --config p/command-injection --error --metrics off --quiet src/ + language: system + files: ^src/.*\.py$ + pass_filenames: false diff --git a/src/phantom/cli/update.py b/src/phantom/cli/update.py index b02ecfa..c65c8c8 100644 --- a/src/phantom/cli/update.py +++ b/src/phantom/cli/update.py @@ -58,10 +58,18 @@ def _parse_version(tag: str) -> tuple[int, ...]: def _fetch_json(url: str) -> dict | None: - """Fetch JSON from a URL. Returns None on any failure.""" + """Fetch JSON from an https URL. Returns None on any failure. + + The scheme is pinned to https so a non-https value (e.g. a ``file://`` URL) + can never be handed to urlopen, which otherwise supports local-file reads. + All callers pass hardcoded GitHub API constants; this guards future callers. + """ + if not url.startswith("https://"): + return None req = Request(url, headers={"User-Agent": f"phantom-audio/{__version__}"}) try: - with urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + # url is https-pinned above; all callers pass hardcoded GitHub API constants + with urlopen(req, timeout=REQUEST_TIMEOUT) as resp: # nosemgrep if resp.status == 200: return json.loads(resp.read().decode()) except (URLError, OSError, json.JSONDecodeError, ValueError):