from __future__ import annotations
import typing as typ
from step_conditions import conjuncts
from workflow_reader import Workflow, scalars
CODESCENE_TOKEN: typ.Final = "cs_access_token"
CHECK_COMMAND: typ.Final = (
'echo"available=${{secrets.cs_access_token!=\'\'}}">>"$github_output"'
)
TOKEN_INPUT: typ.Final = "${{secrets.cs_access_token}}"
CHECK_KEYS: typ.Final = frozenset({"name", "id", "run"})
TRUNK_CONJUNCT: typ.Final = "github.ref == 'refs/heads/main'"
Step = dict[str, typ.Any]
def token_faults(flow: Workflow, job: str, upload: Step) -> list[str]:
check = token_check(flow, job, upload)
faults = (
[]
if check is not None
else ["runs no single token check before the upload step"]
)
faults += _check_faults(check) + _guard_faults(upload, check)
if _folded((upload.get("with") or {}).get("access-token", "")) != TOKEN_INPUT:
faults.append("the uploader is not given the token from its secret")
return faults + _scope_faults(flow, upload, check)
def token_check(flow: Workflow, job: str, upload: Step) -> Step | None:
steps = dict(flow.jobs())[job].get("steps") or []
before = steps[: steps.index(upload)] if upload in steps else []
checks = [
step
for step in before
if isinstance(step, dict) and _folded(step.get("run", "")) == CHECK_COMMAND
]
return checks[0] if len(checks) == 1 else None
def _check_faults(check: Step | None) -> list[str]:
if check is None:
return []
faults = [] if check.get("id") else ["the token check has no id"]
if "if" in check:
faults.append("the token check is conditional")
if "env" in check:
faults.append("the token check declares an env")
extra = sorted(set(check) - CHECK_KEYS - {"if", "env"})
if extra:
faults.append(f"the token check carries {extra}")
return faults
def _guard_faults(upload: Step, check: Step | None) -> list[str]:
parts = conjuncts(upload.get("if", ""))
if parts is None:
return [f"condition {upload.get('if')!r} has a disjunction"]
if _requires_check_and_trunk(parts, check):
return []
return [
f"condition {upload.get('if')!r} must require the token check and the trunk"
]
def _requires_check_and_trunk(parts: list[str], check: Step | None) -> bool:
if check is None:
return False
available = f"steps.{check.get('id')}.outputs.available == 'true'"
return {TRUNK_CONJUNCT, available} <= set(parts)
def _scope_faults(flow: Workflow, upload: Step, check: Step | None) -> list[str]:
faults: list[str] = []
if any(map(_mentions, _envs(flow))):
faults.append("the token is bound in an env")
if any(map(_mentions, _beyond_env(flow, upload, check))):
faults.append("another step references the token")
return faults
def _envs(flow: Workflow) -> list[object]:
envs = [flow.document.get("env")] + [job.get("env") for _, job in flow.jobs()]
return envs + [step.get("env") for _, step in flow.steps()]
def _beyond_env(flow: Workflow, upload: Step, check: Step | None) -> list[Step]:
others = [
_omitting(step, {"env"})
for _, step in flow.steps()
if step is not upload and step is not check
]
inputs = _omitting(upload.get("with") or {}, {"access-token"})
return [*others, {**_omitting(upload, {"env", "with"}), "with": inputs}]
def _omitting(mapping: dict[str, typ.Any], keys: set[str]) -> dict[str, typ.Any]:
return {key: value for key, value in mapping.items() if key not in keys}
def _mentions(node: object) -> bool:
return any(CODESCENE_TOKEN in text.lower() for text in scalars(node))
def _folded(value: object) -> str:
return "".join(str(value).split()).lower()