diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index b3cea9b..94f97b8 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -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 diff --git a/example.pre-commit-config.yaml b/example.pre-commit-config.yaml index d462eb3..c18c34a 100644 --- a/example.pre-commit-config.yaml +++ b/example.pre-commit-config.yaml @@ -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 - repo: https://github.com/chainguard-dev/yam rev: 768695300c5f663012a77911eb4920c12e5ed2e5 # frozen: v0.2.26 hooks: diff --git a/pre_commit_hooks/check_for_go_fips_test.py b/pre_commit_hooks/check_for_go_fips_test.py new file mode 100644 index 0000000..2e28d68 --- /dev/null +++ b/pre_commit_hooks/check_for_go_fips_test.py @@ -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. + + 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()) diff --git a/pre_commit_hooks/check_pipeline_name_only_steps.py b/pre_commit_hooks/check_pipeline_name_only_steps.py index ba65696..c1fd997 100644 --- a/pre_commit_hooks/check_pipeline_name_only_steps.py +++ b/pre_commit_hooks/check_pipeline_name_only_steps.py @@ -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]: @@ -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__": diff --git a/pre_commit_hooks/melange.py b/pre_commit_hooks/melange.py new file mode 100644 index 0000000..69ba08a --- /dev/null +++ b/pre_commit_hooks/melange.py @@ -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 diff --git a/setup.cfg b/setup.cfg index 264145e..47ec3fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/test-data/README.md b/test-data/README.md index 8926836..853e323 100644 --- a/test-data/README.md +++ b/test-data/README.md @@ -28,3 +28,18 @@ same command works from another repo by replacing `.` with that path. - Expected: passes. Every step has `uses:` or `runs:` alongside any `name:`, and the last subpackage has an empty `pipeline:` key, which melange allows and the hook must not trip over. + +### go-fips-test-bad.yaml + +- Hook: `check-for-go-fips-test` +- Expected: fails with three findings: the main package, built by `go/build` + with a `go-fips` toolchain; a subpackage built by `go/build/v2` with the + environment's `go-fips`; and a subpackage with a hand-written `go build`. + None of the three has a `test/go-fips-check`. + +### go-fips-test-good.yaml + +- Hook: `check-for-go-fips-test` +- Expected: passes. Range subpackages are covered by the main test, which + installs them and runs `test/go-fips-check`; another subpackage carries its + own check; one builds no Go at all; and one has an empty `pipeline:`. diff --git a/test-data/go-fips-test-bad.yaml b/test-data/go-fips-test-bad.yaml new file mode 100644 index 0000000..5c93040 --- /dev/null +++ b/test-data/go-fips-test-bad.yaml @@ -0,0 +1,49 @@ +package: + name: fips-bad + version: "1.0.0" + epoch: 0 + description: "Builds Go binaries with go-fips but never runs test/go-fips-check" + copyright: + - license: Apache-2.0 + +environment: + contents: + packages: + - busybox + - go-fips-1.25 + +pipeline: + - uses: git-checkout + with: + repository: https://example.invalid/fips-bad + tag: v${{package.version}} + expected-commit: 0000000000000000000000000000000000000000 + - uses: go/build + with: + packages: . + output: fips-bad + go-package: go-fips-md5-1.25 + +test: + pipeline: + - uses: test/tw/ldd-check # BAD: main binary is built with go-fips, no test/go-fips-check + +subpackages: + - name: ${{package.name}}-helper + pipeline: + - uses: go/build/v2 # BAD: builds with the environment's go-fips, test lacks go-fips-check + with: + packages: ./cmd/helper + output: helper + test: + pipeline: + - uses: test/tw/help-check + with: + bins: helper + - name: ${{package.name}}-manual + pipeline: + - runs: | # BAD: hand-written go build with the environment's go-fips + go build -o "${{targets.contextdir}}/usr/bin/manual" ./cmd/manual + test: + pipeline: + - uses: test/tw/ldd-check diff --git a/test-data/go-fips-test-good.yaml b/test-data/go-fips-test-good.yaml new file mode 100644 index 0000000..9027869 --- /dev/null +++ b/test-data/go-fips-test-good.yaml @@ -0,0 +1,73 @@ +package: + name: fips-good + version: "1.0.0" + epoch: 0 + description: "Every go-fips build is covered by a test/go-fips-check" + copyright: + - license: Apache-2.0 + +environment: + contents: + packages: + - busybox + - go-fips-1.25 + +pipeline: + - uses: git-checkout + with: + repository: https://example.invalid/fips-good + tag: v${{package.version}} + expected-commit: 0000000000000000000000000000000000000000 + - uses: go/build + with: + packages: . + output: fips-good + go-package: go-fips-md5-1.25 + +data: + - name: components + items: + controller: controller + agent: agent + +test: + environment: + contents: + packages: + - ${{package.name}}-controller + - ${{package.name}}-agent + pipeline: + - uses: test/go-fips-check # GOOD: covers the main binary and the installed subpackages + +subpackages: + - name: ${{package.name}}-${{range.key}} # GOOD: installed in the main test, which runs go-fips-check + range: components + pipeline: + - uses: go/build/v2 + with: + packages: ./cmd/${{range.key}} + output: ${{range.key}} + test: + pipeline: + - uses: test/tw/ldd-check + - name: ${{package.name}}-tools + pipeline: + - runs: | + go build -o "${{targets.contextdir}}/usr/bin/tools" ./cmd/tools + test: + pipeline: + - uses: test/go-fips-check # GOOD: covered by its own test + - name: ${{package.name}}-compat + pipeline: + - runs: | + mkdir -p "${{targets.contextdir}}/usr/local/bin" + ln -sf /usr/bin/fips-good "${{targets.contextdir}}/usr/local/bin/fips-good" + test: + pipeline: + - uses: test/tw/symlink-check # GOOD: no Go build in this subpackage + - name: ${{package.name}}-meta + description: "An empty pipeline is allowed and must not trip the hook" + pipeline: + test: + pipeline: + - uses: test/tw/emptypackage