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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
10 changes: 10 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@
- manual
types:
- yaml
- id: check-for-go-fips-test
name: check for go-fips test
description: Check that packages using go-fips have corresponding go-fips tests
entry: check-for-go-fips-test
language: python
stages:
- pre-commit
- manual
types:
- yaml
2 changes: 2 additions & 0 deletions example.pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ repos:
)$
- id: check-pipeline-name-only-steps
files: '^[^.][^/]*\.yaml$' # matches non-hidden .yaml files at the top level only
- id: check-for-go-fips-test
files: '^[^.][^/]*\.yaml$' # matches non-hidden .yaml files at the top level only

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be more efficient to use this to identify the go-fips packages that need to be parsed. If we can filter out packages at this level, we needn't spawn another python interpreter to run the check. Of course, if it might be the case that only a subpackage will match, then that won't work.

- repo: https://github.com/chainguard-dev/yam
rev: 768695300c5f663012a77911eb4920c12e5ed2e5 # frozen: v0.2.26
hooks:
Expand Down
151 changes: 151 additions & 0 deletions pre_commit_hooks/check_for_go_fips_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
from __future__ import annotations

import argparse
import re
import sys
from collections.abc import Sequence
from typing import Any

from pre_commit_hooks.melange import build_steps
from pre_commit_hooks.melange import environment_packages
from pre_commit_hooks.melange import iter_steps
from pre_commit_hooks.melange import resolve_vars
from pre_commit_hooks.melange import run_check
from pre_commit_hooks.melange import subpackages
from pre_commit_hooks.melange import test_steps
from pre_commit_hooks.melange import uses_any

# Go toolchain packages that imply a FIPS build, matched as prefixes against
# environment packages and `go-package:` inputs. One entry per toolchain family
# test/go-fips-check knows how to verify:
# go-fips, go-fips-md5 Microsoft-patched Go with the OpenSSL backend; at 1.27
# these are meta packages pointing at go-msft-1.27
# go-msft the same toolchain under its own name (systemcrypto)
# go-geomys Geomys Go with the native FIPS 140 module
# go-openssl unpatched golang-fips/go
FIPS_GO_PREFIXES = ("go-fips", "go-msft", "go-geomys", "go-openssl")

# Pipelines that install the toolchain named by their `go-package` input.
GO_PIPELINES_WITH_TOOLCHAIN_INPUT = frozenset({"go/build", "go/install"})
# Pipelines that build with whatever `go` the build environment provides.
GO_PIPELINES_FROM_ENVIRONMENT = frozenset({"go/build/v2"})
# `runs:` blocks that call the compiler directly also use the environment's go.
GO_BUILD_COMMAND = re.compile(r"\bgo\s+(build|install)\b")

FIPS_TEST = frozenset({"test/go-fips-check"})
EMPTY_PACKAGE_TESTS = frozenset({"test/emptypackage", "test/tw/emptypackage"})
# Moves the main package's binaries into a subpackage, which then owns the test.
SPLIT_BIN = frozenset({"split/bin"})


def is_fips_toolchain(package: Any) -> bool:
return isinstance(package, str) and package.startswith(FIPS_GO_PREFIXES)


def builds_with_fips(steps: Any, env_has_fips_go: bool) -> bool:
"""Whether *steps* compile Go with a FIPS toolchain.

`go/build` and `go/install` say so explicitly through `go-package`.
`go/build/v2` and hand-written `go build` lines use the environment's go,
so they count only when the environment provides a FIPS toolchain.
"""
for step in iter_steps(steps):
uses = step.get("uses")
if uses in GO_PIPELINES_WITH_TOOLCHAIN_INPUT:
inputs = step.get("with") or {}
if is_fips_toolchain(inputs.get("go-package")):
return True
elif uses in GO_PIPELINES_FROM_ENVIRONMENT:
if env_has_fips_go:
return True
elif env_has_fips_go and isinstance(step.get("runs"), str):
if GO_BUILD_COMMAND.search(step["runs"]):
return True
return False


def installed_in_main_test(melange_cfg: dict[str, Any], subpkg_name: str) -> bool:
"""Whether the main test environment installs *subpkg_name*.

Names are compared before template expansion. For range subpackages the
part before ``${{range.key}}`` is matched as a prefix, so a main test that
installs ``${{package.name}}-controller`` covers
``${{package.name}}-${{range.key}}``.
"""
test = melange_cfg.get("test") or {}
packages = environment_packages(test) if isinstance(test, dict) else []
if subpkg_name in packages:
return True
prefix, sep, _ = subpkg_name.partition("${{range.key}}")
return bool(sep and prefix) and any(
isinstance(p, str) and p.startswith(prefix) for p in packages
)


def check_go_fips_compliance(melange_cfg: dict[str, Any]) -> list[str]:
"""Return a message for every package or subpackage that builds Go with a
FIPS toolchain and has no test/go-fips-check covering it.

Comment thread
AmberArcadia marked this conversation as resolved.
Resolved
Coverage is the scope's own test pipeline, an empty-package test (nothing
to check), or for subpackages the main test pipeline when it both runs
test/go-fips-check and installs the subpackage.
"""
issues = []
# Toolchain names are often templated (`go-package: ${{vars.go-package}}`);
# resolve what can be resolved without a melange compile.
melange_cfg = resolve_vars(melange_cfg)
env_has_fips_go = any(
is_fips_toolchain(p) for p in environment_packages(melange_cfg)
)
subs = subpackages(melange_cfg)

main_test = test_steps(melange_cfg)
main_has_fips_test = uses_any(main_test, FIPS_TEST)
main_builds_fips = builds_with_fips(build_steps(melange_cfg), env_has_fips_go)
# split/bin in a subpackage moves the main package's binaries there: the
# subpackage inherits the obligation and the main package is left with none.
split_off = [s for s in subs if uses_any(build_steps(s), SPLIT_BIN)]

if (
main_builds_fips
and not split_off
and not main_has_fips_test
and not uses_any(main_test, EMPTY_PACKAGE_TESTS)
):
issues.append(
"main package builds with a FIPS Go toolchain but lacks test/go-fips-check",
)

for i, subpkg in enumerate(subs):
name = str(subpkg.get("name", f"#{i}"))
builds_fips = builds_with_fips(build_steps(subpkg), env_has_fips_go) or (
main_builds_fips and any(s is subpkg for s in split_off)
)
if not builds_fips:
continue
sub_test = test_steps(subpkg)
if uses_any(sub_test, FIPS_TEST) or uses_any(sub_test, EMPTY_PACKAGE_TESTS):
continue
if main_has_fips_test and installed_in_main_test(melange_cfg, name):
continue
issues.append(
f"subpackage '{name}' builds with a FIPS Go toolchain but lacks "
"test/go-fips-check (in its own test, or in the main test with the "
"subpackage installed)",
)

return issues


def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Check that packages built with a FIPS Go toolchain "
"have a test/go-fips-check test",
)
parser.add_argument("filenames", nargs="*", help="Filenames to check")
args = parser.parse_args(argv)
return run_check(args.filenames, check_go_fips_compliance)


if __name__ == "__main__":
sys.exit(main())
55 changes: 4 additions & 51 deletions pre_commit_hooks/check_pipeline_name_only_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,12 @@

import argparse
import sys
from collections.abc import Iterator
from collections.abc import Sequence
from typing import Any

import ruamel.yaml

yaml = ruamel.yaml.YAML(typ="safe")


def iter_pipelines(melange_cfg: dict[str, Any]) -> Iterator[tuple[str, list[Any]]]:
"""Yield (label, steps) for every pipeline in a melange config.

Covers the main build and test pipelines and each subpackage's build and
test pipelines. A ``pipeline:`` key with no value (YAML null) yields an
empty list, matching how melange treats it.
"""
scopes: list[tuple[str, dict[str, Any]]] = [("main", melange_cfg)]
for i, subpkg in enumerate(melange_cfg.get("subpackages") or []):
if isinstance(subpkg, dict):
scopes.append((f"subpackage '{subpkg.get('name', i)}'", subpkg))
for label, scope in scopes:
yield f"{label} pipeline", scope.get("pipeline") or []
test = scope.get("test") or {}
if isinstance(test, dict):
yield f"{label} test pipeline", test.get("pipeline") or []


def iter_steps(steps: list[Any]) -> Iterator[dict[str, Any]]:
"""Yield every dict step in *steps*, descending into nested ``pipeline:`` lists."""
for step in steps:
if not isinstance(step, dict):
continue
yield step
yield from iter_steps(step.get("pipeline") or [])
from pre_commit_hooks.melange import iter_pipelines
from pre_commit_hooks.melange import iter_steps
from pre_commit_hooks.melange import run_check


def check_pipeline_steps(melange_cfg: dict[str, Any]) -> list[str]:
Expand All @@ -62,26 +34,7 @@ def main(argv: Sequence[str] | None = None) -> int:
)
parser.add_argument("filenames", nargs="*", help="Filenames to check")
args = parser.parse_args(argv)

retval = 0

for filename in args.filenames:
try:
with open(filename) as f:
melange_cfg = yaml.load(f)
except Exception as e:
print(f"Error loading {filename}: {e}")
retval = 1
continue

if not isinstance(melange_cfg, dict):
continue

for issue in check_pipeline_steps(melange_cfg):
print(f"{filename}: {issue}")
retval = 1

return retval
return run_check(args.filenames, check_pipeline_steps)


if __name__ == "__main__":
Expand Down
131 changes: 131 additions & 0 deletions pre_commit_hooks/melange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from __future__ import annotations

import re
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Sequence
from typing import Any

import ruamel.yaml

# Helpers for walking melange package configurations.
#
# Shared by the hooks that inspect package YAML before melange compiles it.
# Every accessor tolerates a key that is present with no value (YAML null),
# which melange treats the same as an absent key.

yaml = ruamel.yaml.YAML(typ="safe")


def iter_steps(steps: Any) -> Iterator[dict[str, Any]]:
"""Yield every dict step in *steps*, descending into nested ``pipeline:`` lists."""
for step in steps or []:
if not isinstance(step, dict):
continue
yield step
yield from iter_steps(step.get("pipeline"))


def uses_any(steps: Any, pipelines: frozenset[str]) -> bool:
"""Whether any step in *steps*, at any depth, ``uses:`` one of *pipelines*."""
return any(step.get("uses") in pipelines for step in iter_steps(steps))


def subpackages(melange_cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""The ``subpackages:`` entries that are mappings, in file order."""
return [s for s in melange_cfg.get("subpackages") or [] if isinstance(s, dict)]


def build_steps(scope: dict[str, Any]) -> list[Any]:
"""Top-level steps of *scope*'s build ``pipeline:``."""
return list(scope.get("pipeline") or [])


def test_steps(scope: dict[str, Any]) -> list[Any]:
"""Top-level steps of *scope*'s ``test.pipeline:``."""
test = scope.get("test") or {}
return list(test.get("pipeline") or []) if isinstance(test, dict) else []


def iter_pipelines(melange_cfg: dict[str, Any]) -> Iterator[tuple[str, list[Any]]]:
"""Yield (label, steps) for the main and every subpackage build and test pipeline."""
scopes: list[tuple[str, dict[str, Any]]] = [("main", melange_cfg)]
scopes += [
(f"subpackage '{s.get('name', i)}'", s)
for i, s in enumerate(subpackages(melange_cfg))
]
for label, scope in scopes:
yield f"{label} pipeline", build_steps(scope)
yield f"{label} test pipeline", test_steps(scope)


_VAR_REF = re.compile(r"\$\{\{vars\.([A-Za-z0-9_.-]+)\}\}")


def resolve_vars(melange_cfg: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *melange_cfg* with ``${{vars.NAME}}`` substituted.

Only plain entries of the top-level ``vars:`` mapping are resolved; values
produced by ``var-transforms`` and other template namespaces are left as
written. That is enough to see through ``go-package: ${{vars.go-package}}``
without running ``melange compile``.
"""
vars_ = melange_cfg.get("vars") or {}
if not isinstance(vars_, dict):
return melange_cfg

def substitute(value: Any) -> Any:
if isinstance(value, str):
return _VAR_REF.sub(
lambda m: str(vars_.get(m.group(1), m.group(0))),
value,
)
if isinstance(value, list):
return [substitute(v) for v in value]
if isinstance(value, dict):
return {k: substitute(v) for k, v in value.items()}
return value

resolved: dict[str, Any] = substitute(melange_cfg)
return resolved


def environment_packages(scope: dict[str, Any]) -> list[Any]:
"""Packages under ``environment.contents.packages`` of *scope*.

Works for the top-level build environment and for a ``test:`` section,
which has the same shape.
"""
env = scope.get("environment") or {}
contents = (env.get("contents") or {}) if isinstance(env, dict) else {}
packages = contents.get("packages") if isinstance(contents, dict) else None
return list(packages or [])


def run_check(
filenames: Sequence[str],
check: Callable[[dict[str, Any]], list[str]],
) -> int:
"""Load each file as a melange config, print *check*'s findings per file.

Returns 1 if any file failed to load or produced a finding, else 0.
Files that do not parse to a mapping are skipped.
"""
retval = 0
for filename in filenames:
try:
with open(filename) as f:
melange_cfg = yaml.load(f)
except Exception as e:
print(f"Error loading {filename}: {e}")
retval = 1
continue

if not isinstance(melange_cfg, dict):
continue

for issue in check(melange_cfg):
print(f"{filename}: {issue}")
retval = 1

return retval
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ python_requires = >=3.9
console_scripts =
shellcheck-run-steps = pre_commit_hooks.shellcheck_run_steps:main
check-pipeline-name-only-steps = pre_commit_hooks.check_pipeline_name_only_steps:main
check-for-go-fips-test = pre_commit_hooks.check_for_go_fips_test:main

[bdist_wheel]
universal = True
Expand Down
Loading
Loading