Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,42 @@ pip install git+https://github.com/acompany-develop/IMA-PCR-Utils

## What's inside

### Module
### Modules

The `imapcrutils` module consists of the following public types and functions:
The `imapcrutils` package is organized into the following modules, each with a
focused responsibility. All public symbols are also re-exported from the
top-level `imapcrutils` namespace for convenience.

#### `imapcrutils.log` — IMA log data model and parser

| Name | Description |
| ---- | ----------- |
| `IMALogEntry` | Represents a single IMA log entry (`pcr_idx`, `template_hash`, `template_name`, `hash_algo`, `file_hash`, `file_path`). |
| `parse_ima_log_string` | Parse an ASCII IMA log string into a list of `IMALogEntry`. |

#### `imapcrutils.template` — ima-ng template serialization and template_hash

| Name | Description |
| ---- | ----------- |
| `build_template_fields` | Build `ima-ng` template fields (digest/name) from an `IMALogEntry`. |
| `calculate_expected_template_hash` | Recompute the expected template hash for an entry (default: SHA-1). |
| `validate_ima_log_entry` | Validate a single entry by comparing the template hash with the recomputed value. |

#### `imapcrutils.pcr` — PCR10 replay and boot_aggregate

| Name | Description |
| ---- | ----------- |
| `calculate_pcr10` | Replay PCR10 by extending PCR10 with each `ima-ng` entry (default chain hash: SHA-256). |
| `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. |

#### `imapcrutils.appraisal` — IMA log appraisal against a YAML policy

Subpackage split into `policy` (data model), `loader` (YAML parsing), and
`appraise` (policy evaluation).

| Name | Description |
| ---- | ----------- |
| `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. |
Expand Down
3 changes: 2 additions & 1 deletion examples/appraise.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import argparse
import sys

from imapcrutils import AppraisalResult, appraise_ima_log, load_policy_file, parse_ima_log_string
from imapcrutils.appraisal import AppraisalResult, appraise_ima_log, load_policy_file
from imapcrutils.log import parse_ima_log_string

DEFAULT_IMA_LOG_PATH = "/sys/kernel/security/ima/ascii_runtime_measurements"

Expand Down
2 changes: 1 addition & 1 deletion examples/boot_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import hashlib
import sys

from imapcrutils import calculate_boot_aggregate
from imapcrutils.pcr import calculate_boot_aggregate


def select_hash_function(hash_algorithm: str):
Expand Down
3 changes: 2 additions & 1 deletion examples/pcr10.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import hashlib
import sys

from imapcrutils import calculate_pcr10, parse_ima_log_string
from imapcrutils.log import parse_ima_log_string
from imapcrutils.pcr import calculate_pcr10

DEFAULT_IMA_LOG_PATH = "/sys/kernel/security/ima/ascii_runtime_measurements"

Expand Down
3 changes: 2 additions & 1 deletion examples/truncate_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import hashlib
import sys

from imapcrutils import parse_ima_log_string, truncate_ima_log_by_pcr
from imapcrutils.log import parse_ima_log_string
from imapcrutils.pcr import truncate_ima_log_by_pcr

DEFAULT_IMA_LOG_PATH = "/sys/kernel/security/ima/ascii_runtime_measurements"

Expand Down
47 changes: 35 additions & 12 deletions imapcrutils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
"""
IMA-PCR-Utils - Python Library

This package provides functions for parsing IMA log entries and calculating PCR10 values.
Parsing IMA log entries, replaying PCR10 / boot_aggregate, and appraising
IMA log entries against a YAML policy.

Modules:

- :mod:`imapcrutils.log` — IMA log data model and parser.
- :mod:`imapcrutils.template` — ima-ng template serialization and template_hash recomputation.
- :mod:`imapcrutils.pcr` — PCR10 replay and boot_aggregate.
- :mod:`imapcrutils.appraisal` — IMA log appraisal (policy model, YAML loader, evaluator).
"""

__version__ = "0.1.0"

from imapcrutils.libs import (
IMALogEntry,
build_template_fields,
calculate_boot_aggregate,
calculate_expected_template_hash,
calculate_pcr10,
parse_ima_log_string,
truncate_ima_log_by_pcr,
validate_ima_log_entry,
)
from imapcrutils.verify import (
from imapcrutils.appraisal import (
AppraisalPolicy,
AppraisalResult,
PolicyComponent,
Expand All @@ -26,3 +24,28 @@
load_policy_file,
verify_ima_log,
)
from imapcrutils.log import IMALogEntry, parse_ima_log_string
from imapcrutils.pcr import calculate_boot_aggregate, calculate_pcr10, truncate_ima_log_by_pcr
from imapcrutils.template import build_template_fields, calculate_expected_template_hash, validate_ima_log_entry

__all__ = [
# log
"IMALogEntry",
"parse_ima_log_string",
# template
"build_template_fields",
"calculate_expected_template_hash",
"validate_ima_log_entry",
# pcr
"calculate_pcr10",
"truncate_ima_log_by_pcr",
"calculate_boot_aggregate",
# appraisal
"AppraisalResult",
"PolicyComponent",
"AppraisalPolicy",
"load_policy",
"load_policy_file",
"appraise_ima_log",
"verify_ima_log",
]
24 changes: 24 additions & 0 deletions imapcrutils/appraisal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT
"""
IMA log appraisal against a YAML policy.

The package is split into three modules:

- :mod:`imapcrutils.appraisal.policy` — policy data model (no I/O).
- :mod:`imapcrutils.appraisal.loader` — YAML → :class:`AppraisalPolicy`.
- :mod:`imapcrutils.appraisal.appraise` — apply a policy to IMA log entries.
"""

from imapcrutils.appraisal.appraise import appraise_ima_log, verify_ima_log
from imapcrutils.appraisal.loader import load_policy, load_policy_file
from imapcrutils.appraisal.policy import AppraisalPolicy, AppraisalResult, PolicyComponent

__all__ = [
"AppraisalResult",
"PolicyComponent",
"AppraisalPolicy",
"load_policy",
"load_policy_file",
"appraise_ima_log",
"verify_ima_log",
]
41 changes: 41 additions & 0 deletions imapcrutils/appraisal/appraise.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: MIT
"""
Apply an appraisal policy to IMA log entries.

For every IMA log entry, the first matching component (in declaration order)
decides the verdict: Allow, Deny, or Neutral. :func:`appraise_ima_log`
returns the per-entry verdicts; :func:`verify_ima_log` collapses them to a
single pass/fail boolean.
"""

__all__ = [
"appraise_ima_log",
"verify_ima_log",
]

from imapcrutils.appraisal.policy import AppraisalPolicy, AppraisalResult
from imapcrutils.log import IMALogEntry


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)
78 changes: 78 additions & 0 deletions imapcrutils/appraisal/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# SPDX-License-Identifier: MIT
"""
YAML appraisal policy loader.

Parses a YAML document into an :class:`AppraisalPolicy`. This is the only
module that depends on ``pyyaml``; the policy model itself
(:mod:`imapcrutils.appraisal.policy`) is format-agnostic.

Policy format (YAML)::

component1:
path: <glob>
allow: [<hex-hash>...] # optional
component2:
path: <glob>
deny: [<hex-hash>...] # optional
...
"""

__all__ = [
"load_policy",
"load_policy_file",
]

from pathlib import Path

import yaml

from imapcrutils.appraisal.policy import AppraisalPolicy, PolicyComponent


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())
71 changes: 71 additions & 0 deletions imapcrutils/appraisal/policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# SPDX-License-Identifier: MIT
"""
Appraisal policy data model.

Defines :class:`AppraisalResult`, :class:`PolicyComponent`, and
:class:`AppraisalPolicy` — the in-memory representation of an IMA log
appraisal policy. No file-format dependencies live here; YAML parsing
is provided separately by :mod:`imapcrutils.appraisal.loader`.
"""

__all__ = [
"AppraisalResult",
"PolicyComponent",
"AppraisalPolicy",
]

import fnmatch
from dataclasses import dataclass, field
from enum import Enum

from imapcrutils.log 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
Loading