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
29 changes: 25 additions & 4 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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/):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion examples/circleci/01-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/circleci/02-strict-with-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down
2 changes: 1 addition & 1 deletion examples/circleci/03-sarif-artifact-retention.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/circleci/04-multi-config-workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down
2 changes: 1 addition & 1 deletion examples/gitlab-ci/01-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/gitlab-ci/02-strict-with-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/gitlab-ci/03-sarif-or-artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/gitlab-ci/04-multi-config-workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
78 changes: 76 additions & 2 deletions tests/test_ci_recipes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import os
import re
import subprocess
import sys
from pathlib import Path

import pytest
import yaml


Expand All @@ -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"]
Expand All @@ -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))

Expand All @@ -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'(?<![\w.-])(?:[\w${}./"-]*/)?python(?:3(?:\.\d+)?)?'
r'(?![\w.-])"?\s+(?P<flags>(?:-[A-Za-z]+\s+)*)'
r"(?P<entry>-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 ")
Loading