diff --git a/docs/integrations.md b/docs/integrations.md index e3eebc81..a5da7762 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -174,7 +174,7 @@ For source-only testing in this repository: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: "3.12" -- run: python -m pip install -e ".[dev]" +- run: python -P -m pip install -e ".[dev]" - run: agents-shipgate verify --workspace . --config shipgate.yaml --base origin/main --head HEAD --ci-mode advisory --format json ``` @@ -269,6 +269,27 @@ trust boundary and not a replacement for CI. CI should continue to run the GitHub Action or an equivalent `agents-shipgate verify` command, and CI's `report.json.release_decision.decision` remains authoritative. +## CI installation trust + +The following recipes use `python -P` to keep the checkout off Python's implicit +module search path during installation (Python 3.12 or newer). Use a trusted +Python executable and installed CLI on `PATH`, and do not supply a checkout +through `PYTHONPATH`. `-P` does not neutralize an attacker-controlled pipeline, +explicit imports, environment variables, or an editable package's build backend. +The source-only editable-install example above is for trusted source testing. + +- **GitLab:** use a protected/parent pipeline definition from a trusted source + when scanning an untrusted checkout; a merge-request-controlled job can alter + its own commands. +- **CircleCI:** keep the configuration and any setup/continuation configuration + trusted when analyzing untrusted changes; checkout isolation alone does not + authenticate the job definition. +- **Jenkins:** use a trusted Jenkinsfile or shared library for the scan stage; + do not execute a proposed Jenkinsfile as the authority for its own review. + +These examples document the installation boundary; they do not claim a hosted +security test on GitLab, CircleCI or Jenkins. + ## GitLab CI First-class GitLab CI recipes live in [`../examples/gitlab-ci/`](../examples/gitlab-ci/): @@ -284,7 +305,7 @@ agents-shipgate: stage: test image: python:3.12 script: - - python -m pip install --pre "agents-shipgate==1.1.0" + - python -P -m pip install --pre "agents-shipgate==1.1.0" - agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif artifacts: when: always @@ -316,7 +337,7 @@ jobs: - image: cimg/python:3.12 steps: - checkout - - run: python -m pip install --pre "agents-shipgate==1.1.0" + - run: python -P -m pip install --pre "agents-shipgate==1.1.0" - run: agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif - store_artifacts: path: agents-shipgate-reports @@ -328,7 +349,7 @@ jobs: ```groovy stage('Agents Shipgate') { steps { - sh 'python -m pip install agents-shipgate' + sh 'python -P -m pip install agents-shipgate' sh 'agents-shipgate scan --config shipgate.yaml --ci-mode advisory' archiveArtifacts artifacts: 'agents-shipgate-reports/**', allowEmptyArchive: true } diff --git a/examples/circleci/01-advisory.yml b/examples/circleci/01-advisory.yml index b742a3ff..4f9d1ad0 100644 --- a/examples/circleci/01-advisory.yml +++ b/examples/circleci/01-advisory.yml @@ -6,7 +6,7 @@ jobs: - image: cimg/python:3.12 steps: - checkout - - run: python -m pip install "agents-shipgate==1.1.0" + - run: python -P -m pip install "agents-shipgate==1.1.0" - run: agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif - store_artifacts: path: agents-shipgate-reports diff --git a/examples/circleci/02-strict-with-baseline.yml b/examples/circleci/02-strict-with-baseline.yml index 0a37f01b..89aec883 100644 --- a/examples/circleci/02-strict-with-baseline.yml +++ b/examples/circleci/02-strict-with-baseline.yml @@ -6,7 +6,7 @@ jobs: - image: cimg/python:3.12 steps: - checkout - - run: python -m pip install "agents-shipgate==1.1.0" + - run: python -P -m pip install "agents-shipgate==1.1.0" - run: name: Agents Shipgate strict scan command: > diff --git a/examples/circleci/03-sarif-artifact-retention.yml b/examples/circleci/03-sarif-artifact-retention.yml index b650c568..cce33aaa 100644 --- a/examples/circleci/03-sarif-artifact-retention.yml +++ b/examples/circleci/03-sarif-artifact-retention.yml @@ -6,7 +6,7 @@ jobs: - image: cimg/python:3.12 steps: - checkout - - run: python -m pip install "agents-shipgate==1.1.0" + - run: python -P -m pip install "agents-shipgate==1.1.0" - run: agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif - store_artifacts: path: agents-shipgate-reports/report.sarif diff --git a/examples/circleci/04-multi-config-workspace.yml b/examples/circleci/04-multi-config-workspace.yml index e88ff0bc..e2676238 100644 --- a/examples/circleci/04-multi-config-workspace.yml +++ b/examples/circleci/04-multi-config-workspace.yml @@ -6,7 +6,7 @@ jobs: - image: cimg/python:3.12 steps: - checkout - - run: python -m pip install "agents-shipgate==1.1.0" + - run: python -P -m pip install "agents-shipgate==1.1.0" - run: name: Agents Shipgate workspace scan command: > diff --git a/examples/gitlab-ci/01-advisory.yml b/examples/gitlab-ci/01-advisory.yml index ccbe75a8..245fdcfc 100644 --- a/examples/gitlab-ci/01-advisory.yml +++ b/examples/gitlab-ci/01-advisory.yml @@ -5,7 +5,7 @@ agents_shipgate: stage: test image: python:3.12 script: - - python -m pip install "agents-shipgate==1.1.0" + - python -P -m pip install "agents-shipgate==1.1.0" - agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif artifacts: when: always diff --git a/examples/gitlab-ci/02-strict-with-baseline.yml b/examples/gitlab-ci/02-strict-with-baseline.yml index 1accbbf5..7743d326 100644 --- a/examples/gitlab-ci/02-strict-with-baseline.yml +++ b/examples/gitlab-ci/02-strict-with-baseline.yml @@ -5,7 +5,7 @@ agents_shipgate: stage: test image: python:3.12 script: - - python -m pip install "agents-shipgate==1.1.0" + - python -P -m pip install "agents-shipgate==1.1.0" - > agents-shipgate scan --config shipgate.yaml diff --git a/examples/gitlab-ci/03-sarif-or-artifact.yml b/examples/gitlab-ci/03-sarif-or-artifact.yml index 3bcc5e01..ffa9166b 100644 --- a/examples/gitlab-ci/03-sarif-or-artifact.yml +++ b/examples/gitlab-ci/03-sarif-or-artifact.yml @@ -5,7 +5,7 @@ agents_shipgate: stage: test image: python:3.12 script: - - python -m pip install "agents-shipgate==1.1.0" + - python -P -m pip install "agents-shipgate==1.1.0" - agents-shipgate scan --config shipgate.yaml --ci-mode advisory --format markdown,json,sarif artifacts: when: always diff --git a/examples/gitlab-ci/04-multi-config-workspace.yml b/examples/gitlab-ci/04-multi-config-workspace.yml index 5c01f73e..c69969a9 100644 --- a/examples/gitlab-ci/04-multi-config-workspace.yml +++ b/examples/gitlab-ci/04-multi-config-workspace.yml @@ -5,7 +5,7 @@ agents_shipgate: stage: test image: python:3.12 script: - - python -m pip install "agents-shipgate==1.1.0" + - python -P -m pip install "agents-shipgate==1.1.0" - > agents-shipgate scan --workspace . diff --git a/tests/test_ci_recipes.py b/tests/test_ci_recipes.py index 225f2d9a..63e67960 100644 --- a/tests/test_ci_recipes.py +++ b/tests/test_ci_recipes.py @@ -1,5 +1,10 @@ +import os +import re +import subprocess +import sys from pathlib import Path +import pytest import yaml @@ -8,7 +13,7 @@ def test_gitlab_ci_examples_are_parseable_and_store_reports(): data = yaml.safe_load(path.read_text(encoding="utf-8")) job = data["agents_shipgate"] - assert "python -m pip install" in "\n".join(job["script"]) + assert "python -P -m pip install" in "\n".join(job["script"]) assert "agents-shipgate scan" in "\n".join(job["script"]) assert job["artifacts"]["when"] == "always" assert "agents-shipgate-reports/" in job["artifacts"]["paths"] @@ -21,7 +26,7 @@ def test_circleci_examples_are_parseable_and_store_reports(): steps = job["steps"] assert job["docker"][0]["image"] == "cimg/python:3.12" - assert any(_run_command(step).startswith("python -m pip install") for step in steps) + assert any(_run_command(step).startswith("python -P -m pip install") for step in steps) assert any("agents-shipgate scan" in _run_command(step) for step in steps) assert any("store_artifacts" in step for step in steps if isinstance(step, dict)) @@ -35,3 +40,72 @@ def _run_command(step: object) -> str: if isinstance(run, dict): return str(run.get("command") or "") return "" + + +_PYTHON_MODULE_OR_STDIN = re.compile( + r'(?(?:-[A-Za-z]+\s+)*)' + r"(?P-m\s+(?:pip|agents_shipgate)\b|-(?=\s|$))" +) + + +def _unsafe_checkout_invocations(text: str) -> list[str]: + return [ + match.group(0) + for match in _PYTHON_MODULE_OR_STDIN.finditer(text) + if "-P" not in match.group("flags").split() + ] + + +def test_ci_installations_use_safe_import_path(): + paths = [ + *Path("examples/gitlab-ci").glob("*.yml"), + *Path("examples/circleci").glob("*.yml"), + ] + # Only CI documentation: the local trigger hook intentionally imports its repo. + document = Path("docs/integrations.md").read_text(encoding="utf-8") + ci_sections = document.split("## Local Diagnostics")[0] + ci_sections += document.split("## GitLab CI", 1)[1].split("## MCP server", 1)[0] + for path in paths: + assert not _unsafe_checkout_invocations(path.read_text()), path + assert not _unsafe_checkout_invocations(ci_sections) + + +@pytest.mark.parametrize("command", [ + "python -m pip install agents-shipgate", + "python3.12 -m agents_shipgate scan", + "python - <<'PY'", + "echo setup && python -m pip install agents-shipgate", + '"/usr/bin/python3.12" -m pip install agents-shipgate', + '${PYTHON_ROOT}/python -m agents_shipgate scan', +]) +def test_install_guard_rejects_unsafe_commands(command): + assert _unsafe_checkout_invocations(command) + + +@pytest.mark.parametrize("command", [ + "python -P -m pip install agents-shipgate", + "python3.12 -P -m agents_shipgate scan", + "python -P - <<'PY'", + '"/usr/bin/python3.12" -P -m pip install agents-shipgate', +]) +def test_install_guard_accepts_safe_commands(command): + assert not _unsafe_checkout_invocations(command) + + +def test_safe_path_prevents_checkout_pip_shadowing(tmp_path): + # Synthetic module: never install anything or execute a subject repository. + (tmp_path / "pip.py").write_text("print('CHECKOUT_SHADOW_MARKER')\n") + env = {k: v for k, v in os.environ.items() + if k not in {"PYTHONPATH", "PYTHONSAFEPATH"}} + unsafe = subprocess.run( + [sys.executable, "-m", "pip", "--version"], cwd=tmp_path, + env=env, capture_output=True, text=True, check=True, timeout=30, + ) + safe = subprocess.run( + [sys.executable, "-P", "-m", "pip", "--version"], cwd=tmp_path, + env=env, capture_output=True, text=True, check=True, timeout=30, + ) + assert "CHECKOUT_SHADOW_MARKER" in unsafe.stdout + assert "CHECKOUT_SHADOW_MARKER" not in safe.stdout + assert safe.stdout.startswith("pip ")