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
4 changes: 2 additions & 2 deletions dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ that directory.
| D290 | HIGH | Dangerous trigger (`pull_request_target`, `workflow_run`) | GitHub Actions |
| D291 | MEDIUM-HIGH | Missing or excessive permissions | GitHub Actions |
| D292 | HIGH (LOW for GitHub-owned) | Unpinned action or reusable workflow. GitHub-owned `actions/*` and `github/*` at a tag or branch are LOW (GitHub runs the workflow and owns them; zizmor's default policy allows ref-pins for them); every other owner, dynamic `${{ }}` refs and refs with no `@` are HIGH | GitHub Actions |
| D293 | MEDIUM | Checkout persists credentials | GitHub Actions |
| D293 | MEDIUM | Checkout persists credentials where they can reach someone new: a later `actions/upload-artifact` of the checkout (`.git` included: before v4.4, or with `include-hidden-files: true`, or a SHA pin), a pull request's code checked out in a job accounts without write access can start, or an explicitly write-scoped token beside a later third-party action. GitHub-owned and local actions already hold the token; an undeclared token scope is D291's finding | GitHub Actions |
| D294 | HIGH | Template injection from untrusted context | GitHub Actions |
| D295 | HIGH | Self-hosted runner exposure | GitHub Actions |
| D296 | MEDIUM | Unpinned container image | GitHub Actions |
Expand All @@ -447,7 +447,7 @@ that directory.
| D310 | HIGH | OIDC token exposed to local build script | GitHub Actions |
| D311 | MEDIUM | Lax artifact upload | GitHub Actions |
| D312 | MEDIUM | JavaScript install scripts in CI | GitHub Actions |
| D313 | MEDIUM | Privileged job missing timeout | GitHub Actions |
| D313 | LOW | Privileged job missing a bounded timeout while reachable accounts without write access can run pull request code. The default is 360 minutes on both GitHub-hosted and self-hosted runners; trusted jobs and jobs that do not run PR code are not reported | GitHub Actions |
| D350 | MEDIUM-CRITICAL | AI agent on an untrusted trigger with no actor gate | GitHub Actions |
| D351 | MEDIUM-CRITICAL | AI agent action's write-access check widened (`*`) | GitHub Actions |
| D352 | MEDIUM-HIGH | AI agent can run any shell command on an untrusted trigger | GitHub Actions |
Expand Down
2 changes: 1 addition & 1 deletion skylos/rules/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ def to_dict(self) -> dict:
"SKY-D313",
"GitHub Actions privileged job missing timeout",
"security",
"MEDIUM",
"LOW",
finding_type="review",
),
RuleCatalogEntry(
Expand Down
62 changes: 35 additions & 27 deletions skylos/rules/config/cicd/ai_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1186,13 +1186,6 @@ def _job_routes_by_event(job: dict[str, Any] | None) -> bool:
)


def _approval_environment(job: dict[str, Any]) -> bool:
"""An environment chosen per run, the documented way to require approval
for fork pull requests. A fixed name usually only scopes secrets."""
environment = job.get("environment")
if isinstance(environment, dict):
environment = environment.get("name")
return isinstance(environment, str) and "${{" in environment


def _literal_environment(job: dict[str, Any]) -> str:
Expand All @@ -1205,9 +1198,7 @@ def _literal_environment(job: dict[str, Any]) -> str:


def _job_authorizes(job: dict[str, Any]) -> bool:
"""An approval environment or a permission check."""
if _approval_environment(job):
return True
"""A recognized permission check; an environment name proves no approval."""
steps = job.get("steps")
return isinstance(steps, list) and any(
isinstance(step, dict) and _step_checks_permission(step) for step in steps
Expand Down Expand Up @@ -1242,23 +1233,20 @@ def job_runs(
return True
inner = stack + (job_id,)
result = True
if untrusted and _approval_environment(job):
result = False
else:
condition = parse_condition(job.get("if"))
if not has_status_override(condition):
for dep in _needs(job):
if self._blocked(dep, event, untrusted, inner):
result = False
break
if result:
ctx = EvalContext(
event,
untrusted,
lambda dep: self._blocked(dep, event, untrusted, inner),
lambda dep: _job_routes_by_event(self.jobs.get(dep)),
)
result = evaluate(condition, ctx)[0]
condition = parse_condition(job.get("if"))
if not has_status_override(condition):
for dep in _needs(job):
if self._blocked(dep, event, untrusted, inner):
result = False
break
if result:
ctx = EvalContext(
event,
untrusted,
lambda dep: self._blocked(dep, event, untrusted, inner),
lambda dep: _job_routes_by_event(self.jobs.get(dep)),
)
result = evaluate(condition, ctx)[0]
self._memo[key] = result
return result

Expand All @@ -1283,6 +1271,26 @@ def step_runs(self, job_id: str, index: int, event: str, untrusted: bool) -> boo
return True


def outsider_job_events(
data: dict[str, Any], path: Path, root: Path | None
) -> dict[str, list[str]]:
"""For each job, the events on which an account without write access can
make it run: the workflow's untrusted triggers that the job's ``if``,
``needs`` and recognized permission checks do not shut out. An
environment name does not prove required approval. Jobs no such event
reaches are omitted."""
events = _untrusted_events(data, path, root, {})
if not events:
return {}
reach = _Reach(data)
reachable = {}
for job_id in reach.jobs:
job_events = [event for event in events if reach.job_runs(job_id, event, True)]
if job_events:
reachable[job_id] = job_events
return reachable


# ------------------------------------------------------------ privilege


Expand Down
Loading
Loading