Skip to content

Commit b6ff8e5

Browse files
authored
id: add ambient detector for CircleCI (#144)
* id: add ambient detector for CircleCI Signed-off-by: William Woodruff <william@trailofbits.com> * lintage Replace `isort` and `black` with `ruff`. Signed-off-by: William Woodruff <william@trailofbits.com> * test: round out CircleCI tests Signed-off-by: William Woodruff <william@trailofbits.com> * ambient: add bandit ignores Signed-off-by: William Woodruff <william@trailofbits.com> --------- Signed-off-by: William Woodruff <william@trailofbits.com>
1 parent 9695feb commit b6ff8e5

File tree

6 files changed

+168
-89
lines changed

6 files changed

+168
-89
lines changed

Makefile

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ run: $(VENV)/pyvenv.cfg
6363
.PHONY: lint
6464
lint: $(VENV)/pyvenv.cfg
6565
. $(VENV_BIN)/activate && \
66-
black --check $(ALL_PY_SRCS) && \
67-
isort --check $(ALL_PY_SRCS) && \
66+
ruff format --check $(ALL_PY_SRCS) && \
6867
ruff $(ALL_PY_SRCS) && \
6968
mypy $(PY_MODULE) && \
7069
bandit -c pyproject.toml -r $(PY_MODULE) && \
@@ -74,8 +73,7 @@ lint: $(VENV)/pyvenv.cfg
7473
reformat: $(VENV)/pyvenv.cfg
7574
. $(VENV_BIN)/activate && \
7675
ruff --fix $(ALL_PY_SRCS) && \
77-
black $(ALL_PY_SRCS) && \
78-
isort $(ALL_PY_SRCS)
76+
ruff format $(ALL_PY_SRCS)
7977

8078
.PHONY: test
8179
test: $(VENV)/pyvenv.cfg

id/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def detect_credential(audience: str) -> Optional[str]:
5959
"""
6060
from ._internal.oidc.ambient import (
6161
detect_buildkite,
62+
detect_circleci,
6263
detect_gcp,
6364
detect_github,
6465
detect_gitlab,
@@ -69,6 +70,7 @@ def detect_credential(audience: str) -> Optional[str]:
6970
detect_gcp,
7071
detect_buildkite,
7172
detect_gitlab,
73+
detect_circleci,
7274
]
7375
for detector in detectors:
7476
credential = detector(audience)

id/__main__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ def _parser() -> argparse.ArgumentParser:
3636
description="a tool for generating OIDC identities",
3737
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
3838
)
39-
parser.add_argument(
40-
"-V", "--version", action="version", version=f"%(prog)s {__version__}"
41-
)
39+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
4240
parser.add_argument(
4341
"-v",
4442
"--verbose",

id/_internal/oidc/ambient.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Ambient OIDC credential detection.
1717
"""
1818

19+
import json
1920
import logging
2021
import os
2122
import re
@@ -31,9 +32,15 @@
3132
logger = logging.getLogger(__name__)
3233

3334
_GCP_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name"
34-
_GCP_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/token" # noqa # nosec B105
35-
_GCP_IDENTITY_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa
36-
_GCP_GENERATEIDTOKEN_REQUEST_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa
35+
_GCP_TOKEN_REQUEST_URL = (
36+
"http://metadata/computeMetadata/v1/instance/service-accounts/default/token" # noqa # nosec B105
37+
)
38+
_GCP_IDENTITY_REQUEST_URL = (
39+
"http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa
40+
)
41+
_GCP_GENERATEIDTOKEN_REQUEST_URL = (
42+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa
43+
)
3744

3845
_env_var_regex = re.compile(r"[^A-Z0-9_]|^[^A-Z_]")
3946

@@ -178,15 +185,11 @@ def detect_gcp(audience: str) -> Optional[str]:
178185
with open(_GCP_PRODUCT_NAME_FILE) as f:
179186
name = f.read().strip()
180187
except OSError:
181-
logger.debug(
182-
"GCP: environment doesn't have GCP product name file; giving up"
183-
)
188+
logger.debug("GCP: environment doesn't have GCP product name file; giving up")
184189
return None
185190

186191
if name not in {"Google", "Google Compute Engine"}:
187-
logger.debug(
188-
f"GCP: product name file exists, but product name is {name!r}; giving up"
189-
)
192+
logger.debug(f"GCP: product name file exists, but product name is {name!r}; giving up")
190193
return None
191194

192195
logger.debug("GCP: requesting OIDC token")
@@ -292,9 +295,43 @@ def detect_gitlab(audience: str) -> Optional[str]:
292295
var_name = f"{sanitized_audience}_ID_TOKEN"
293296
token = os.getenv(var_name)
294297
if not token:
295-
raise AmbientCredentialError(
296-
f"GitLab: Environment variable {var_name} not found"
297-
)
298+
raise AmbientCredentialError(f"GitLab: Environment variable {var_name} not found")
298299

299300
logger.debug(f"GitLab: Found token in environment variable {var_name}")
300301
return token
302+
303+
304+
def detect_circleci(audience: str) -> Optional[str]:
305+
"""
306+
Detect and return a CircleCI ambient OIDC credential.
307+
308+
Returns `None` if the context is not a CircleCI environment.
309+
310+
Raises if the environment is GitHub Actions, but is incorrect or
311+
insufficiently permissioned for an OIDC credential.
312+
"""
313+
logger.debug("CircleCI: looking for OIDC credentials")
314+
315+
if not os.getenv("CIRCLECI"):
316+
logger.debug("CircleCI: environment doesn't look like CircleCI; giving up")
317+
return None
318+
319+
# Check that the circleci executable exists in the `PATH`.
320+
if shutil.which("circleci") is None:
321+
raise AmbientCredentialError("CircleCI: could not find `circleci` in the environment")
322+
323+
# See NOTE on `detect_buildkite` for why we silence these warnings.
324+
payload = json.dumps({"aud": audience})
325+
process = subprocess.run( # nosec B603, B607
326+
["circleci", "run", "oidc", "get", "--claims", payload],
327+
stdout=subprocess.PIPE,
328+
stderr=subprocess.PIPE,
329+
text=True,
330+
)
331+
332+
if process.returncode != 0:
333+
raise AmbientCredentialError(
334+
f"CircleCI: the `circleci` tool encountered an error: {process.stdout}"
335+
)
336+
337+
return process.stdout.strip()

pyproject.toml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ Source = "https://github.com/di/id"
3434
test = ["pytest", "pytest-cov", "pretend", "coverage[toml]"]
3535
lint = [
3636
"bandit",
37-
"black",
38-
"isort",
3937
"interrogate",
4038
"mypy",
4139
# NOTE(ww): ruff is under active development, so we pin conservatively here
@@ -45,11 +43,6 @@ lint = [
4543
]
4644
dev = ["build", "bump >= 1.3.2", "id[test,lint]"]
4745

48-
[tool.isort]
49-
multi_line_output = 3
50-
known_first_party = "id"
51-
include_trailing_comma = true
52-
5346
[tool.interrogate]
5447
# don't enforce documentation coverage for packaging, testing, the virtual
5548
# environment, or the CLI (which is documented separately).
@@ -85,4 +78,4 @@ exclude_dirs = ["./test"]
8578
line-length = 100
8679
# TODO: Enable "UP" here once Pydantic allows us to:
8780
# See: https://github.com/pydantic/pydantic/issues/4146
88-
select = ["E", "F", "W"]
81+
select = ["I", "E", "F", "W"]

0 commit comments

Comments
 (0)