diff --git a/README.md b/README.md index afde0c4..6122c2b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ **IMA-PCR-Utils** (`imapcrutils`) is a Python library for Integrity Measurement Architecture (IMA) and Platform Configuration Register (PCR), providing functionality for parsing IMA log entries, calculating PCR10 hash values and -boot_aggregate values. +boot_aggregate values, and appraising IMA log entries against a YAML allow/deny +policy. ## Installation @@ -37,6 +38,13 @@ The `imapcrutils` module consists of the following public types and functions: | `truncate_ima_log_by_pcr` | Truncate the IMA log at the point where the calculated PCR matches the reference value. | | `validate_ima_log_entry` | Validate a single entry by comparing the template hash with the recomputed value. | | `calculate_boot_aggregate` | Calculate `boot_aggregate` from PCR0..PCR9 values. | +| `AppraisalResult` | Verdict (`ALLOW` / `DENY` / `NEUTRAL`) for a single IMA log entry against an appraisal policy. | +| `PolicyComponent` | A single named component of an appraisal policy (`name`, `path` glob, optional `allow`/`deny` hash sets). | +| `AppraisalPolicy` | An ordered collection of `PolicyComponent`s; the first matching component decides the verdict for an entry. | +| `load_policy` | Parse a YAML appraisal policy string into an `AppraisalPolicy`. | +| `load_policy_file` | Load an `AppraisalPolicy` from a YAML file on disk. | +| `appraise_ima_log` | Classify each IMA log entry against an appraisal policy, returning `(entry, verdict)` pairs. | +| `verify_ima_log` | Return `True` when no IMA log entry is denied by the policy. | ### CLI Tools / Example Scripts @@ -48,6 +56,7 @@ and command-line tools. Sample IMA log and PCR list files are also available. | `pcr10.py` | Calculate PCR10 from input IMA log | | `truncate_log.py` | Truncate IMA log at the point where the calculated PCR matches the reference value | | `boot_aggregate.py` | Calculate boot_aggregate from PCR list file including PCR[0-9] | +| `appraise.py` | Appraise IMA log entries against a YAML allow/deny policy | ### Compare with the true PCR10 hash value diff --git a/examples/README.md b/examples/README.md index 3aee0cc..f590126 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,7 +44,7 @@ where vTPM is available. python pcr10.py # Replay PCR10 from the sample IMA log file -python pcr10.py -i ascii_runtime_measurements +python pcr10.py -i sample_input/ascii_runtime_measurements ``` ## truncate_log @@ -81,14 +81,57 @@ the matching entry. ```shell # Truncate IMA log using reference PCR10 from pcrlist_2.bin -python truncate_pcr10.py -i ascii_runtime_measurements_2 -p c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678 +python truncate_pcr10.py -i sample_input/ascii_runtime_measurements_2 -p c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678 # Save truncated log to file -python truncate_pcr10.py -i ascii_runtime_measurements_2 \ +python truncate_pcr10.py -i sample_input/ascii_runtime_measurements_2 \ -p c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678 \ -o truncated_measurements.txt ``` +## appraise + +### Usage + +```shell +python appraise.py [-i $IMA_LOG_PATH] -p $POLICY_PATH [-s $SHOW] [-o $OUTPUT_PATH] +``` + +```shell +python appraise.py [--in $IMA_LOG_PATH] --policy $POLICY_PATH [--show $SHOW] [--out $OUTPUT_PATH] +``` + +### Required Arguments + +- `-p, --policy`: Path to the YAML appraisal policy file + +### Options + +- `-i, --in`: Path to the IMA log file (default: `/sys/kernel/security/ima/ascii_runtime_measurements`) +- `-s, --show`: Which verdicts to print: `all`, `deny`, `non-allow` (deny + + neutral) (default: `deny`) +- `-o, --out`: Path to the output file (default: stdout) + +### Description + +This tool classifies each IMA log entry against a YAML appraisal policy and +prints the selected verdicts as tab-separated `\t\t` lines. +A summary is written to stderr. Exit status is `0` when no entry is denied, +`1` when at least one entry is denied, and `2` on I/O or policy parse errors. + +See [appraise_policy.yaml](appraise_policy.yaml) for the policy format. + +### Example + +```shell +# Print all denied entries (default) for the sample IMA log +python appraise.py -i sample_input/ascii_runtime_measurements -p sample_input/appraise_policy.yaml + +# Show every verdict +python appraise.py -i sample_input/ascii_runtime_measurements \ + -p appraise_policy.yaml -s all +``` + ## boot-aggregate ### Usage @@ -121,5 +164,5 @@ python boot_aggregate.py --in $PCR_LIST_PATH --selector $SELECTOR [--hash-algori ```shell # Calculate boot_aggregate from the sample PCR list file -python boot_aggregate.py --in pcr_list.bin -s sha256:0,1,2,3,4,5,6,7,8,9,10,12,14,23 +python boot_aggregate.py --in sample_input/pcrlist.bin -s sha256:0,1,2,3,4,5,6,7,8,9,10,12,14,23 ``` diff --git a/examples/appraise.py b/examples/appraise.py new file mode 100755 index 0000000..00247a1 --- /dev/null +++ b/examples/appraise.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +CLI tool for appraising IMA log entries against a YAML policy. +""" + +import argparse +import sys + +from imapcrutils import AppraisalResult, appraise_ima_log, load_policy_file, parse_ima_log_string + +DEFAULT_IMA_LOG_PATH = "/sys/kernel/security/ima/ascii_runtime_measurements" + +SHOW_CHOICES = ["all", "deny", "non-allow"] + + +def filter_results(results, show: str): + """ + Filter (entry, verdict) pairs by --show mode. + """ + match show: + case "all": + return results + case "deny": + return [(e, v) for e, v in results if v is AppraisalResult.DENY] + case "non-allow": + return [(e, v) for e, v in results if v is not AppraisalResult.ALLOW] + case _: + # will not reach here (choices enforced by argparse) + raise ValueError(f"Invalid show mode: {show}") + + +def format_results(results) -> str: + """ + Format (entry, verdict) pairs as tab-separated lines. + """ + lines = [f"{verdict.value}\t{entry.file_hash.hex()}\t{entry.file_path}" for entry, verdict in results] + return "\n".join(lines) + "\n" if lines else "" + + +def write_output(text: str, output_path: str | None) -> None: + """ + Write text to file or stdout. + """ + if output_path is None: + print(text, end="") + else: + with open(output_path, "w", encoding="utf-8") as f: + f.write(text) + + +def main() -> int: + """ + Main function. + """ + parser = argparse.ArgumentParser(description="Appraise IMA log entries against a YAML policy.") + parser.add_argument( + "-i", + "--in", + dest="input_path", + default=DEFAULT_IMA_LOG_PATH, + help=f"Path to the IMA log file (default: {DEFAULT_IMA_LOG_PATH})", + ) + parser.add_argument( + "-p", + "--policy", + dest="policy_path", + required=True, + help="Path to the YAML appraisal policy file", + ) + parser.add_argument( + "-s", + "--show", + dest="show", + type=str.lower, + default="deny", + choices=SHOW_CHOICES, + help="Which verdicts to print: all entries, deny only, or non-allow (deny + neutral) (default: deny)", + ) + parser.add_argument( + "-o", + "--out", + dest="output_path", + default=None, + help="Path to the output file (default: stdout)", + ) + + args = parser.parse_args() + + # Load the appraisal policy + try: + policy = load_policy_file(args.policy_path) + except FileNotFoundError: + print(f"Error: policy file not found: {args.policy_path}", file=sys.stderr) + return 2 + except ValueError as e: + print(f"Error: invalid policy: {e}", file=sys.stderr) + return 2 + + # Read IMA log entries + try: + with open(args.input_path, encoding="utf-8") as f: + lines = f.read() + except FileNotFoundError: + print(f"Error: IMA log file not found: {args.input_path}", file=sys.stderr) + return 2 + except OSError as e: + print(f"Error reading IMA log file: {e}", file=sys.stderr) + return 2 + + entries = parse_ima_log_string(lines) + results = appraise_ima_log(entries, policy) + + write_output(format_results(filter_results(results, args.show)), args.output_path) + + deny_count = sum(1 for _, v in results if v is AppraisalResult.DENY) + allow_count = sum(1 for _, v in results if v is AppraisalResult.ALLOW) + neutral_count = len(results) - deny_count - allow_count + print( + f"Appraised {len(results)} entries: {allow_count} allow, {deny_count} deny, {neutral_count} neutral", + file=sys.stderr, + ) + + return 1 if deny_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/sample_input/appraise_policy.yaml b/examples/sample_input/appraise_policy.yaml new file mode 100644 index 0000000..2d9909e --- /dev/null +++ b/examples/sample_input/appraise_policy.yaml @@ -0,0 +1,9 @@ +boot_aggregate: + path: boot_aggregate + allow: [088faac4777b024045bd578c5c3f8efc4ac2cafb4af90a12832a762feb58eb88] + +kernel_modules: + path: "*/**/autofs4.ko.zst" + allow: + - bbdbca85a82cf96aad024bdfedaa4fcc791d857b338b6f7c5a4625bce0118ac4 + - cf06a09ff00ee3275779e83cf9a4037dd822ba9dc16442584212f605ba71e341 diff --git a/examples/ascii_runtime_measurements b/examples/sample_input/ascii_runtime_measurements similarity index 100% rename from examples/ascii_runtime_measurements rename to examples/sample_input/ascii_runtime_measurements diff --git a/examples/ascii_runtime_measurements_2 b/examples/sample_input/ascii_runtime_measurements_2 similarity index 100% rename from examples/ascii_runtime_measurements_2 rename to examples/sample_input/ascii_runtime_measurements_2 diff --git a/examples/pcr_list.bin b/examples/sample_input/pcrlist.bin similarity index 100% rename from examples/pcr_list.bin rename to examples/sample_input/pcrlist.bin diff --git a/examples/pcrlist_2.bin b/examples/sample_input/pcrlist_2.bin similarity index 100% rename from examples/pcrlist_2.bin rename to examples/sample_input/pcrlist_2.bin diff --git a/imapcrutils/__init__.py b/imapcrutils/__init__.py index afef70d..01f6a28 100644 --- a/imapcrutils/__init__.py +++ b/imapcrutils/__init__.py @@ -17,3 +17,12 @@ truncate_ima_log_by_pcr, validate_ima_log_entry, ) +from imapcrutils.verify import ( + AppraisalPolicy, + AppraisalResult, + PolicyComponent, + appraise_ima_log, + load_policy, + load_policy_file, + verify_ima_log, +) diff --git a/imapcrutils/verify.py b/imapcrutils/verify.py new file mode 100644 index 0000000..529cfa2 --- /dev/null +++ b/imapcrutils/verify.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: MIT +""" +IMA log appraisal against a YAML policy. + +Each policy component declares a file-path glob and one or more allow/deny +hash rules. For every IMA log entry the first matching component (in +declaration order) decides the verdict: Allow, Deny, or Neutral. + +Policy format (YAML):: + + component1: + path: + allow: [...] # optional + component2: + path: + deny: [...] # optional + ... +""" + +__all__ = [ + "AppraisalResult", + "PolicyComponent", + "AppraisalPolicy", + "load_policy", + "load_policy_file", + "appraise_ima_log", + "verify_ima_log", +] + +import fnmatch +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +import yaml + +from imapcrutils.libs import IMALogEntry + + +class AppraisalResult(Enum): + """Verdict for a single IMA log entry against an appraisal policy.""" + + ALLOW = "allow" + DENY = "deny" + NEUTRAL = "neutral" + + +@dataclass +class PolicyComponent: + """A single named component of an appraisal policy.""" + + name: str + path: str + allow: set[str] | None = None + deny: set[str] | None = None + + def matches_path(self, file_path: str) -> bool: + """Return True if file_path matches this component's path glob.""" + return fnmatch.fnmatchcase(file_path, self.path) + + def appraise_hash(self, file_hash_hex: str) -> AppraisalResult: + """Classify a hex-encoded file hash against this component's rules.""" + normalized = file_hash_hex.lower() + if self.deny is not None: + if normalized in self.deny: + return AppraisalResult.DENY + return AppraisalResult.ALLOW + if self.allow is not None: + if normalized in self.allow: + return AppraisalResult.ALLOW + return AppraisalResult.DENY + return AppraisalResult.NEUTRAL + + +@dataclass +class AppraisalPolicy: + """An ordered collection of policy components.""" + + components: list[PolicyComponent] = field(default_factory=list) + + def appraise(self, entry: IMALogEntry) -> AppraisalResult: + """Return the verdict for entry from the first matching component.""" + file_hash_hex = entry.file_hash.hex() + for component in self.components: + if component.matches_path(entry.file_path): + return component.appraise_hash(file_hash_hex) + return AppraisalResult.NEUTRAL + + +def _coerce_hash_set(name: str, single: object, many: object) -> set[str]: + result: set[str] = set() + if single is not None: + if not isinstance(single, str): + raise ValueError(f"component '{name}': scalar hash must be a string") + result.add(single.lower()) + if many is not None: + if not isinstance(many, list) or not all(isinstance(h, str) for h in many): + raise ValueError(f"component '{name}': list hash field must be a list of strings") + result.update(h.lower() for h in many) + return result + + +def load_policy(yaml_string: str) -> AppraisalPolicy: + """ + Parse a YAML appraisal policy string into an AppraisalPolicy. + + Args: + yaml_string: YAML document mapping component names to rule dicts. + + Returns: + AppraisalPolicy with components in declaration order. + + Raises: + ValueError: if the document is not a mapping or a component is malformed. + """ + data = yaml.safe_load(yaml_string) + if data is None: + return AppraisalPolicy(components=[]) + if not isinstance(data, dict): + raise ValueError("policy root must be a mapping of component names to rules") + + components: list[PolicyComponent] = [] + for name, rules in data.items(): + if not isinstance(rules, dict): + raise ValueError(f"component '{name}': rules must be a mapping") + path = rules.get("path") + if not isinstance(path, str): + raise ValueError(f"component '{name}': 'path' is required and must be a string") + allowlist = rules.get("allow") + if isinstance(allowlist, list) and all(map(lambda x: isinstance(x, str), allowlist)): + allow = list(map(lambda x: x.lower(), allowlist)) + elif allowlist is None: + allow = None + else: + raise ValueError(f"component '{name}': 'allow' must be a list of strings") + denylist = rules.get("deny") + if isinstance(denylist, list) and all(map(lambda x: isinstance(x, str), denylist)): + deny = list(map(lambda x: x.lower(), denylist)) + elif denylist is None: + deny = None + else: + raise ValueError(f"component '{name}': 'deny' must be a list of strings") + components.append(PolicyComponent(name=str(name), path=path, allow=allow, deny=deny)) + return AppraisalPolicy(components=components) + + +def load_policy_file(path: str | Path) -> AppraisalPolicy: + """Load an appraisal policy from a YAML file on disk.""" + return load_policy(Path(path).read_text()) + + +def appraise_ima_log(entries: list[IMALogEntry], policy: AppraisalPolicy) -> list[tuple[IMALogEntry, AppraisalResult]]: + """ + Classify each IMA log entry against the appraisal policy. + + Args: + entries: IMA log entries to classify. + policy: Appraisal policy. + + Returns: + A list of (entry, verdict) pairs in the same order as entries. + """ + return [(entry, policy.appraise(entry)) for entry in entries] + + +def verify_ima_log(entries: list[IMALogEntry], policy: AppraisalPolicy) -> bool: + """ + Verify that no IMA log entry is denied by the policy. + + Returns True when every entry's verdict is Allow or Neutral. Returns + False as soon as any entry hits a deny/denylist rule. + """ + return all(policy.appraise(entry) is not AppraisalResult.DENY for entry in entries) diff --git a/pyproject.toml b/pyproject.toml index fe0cc73..e3daf70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ version = "0.1.0" readme = "README.md" requires-python = ">=3.10" -dependencies = [] +dependencies = [ + "pyyaml >= 6.0", +] [project.optional-dependencies] dev = [ diff --git a/tests/conftest.py b/tests/conftest.py index 891cb11..0448ce4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest -EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" +EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" / "sample_input" @pytest.fixture @@ -23,8 +23,8 @@ def sample_ima_log(sample_ima_log_path): @pytest.fixture def sample_pcr_list_path(): - """Path to the sample pcr_list.bin file.""" - return EXAMPLES_DIR / "pcr_list.bin" + """Path to the sample pcrlist.bin file.""" + return EXAMPLES_DIR / "pcrlist.bin" @pytest.fixture diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bab41a..634e8ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MIT -"""Tests for the CLI example scripts (pcr10.py, boot_aggregate.py) via subprocess.""" +"""Tests for the CLI example scripts (pcr10.py, boot_aggregate.py, appraise.py) via subprocess.""" import pathlib import subprocess @@ -8,8 +8,10 @@ EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" PCR10_SCRIPT = EXAMPLES_DIR / "pcr10.py" BOOT_AGG_SCRIPT = EXAMPLES_DIR / "boot_aggregate.py" -SAMPLE_IMA_LOG = EXAMPLES_DIR / "ascii_runtime_measurements" -SAMPLE_PCR_LIST = EXAMPLES_DIR / "pcr_list.bin" +APPRAISE_SCRIPT = EXAMPLES_DIR / "appraise.py" +SAMPLE_IMA_LOG = EXAMPLES_DIR / "sample_input" / "ascii_runtime_measurements" +SAMPLE_PCR_LIST = EXAMPLES_DIR / "sample_input" / "pcrlist.bin" +SAMPLE_POLICY = EXAMPLES_DIR / "sample_input" / "appraise_policy.yaml" EXPECTED_PCR10_SHA256 = "90E7C2DF7E39D26D13A7F67F68FF3C92BB22ABB7477322A96B314B98D82524EE" EXPECTED_BOOT_AGGREGATE_SHA256 = "088faac4777b024045bd578c5c3f8efc4ac2cafb4af90a12832a762feb58eb88" @@ -173,3 +175,159 @@ def test_selector_missing_pcr0_to_9(self): check=False, ) assert result.returncode != 0 + + +# --------------------------------------------------------------------------- +# appraise.py +# --------------------------------------------------------------------------- + +# Hash of `boot_aggregate` in the sample IMA log; used to build a deny policy. +SAMPLE_BOOT_AGGREGATE_HASH = "088faac4777b024045bd578c5c3f8efc4ac2cafb4af90a12832a762feb58eb88" +# Total IMA log entries in the sample ascii_runtime_measurements file. +SAMPLE_IMA_LOG_ENTRY_COUNT = 32 + + +def _write_deny_policy(tmp_path: pathlib.Path, file_hash_hex: str) -> pathlib.Path: + """Write a minimal deny policy targeting boot_aggregate by hash and return its path.""" + policy_path = tmp_path / "deny_policy.yaml" + policy_path.write_text(f"boot_aggregate:\n path: boot_aggregate\n deny: [{file_hash_hex}]\n") + return policy_path + + +class TestAppraiseCli: + """Tests for examples/appraise.py CLI.""" + + def test_default_show_deny_on_clean_log_is_empty_and_exit_zero(self): + """With the sample allow-only policy, default '-s deny' prints nothing and exits 0.""" + result = run_script( + APPRAISE_SCRIPT, + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(SAMPLE_POLICY), + ) + assert result.stdout == "" + # summary always goes to stderr + assert "0 deny" in result.stderr + + def test_show_all_lists_every_entry(self): + """'-s all' must emit one tab-separated line per IMA log entry.""" + result = run_script( + APPRAISE_SCRIPT, + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(SAMPLE_POLICY), + "-s", + "all", + ) + lines = result.stdout.strip().splitlines() + assert len(lines) == SAMPLE_IMA_LOG_ENTRY_COUNT + # boot_aggregate is the first entry and should be classified as allow under the sample policy + first_verdict, first_hash, first_path = lines[0].split("\t") + assert first_verdict == "allow" + assert first_hash == SAMPLE_BOOT_AGGREGATE_HASH + assert first_path == "boot_aggregate" + + def test_show_non_allow_excludes_allowed_entries(self): + """'-s non-allow' must omit allow verdicts but include neutral and deny.""" + result = run_script( + APPRAISE_SCRIPT, + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(SAMPLE_POLICY), + "-s", + "non-allow", + ) + verdicts = [line.split("\t", 1)[0] for line in result.stdout.strip().splitlines()] + assert verdicts # the sample log has neutral entries + assert "allow" not in verdicts + + def test_deny_exits_one_and_prints_denied_entry(self, tmp_path): + """A deny policy that hits an entry must print it and exit with status 1.""" + policy_path = _write_deny_policy(tmp_path, SAMPLE_BOOT_AGGREGATE_HASH) + result = subprocess.run( + [ + sys.executable, + str(APPRAISE_SCRIPT), + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(policy_path), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + lines = result.stdout.strip().splitlines() + assert len(lines) == 1 + verdict, file_hash, file_path = lines[0].split("\t") + assert verdict == "deny" + assert file_hash == SAMPLE_BOOT_AGGREGATE_HASH + assert file_path == "boot_aggregate" + + def test_output_to_file(self, tmp_path): + """'-o' must write the verdict lines to the specified file.""" + out_file = tmp_path / "verdicts.tsv" + run_script( + APPRAISE_SCRIPT, + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(SAMPLE_POLICY), + "-s", + "all", + "-o", + str(out_file), + ) + lines = out_file.read_text().strip().splitlines() + assert len(lines) == SAMPLE_IMA_LOG_ENTRY_COUNT + + def test_missing_policy_arg(self): + """Omitting required --policy must cause a non-zero exit.""" + result = subprocess.run( + [sys.executable, str(APPRAISE_SCRIPT), "-i", str(SAMPLE_IMA_LOG)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + + def test_policy_file_not_found(self, tmp_path): + """A nonexistent policy path must exit with status 2.""" + missing = tmp_path / "does_not_exist.yaml" + result = subprocess.run( + [ + sys.executable, + str(APPRAISE_SCRIPT), + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(missing), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 2 + + def test_malformed_policy_file(self, tmp_path): + """A policy missing 'path' on a component must exit with status 2.""" + policy_path = tmp_path / "bad_policy.yaml" + policy_path.write_text("missing_path:\n allow: [abc]\n") + result = subprocess.run( + [ + sys.executable, + str(APPRAISE_SCRIPT), + "-i", + str(SAMPLE_IMA_LOG), + "-p", + str(policy_path), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 2 diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..bbddce5 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: MIT +"""Tests for imapcrutils.verify — appraisal of IMA log entries against a YAML policy.""" + +import textwrap + +import pytest + +from imapcrutils import ( + AppraisalPolicy, + AppraisalResult, + IMALogEntry, + PolicyComponent, + appraise_ima_log, + load_policy, + load_policy_file, + parse_ima_log_string, + verify_ima_log, +) + +SAMPLE_LINE = "10 8facace9d7255a1985e976e9bb59675f211c82de ima-ng sha256:088faac4777b024045bd578c5c3f8efc4ac2cafb4af90a12832a762feb58eb88 boot_aggregate" # noqa: E501 +SAMPLE_HASH = "088faac4777b024045bd578c5c3f8efc4ac2cafb4af90a12832a762feb58eb88" +OTHER_HASH = "00" * 32 + + +def _entry(path: str, hash_hex: str = SAMPLE_HASH) -> IMALogEntry: + return IMALogEntry( + pcr_idx="10", + template_hash="8facace9d7255a1985e976e9bb59675f211c82de", + template_name="ima-ng", + hash_algo="sha256", + file_hash=bytes.fromhex(hash_hex), + file_path=path, + ) + + +# --------------------------------------------------------------------------- +# load_policy +# --------------------------------------------------------------------------- + + +class TestLoadPolicy: + """Tests for load_policy — parsing YAML into AppraisalPolicy.""" + + def test_full_policy(self): + """Parse a policy with all four rule fields and preserve component order.""" + yaml_str = textwrap.dedent( + """ + kernel: + path: /usr/lib/modules/* + allow: [aaaa, bbbb, cccc] + userland: + path: /usr/bin/* + allow: [dddd] + userland2: + path: /usr/local/bin/* + deny: [eeee] + """ + ) + policy = load_policy(yaml_str) + assert [c.name for c in policy.components] == ["kernel", "userland", "userland2"] + kernel = policy.components[0] + assert kernel.path == "/usr/lib/modules/*" + assert kernel.allow == ["aaaa", "bbbb", "cccc"] + assert kernel.deny is None + userland = policy.components[1] + assert userland.path == "/usr/bin/*" + assert userland.allow == ["dddd"] + assert userland.deny is None + userland2 = policy.components[2] + assert userland2.path == "/usr/local/bin/*" + assert userland2.allow is None + assert userland2.deny == ["eeee"] + + def test_lowercases_hashes(self): + """Hashes are normalized to lowercase so callers can mix cases.""" + policy = load_policy("c:\n path: /x\n allow: [ABCDEF]\n") + assert policy.components[0].allow == ["abcdef"] + + def test_empty_yaml(self): + """An empty document is a policy with zero components.""" + assert load_policy("").components == [] + + def test_root_must_be_mapping(self): + """A non-mapping root document is rejected.""" + with pytest.raises(ValueError, match="root must be a mapping"): + load_policy("- not a mapping\n") + + def test_component_rules_must_be_mapping(self): + """Component rules must be a mapping.""" + with pytest.raises(ValueError, match="rules must be a mapping"): + load_policy("c: not-a-mapping\n") + + def test_missing_path(self): + """A component without 'path' is rejected.""" + with pytest.raises(ValueError, match="'path' is required"): + load_policy("c:\n allow: [abcd]\n") + + def test_allowlist_must_be_list_of_strings(self): + """allowlist must be a list of strings.""" + with pytest.raises(ValueError, match="'allow' must be a list of strings"): + load_policy("c:\n path: /x\n allow: 'not-a-list'\n") + + def test_load_policy_file(self, tmp_path): + """load_policy_file reads from disk and parses the same way.""" + f = tmp_path / "policy.yaml" + f.write_text("c:\n path: /x\n allow: [abcd]\n") + policy = load_policy_file(f) + assert policy.components[0].allow == ["abcd"] + + +# --------------------------------------------------------------------------- +# PolicyComponent / AppraisalPolicy.appraise +# --------------------------------------------------------------------------- + + +class TestPolicyComponentAppraise: + """Tests for the per-component verdict logic.""" + + def test_allow_hit(self): + """A hash in the allow set yields ALLOW.""" + c = PolicyComponent(name="c", path="*", allow={SAMPLE_HASH}) + assert c.appraise_hash(SAMPLE_HASH) is AppraisalResult.ALLOW + + def test_allow_unhit(self): + """A hash outside the allow set yields DENY.""" + c = PolicyComponent(name="c", path="*", allow={OTHER_HASH}) + assert c.appraise_hash(SAMPLE_HASH) is AppraisalResult.DENY + + def test_deny_hit(self): + """A hash in the deny set yields DENY.""" + c = PolicyComponent(name="c", path="*", deny={SAMPLE_HASH}) + assert c.appraise_hash(SAMPLE_HASH) is AppraisalResult.DENY + + def test_deny_unhit(self): + """A hash outside the deny set yields ALLOW.""" + c = PolicyComponent(name="c", path="*", deny={OTHER_HASH}) + assert c.appraise_hash(SAMPLE_HASH) is AppraisalResult.ALLOW + + +class TestAppraisalPolicy: + """Tests for AppraisalPolicy.appraise — path matching and ordering.""" + + def test_first_match_wins(self): + """The first component whose path matches decides the verdict.""" + first = PolicyComponent(name="first", path="/usr/*", allow={SAMPLE_HASH}) + second = PolicyComponent(name="second", path="/usr/bin/*", deny={SAMPLE_HASH}) + policy = AppraisalPolicy(components=[first, second]) + assert policy.appraise(_entry("/usr/bin/foo")) is AppraisalResult.ALLOW + + def test_first_match_wins_reverse_order(self): + """Reversing component order flips the verdict — confirms order matters.""" + first = PolicyComponent(name="first", path="/usr/bin/*", deny={SAMPLE_HASH}) + second = PolicyComponent(name="second", path="/usr/*", allow={SAMPLE_HASH}) + policy = AppraisalPolicy(components=[first, second]) + assert policy.appraise(_entry("/usr/bin/foo")) is AppraisalResult.DENY + + def test_no_matching_component_is_neutral(self): + """An entry whose path matches no component is NEUTRAL.""" + policy = AppraisalPolicy(components=[PolicyComponent(name="c", path="/etc/*", allow={SAMPLE_HASH})]) + assert policy.appraise(_entry("/usr/bin/foo")) is AppraisalResult.NEUTRAL + + def test_glob_question_mark(self): + """fnmatch '?' wildcards match a single character.""" + c = PolicyComponent(name="c", path="/a/?.bin", allow={SAMPLE_HASH}) + policy = AppraisalPolicy(components=[c]) + assert policy.appraise(_entry("/a/x.bin")) is AppraisalResult.ALLOW + assert policy.appraise(_entry("/a/xx.bin")) is AppraisalResult.NEUTRAL + + def test_empty_policy_is_neutral(self): + """An empty policy yields NEUTRAL for every entry.""" + assert AppraisalPolicy().appraise(_entry("/anything")) is AppraisalResult.NEUTRAL + + +# --------------------------------------------------------------------------- +# appraise_ima_log / verify_ima_log +# --------------------------------------------------------------------------- + + +class TestAppraiseImaLog: + """Tests for appraise_ima_log — classifying a list of entries.""" + + def test_returns_pairs_in_order(self): + """Result preserves input order and pairs each entry with its verdict.""" + entries = [ + _entry("/usr/bin/a"), + _entry("/usr/bin/b", OTHER_HASH), + _entry("/etc/x"), + ] + policy = AppraisalPolicy(components=[PolicyComponent(name="bin", path="/usr/bin/*", allow=[SAMPLE_HASH])]) + result = appraise_ima_log(entries, policy) + assert [r[0].file_path for r in result] == ["/usr/bin/a", "/usr/bin/b", "/etc/x"] + assert [r[1] for r in result] == [ + AppraisalResult.ALLOW, + AppraisalResult.DENY, + AppraisalResult.NEUTRAL, + ] + + def test_empty_entries(self): + """No entries yields an empty result list.""" + assert appraise_ima_log([], AppraisalPolicy()) == [] + + +class TestVerifyImaLog: + """Tests for verify_ima_log — boolean pass/fail across all entries.""" + + def test_all_allow_returns_true(self): + """All-allow log passes.""" + entries = [_entry("/usr/bin/a"), _entry("/usr/bin/b")] + policy = AppraisalPolicy(components=[PolicyComponent(name="bin", path="/usr/bin/*", allow={SAMPLE_HASH})]) + assert verify_ima_log(entries, policy) is True + + def test_neutral_entries_pass(self): + """Neutral entries (no matching component) do not fail verification.""" + entries = [_entry("/etc/x"), _entry("/usr/bin/a")] + policy = AppraisalPolicy(components=[PolicyComponent(name="bin", path="/usr/bin/*", allow={SAMPLE_HASH})]) + assert verify_ima_log(entries, policy) is True + + def test_single_deny_returns_false(self): + """A single deny in the log fails verification.""" + entries = [ + _entry("/usr/bin/good"), + _entry("/usr/bin/bad", OTHER_HASH), + ] + policy = AppraisalPolicy( + components=[PolicyComponent(name="bin", path="/usr/bin/*", allow={SAMPLE_HASH}, deny={OTHER_HASH})] + ) + assert verify_ima_log(entries, policy) is False + + def test_empty_log_passes(self): + """An empty log trivially passes.""" + assert verify_ima_log([], AppraisalPolicy()) is True + + def test_real_sample_with_wildcard_allowlist(self, sample_ima_log): + """A policy that allows every path/hash from the sample log passes.""" + entries = parse_ima_log_string(sample_ima_log) + all_hashes = {e.file_hash.hex() for e in entries} + policy = AppraisalPolicy(components=[PolicyComponent(name="all", path="*", allow=all_hashes)]) + assert verify_ima_log(entries, policy) is True + results = appraise_ima_log(entries, policy) + assert all(v is AppraisalResult.ALLOW for _, v in results) + + def test_real_sample_with_one_denied_hash(self, sample_ima_log): + """Denying any hash present in the sample log fails verification.""" + entries = parse_ima_log_string(sample_ima_log) + target_hash = entries[5].file_hash.hex() + policy = AppraisalPolicy(components=[PolicyComponent(name="all", path="*", deny={target_hash})]) + assert verify_ima_log(entries, policy) is False + + def test_load_policy_end_to_end(self, sample_ima_log): + """YAML-loaded policy integrates with parse_ima_log_string + verify.""" + entries = parse_ima_log_string(sample_ima_log) + boot_hash = entries[0].file_hash.hex() + yaml_str = textwrap.dedent( + f""" + boot: + path: boot_aggregate + allow: [{boot_hash}] + """ + ) + policy = load_policy(yaml_str) + results = dict((e.file_path, v) for e, v in appraise_ima_log(entries, policy)) + assert results["boot_aggregate"] is AppraisalResult.ALLOW diff --git a/uv.lock b/uv.lock index efcf166..152d105 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,9 @@ wheels = [ name = "imapcrutils" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] [package.optional-dependencies] dev = [ @@ -37,6 +40,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" }, ] provides-extras = ["dev"] @@ -95,6 +99,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.12"