Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- On a repository with no GitHub Actions workflow file, OpenSSF Baseline
OSPS-BR-01.01, OSPS-BR-01.02, and OSPS-AC-04.02 report WARN saying no
workflow files were found and there is nothing to audit, instead of
`WARN [unexpected_exit]` from zizmor exiting 3 or `grep` exiting 2, and
zizmor and `grep` are not run, so their `unexpected_exit` log lines are
gone. A new `darnit-baseline` step type, `github_workflows_present`
(ceiling `{fail}`), is the first step of the three controls and the first
in-tree step type to conclude WARN under a `{fail}` ceiling. Because that
WARN is conclusive, the manual step no longer runs when there is nothing
to audit; the descriptive WARN is the operator guidance. The result stays
non-compliant and is not PASS, FAIL, or N/A. It is concluded by that step
(`concluded_by = "github_workflows_present"`, authority `dispositive`)
rather than left undetermined, and it replaces an ERROR when zizmor or
`grep` is not installed. With workflow files present the step is evidence
only (later steps' indexes move up by one). For OSPS-BR-01.01 an
`action.yml` or `action.yaml` anywhere in the repository also counts as
something to audit, since zizmor's `template-injection` audit reads action
definitions too, so zizmor still runs and reports findings in a standalone
action. A confirmed non-GitHub `ci_provider` still makes the controls N/A.
The SC-008 / FR-017 regression guard is narrowed only for these three
controls, and only for fixtures with no relevant auditable inputs (#584).
- Configure `fail_exit_codes = [0]` for OpenSSF Baseline OSPS-BR-01.02
(`grep -rln github.head_ref .github/workflows/`) so exit 0 on a match records
as non-conclusive evidence without an `unexpected_exit` error class (fixes #647).
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/framework-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2032,6 +2032,7 @@ Implementation-registered sieve handlers (non-exhaustive):
| Step type | Registered by | Ceiling |
|-----------|---------------|---------|
| `github_branch_protection` | `darnit-baseline`: the classic-branch-protection + repository-rulesets two-surface check for `OSPS-AC-03.01`, `OSPS-AC-03.02`, `OSPS-QA-03.01`, `OSPS-QA-07.01`; see `specs/032-ruleset-branch-protection/contracts/github-branch-protection-handler.md` | `{pass, fail}` (reads platform settings) |
| `github_workflows_present` | `darnit-baseline`: the first step of `OSPS-BR-01.01`, `OSPS-BR-01.02`, `OSPS-AC-04.02`; WARN saying there is nothing to audit when `.github/workflows/` holds no `.yml` or `.yaml` file inside the repository and, with `actions = true` (`OSPS-BR-01.01`, whose zizmor audit also reads action definitions), the repository holds no `action.yml` or `action.yaml` outside `.git`; otherwise INCONCLUSIVE (#584) | `{fail}`: it concludes only that WARN, never PASS or FAIL |
| `generate_threat_model` | `darnit-baseline` (remediation, `supports_plan=True`; returns its files as `FileChange`s, section 4.2) | `{}` (a remediation handler, section 3.0.1) |
| `gittuf_verify_policy`, `gittuf_commits_signed` | `darnit-gittuf` | `{pass, fail}` (cryptographic verification) |
| `repro_deps_pinned`, `repro_build_env_declared`, `repro_hermetic_build`, `repro_provenance_exists`, `repro_bit_for_bit` | `darnit-reproducibility` | `{fail}`: they decide from text and file-presence signals, so PASS needs a corpus-backed promotion (section 3.0.1) |
Expand Down
126 changes: 126 additions & 0 deletions packages/darnit-baseline/src/darnit_baseline/github_workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""The ``github_workflows_present`` sieve handler (#584).

The first step of the controls that audit GitHub Actions workflow files
(``OSPS-BR-01.01``, ``OSPS-BR-01.02``, ``OSPS-AC-04.02``). Without workflow
files their commands have nothing to read: zizmor exits 3 and ``grep``
exits 2, which an ``exec`` step reports as ``unexpected_exit``. This step
says what happened instead, before either runs.

* WARN when ``.github/workflows/`` holds no ``.yml`` or ``.yaml`` file inside
the repository. The step type's ceiling is ``{fail}``, so the WARN
concludes the control with this message and no error class, and no later
step runs.
* With ``actions = true``, an action definition (``action.yml`` or
``action.yaml``) anywhere in the repository outside ``.git`` also counts as
something to audit, for a control whose zizmor audit reads actions as well
as workflows (``template-injection``). zizmor collects action definitions
from the whole tree, so without the setting this step would end such a
control before zizmor could report a finding in one.
* INCONCLUSIVE otherwise: the files are evidence and evaluation continues
with the control's other steps.

It never returns PASS or FAIL.
"""

from __future__ import annotations

import os
from typing import Any

from darnit.core.repo_files import escaping_entry, within_repository
from darnit.sieve.handler_registry import HandlerContext, HandlerResult, HandlerResultStatus

WORKFLOWS_DIR = ".github/workflows"
WORKFLOW_SUFFIXES = (".yml", ".yaml")
ACTION_FILE_NAMES = ("action.yml", "action.yaml")
_EVIDENCE_LIMIT = 20

NO_WORKFLOWS_MESSAGE = (
"No GitHub Actions workflow files (.github/workflows/*.yml or *.yaml) were found, "
"so there is nothing to audit. If this project does not use GitHub Actions, "
"confirm ci_provider to mark this control not applicable."
)
NO_WORKFLOWS_OR_ACTIONS_MESSAGE = (
"No GitHub Actions workflow files (.github/workflows/*.yml or *.yaml) or action "
"definitions (action.yml or action.yaml) were found, so there is nothing to audit. "
"If this project does not use GitHub Actions, confirm ci_provider to mark this "
"control not applicable."
)


def _first_action_file(root: str, skipped: list[str]) -> str | None:
"""The first action definition inside the repository, as a repository-relative path."""
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d != ".git")
for name in sorted(filenames):
if name.lower() not in ACTION_FILE_NAMES:
continue
path = os.path.join(dirpath, name)
if not within_repository(root, path):
entry = escaping_entry(root, path)
if entry not in skipped:
skipped.append(entry)
continue
if os.path.isfile(path):
return os.path.relpath(path, root).replace(os.sep, "/")
return None


def github_workflows_present_handler(config: dict[str, Any], context: HandlerContext) -> HandlerResult:
"""WARN when the repository has nothing for the control to audit, else INCONCLUSIVE."""
directory = os.path.join(context.local_path, *WORKFLOWS_DIR.split("/"))
try:
names = sorted(entry.name for entry in os.scandir(directory))
except (FileNotFoundError, NotADirectoryError):
names = []
except OSError as e:
return HandlerResult(
status=HandlerResultStatus.INCONCLUSIVE,
message=f"Could not list {WORKFLOWS_DIR}/: {e}",
evidence={"workflows_dir": WORKFLOWS_DIR},
)

found: list[str] = []
skipped: list[str] = []
for name in names:
if not name.lower().endswith(WORKFLOW_SUFFIXES):
continue
path = os.path.join(directory, name)
if not within_repository(context.local_path, path):
entry = escaping_entry(context.local_path, path)
if entry not in skipped:
skipped.append(entry)
continue
if os.path.isfile(path):
found.append(f"{WORKFLOWS_DIR}/{name}")

evidence: dict[str, Any] = {"workflows_dir": WORKFLOWS_DIR, "workflow_files": found[:_EVIDENCE_LIMIT]}
if found:
if skipped:
evidence["skipped_outside_repository"] = skipped
return HandlerResult(
status=HandlerResultStatus.INCONCLUSIVE,
message=f"GitHub Actions workflow files found: {len(found)}",
evidence=evidence,
)

include_actions = config.get("actions") is True
action_file = _first_action_file(context.local_path, skipped) if include_actions else None
if skipped:
evidence["skipped_outside_repository"] = skipped
if action_file is not None:
evidence["action_files"] = [action_file]
return HandlerResult(
status=HandlerResultStatus.INCONCLUSIVE,
message=f"No GitHub Actions workflow files; action definition found: {action_file}",
evidence=evidence,
)
return HandlerResult(
status=HandlerResultStatus.WARN,
message=NO_WORKFLOWS_OR_ACTIONS_MESSAGE if include_actions else NO_WORKFLOWS_MESSAGE,
confidence=1.0,
evidence=evidence,
)


__all__ = ["NO_WORKFLOWS_MESSAGE", "NO_WORKFLOWS_OR_ACTIONS_MESSAGE", "github_workflows_present_handler"]
12 changes: 12 additions & 0 deletions packages/darnit-baseline/src/darnit_baseline/implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def register_handlers(self) -> None:
from darnit.sieve.handler_registry import get_sieve_handler_registry

from .branch_protection import github_branch_protection_handler
from .github_workflows import github_workflows_present_handler
from .threat_model.remediation import generate_threat_model_handler

sieve_registry = get_sieve_handler_registry()
Expand Down Expand Up @@ -155,6 +156,17 @@ def register_handlers(self) -> None:
ceiling={"pass", "fail"},
settings={"requirement", "required_approvals_minimum", "owner", "repo", "branch"},
)
# #584: WARN, concluding under {fail}, when there is no workflow file
# (or, with `actions = true`, no action definition either) to audit;
# otherwise evidence only. Never PASS or FAIL.
sieve_registry.register(
"github_workflows_present",
phase="deterministic",
handler_fn=github_workflows_present_handler,
description="WARN when .github/workflows/ holds no workflow file",
ceiling={"fail"},
settings={"actions"},
)
sieve_registry.set_plugin_context(None)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,15 @@ run: |
docs_url = "https://baseline.openssf.org/versions/2025-10-10#OSPS-BR-01.01"
location_hint = ".github/workflows"

# No workflow file means nothing to audit (#584): WARN with that reason, and
# zizmor (exit 3, unexpected_exit) never runs. With workflow files present it
# is evidence only. zizmor's template-injection audit also reads action
# definitions, which it collects from the whole tree, so an action.yml or
# action.yaml anywhere also lets zizmor run.
[[controls."OSPS-BR-01.01".passes]]
handler = "github_workflows_present"
actions = true

[[controls."OSPS-BR-01.01".passes]]
handler = "exec"
command = ["zizmor", "--format", "json", "--offline", "$PATH"]
Expand Down Expand Up @@ -756,6 +765,12 @@ run: |
```
"""

# No workflow file means nothing to audit (#584): WARN with that reason, and
# grep (exit 2, unexpected_exit) never runs. With workflow files present it
# is evidence only.
[[controls."OSPS-BR-01.02".passes]]
handler = "github_workflows_present"

# Keyword absence, evidence only (feature 041): not finding github.head_ref
# does not show branch names are handled safely (github.ref_name and event
# payload fields carry them too).
Expand Down Expand Up @@ -2972,6 +2987,13 @@ help_md = """Minimize CI/CD job permissions.
4. Avoid using `write-all` or `admin` permissions
"""

# No workflow file means nothing to audit (#584): WARN with that reason, and
# zizmor (exit 3, unexpected_exit) never runs. With workflow files present it
# is evidence only. No `actions = true`: zizmor's excessive-permissions audit
# reads workflows only.
[[controls."OSPS-AC-04.02".passes]]
handler = "github_workflows_present"

# Primary: zizmor's `excessive-permissions` deep audit. zizmor models per-step
# action requirements and flags workflow- or job-level grants broader than the
# steps actually exercise — the semantic least-privilege check a regex can't do.
Expand Down
24 changes: 19 additions & 5 deletions tests/darnit/sieve/baseline_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ def patched(handler_status, allowed, is_last_step=False):
orchestrator.SieveOrchestrator.__module__ # touch, keeps linters honest


def _capture_one(framework: str, repo_path: str, neutralize: bool = False) -> dict[str, str]:
def _details(result: Any) -> str:
raw = result.get("details") if isinstance(result, dict) else getattr(result, "message", None)
return "" if raw is None else str(raw)


def _capture_one(framework: str, repo_path: str, neutralize: bool = False) -> dict[str, dict[str, str]]:
"""Audit one (framework, repo) pair in THIS process."""
if neutralize:
_neutralize_warn()
Expand All @@ -177,11 +182,19 @@ def _capture_one(framework: str, repo_path: str, neutralize: bool = False) -> di
evaluate_claims=False,
framework_name=framework,
)
return {_control_id(r): _status(r) for r in results}
return {_control_id(r): {"status": _status(r), "details": _details(r)} for r in results}


def capture(neutralize: bool = False) -> dict[str, dict[str, str]]:
"""Per-control status for every (framework, repo) pair.
"""Per-control status for every (framework, repo) pair."""
return {
pair: {control_id: result["status"] for control_id, result in controls.items()}
for pair, controls in capture_details(neutralize).items()
}


def capture_details(neutralize: bool = False) -> dict[str, dict[str, dict[str, str]]]:
"""Per-control ``{"status", "details"}`` for every (framework, repo) pair.

Each pair runs in a FRESH SUBPROCESS. This is not defensive style, it is
required for the capture to mean anything: control registration is
Expand All @@ -195,7 +208,7 @@ def capture(neutralize: bool = False) -> dict[str, dict[str, str]]:
tracked separately. Subprocess isolation is how this baseline avoids
measuring it instead of measuring SC-008.
"""
out: dict[str, dict[str, str]] = {}
out: dict[str, dict[str, dict[str, str]]] = {}
with tempfile.TemporaryDirectory() as scratch:
# Copy once, not once per framework: repo_inputs() is not idempotent
# against the same scratch directory.
Expand All @@ -214,7 +227,8 @@ def capture(neutralize: bool = False) -> dict[str, dict[str, str]]:
env=_neutral_env(Path(scratch)),
)
if proc.returncode != 0:
out[key] = {"__capture_error__": proc.stderr.strip()[-400:]}
error = proc.stderr.strip()[-400:]
out[key] = {"__capture_error__": {"status": error, "details": error}}
continue
out[key] = json.loads(proc.stdout)
return out
Expand Down
Loading
Loading