Skip to content

Latest commit

 

History

History
177 lines (119 loc) · 9.38 KB

File metadata and controls

177 lines (119 loc) · 9.38 KB

Phase H.0 — Config-File Hashing and CLI Profile Alignment

Status: Complete
Tests: 749 total (710 from v47, 39 new), 0 failed
CI Guards: 9 total (7 from v47, 2 new)
Modules modified: wpgovern/core/baseline.py, wpgovern/status/checker.py, wpgovern/utils/invariants.py, wpgovern/config.py
Test files authored: tests/test_h0_config_file_hashing.py
CI guard additions: test_baseline_record_has_optional_config_field, test_no_wp_content_hashing_in_baseline_service (appended to tests/test_ci_guards.py)


Purpose

Phase H.0 is the only Python-side phase in the operational arc. It closes the gap between WPGovern v47's scope and the strategic plan v1.1's commitments around configuration-file integrity governance, and fixes a stack-alignment defect in BaselineService._docker_wp.

The bash arc (H.1 onward) builds on v48 as its Python foundation. H.0 ships before the bash arc because H.5's "byte-one ceremony" calls wpgovern baseline-create against the four config files that the bash arc generates. If baseline-create cannot capture file hashes at H.5 time, the byte-one ceremony does not deliver the file-hash governance the strategic plan commits to.


What this phase delivers

H.0-A — Config-file hashing

wpgovern/core/baseline.py changes

New constant:

CONFIG_FILE_PATHS = (
    "docker-compose.yml",
    "Caddyfile",
    "my.cnf",
    "wp-config.php",
)

Paths are relative to config.install_dir (default: /opt/wpgovern-install/). These are the four config files generated by the bash arc (H.2 generates three; H.4 generates wp-config.php) that WPGovern governs via hash baselining.

BaselineRecord schema extension:

config_file_hashes: dict[str, str] | None = None   # H.0-A: optional, None for legacy

Optional with default None. Legacy baselines (pre-H.0) load without error because the field is absent from their JSON — it resolves to None at parse time. Only baselines created by H.0-extended create_draft() have the field populated.

create_draft() extension:

Hashes are computed BEFORE AtomicTransaction is entered. If any of the four config files is missing, BaselineError is raised immediately with an operator-actionable message naming the missing path and the recovery step ("ensure the installer has completed phase H.4"). The hash manifest is included in the signed JSON payload.

Hash format: sha256:<64 lowercase hex chars> — matching the image digest pinning pattern elsewhere in the system.

_parse_baseline() extension:

Validates the config_file_hashes field at load time (I-CFG-2 contract):

  • Every key is a relative path (no leading /, no ..)
  • Every value matches ^sha256:[0-9a-f]{64}$
  • Non-conforming records raise BaselineError

_baseline_payload() extension:

Includes config_file_hashes in the dict if non-None (idiomatic with existing submitted_at / approved_at / activated_at pattern).

New module-level helpers:

Helper Purpose
_sha256_hex(data: bytes) -> str Returns sha256: + hex SHA-256 digest
_validate_relative_path(rel_path, context) Raises BaselineError on absolute/traversal paths
_validate_config_file_hashes(raw, baseline_id) Full dict validation: types, key safety, value format
_compute_config_file_hashes(install_dir) -> dict Hashes all four CONFIG_FILE_PATHS; fail-closed on missing

wpgovern/status/checker.py changes

Two new exit codes:

Code Meaning
52 Config file hash mismatch — a baselined file was modified on disk
53 Config file missing at check time — a baselined file is absent or unreadable

Step 8.6 added to check() after the existing invariant check (step 8.5). Runs only when an active baseline has a non-None config_file_hashes field. Iterates over hash entries in sorted key order — deterministic result when multiple files diverge.

_evaluate_config_file_hashes(active_baseline) method returns (exit_code, reason) or None (clean). Tested directly; the reason string names the specific file, expected digest, and (for exit 52) actual digest.

wpgovern/utils/invariants.py additions

Invariant ID Description
I-CFG-1 Runtime Active baseline's config_file_hashes matches live filesystem state
I-CFG-2 Structural Each hash key is a relative path; each value matches sha256:<64 hex>

Total invariant count: 29 (was 27).

wpgovern/config.py changes

install_dir docstring updated. The field is now functional — BaselineService.create_draft() and governance-check both read from it. The broader R4 fix (making build_paths honour explicit overrides for all path fields) is deferred to post-H.0 scope.

H.0-B — CLI profile alignment

BaselineService._docker_wp changed:

# Before (v47):
cmd = ["docker", "compose", "exec", "-T", "php", "wp", *wp_args]

# After (H.0-B):
cmd = ["docker", "compose", "run", "--rm", "-T", "cli", "wp", *wp_args]

The wordpress:cli image (profile-gated cli service) contains the wp-cli binary. The wordpress:fpm image used by the php service does not. v47 would fail at baseline-create time against the v1.1 stack.


Test coverage

New test file: tests/test_h0_config_file_hashing.py (37 tests)

Class Tests Coverage
TestCreateDraftHashesConfigFiles 3 Named: all four files → hashes captured. Adjacent: one missing → BaselineError. Adversarial: nonexistent install_dir → clear error.
TestCreateDraftHashChanges 3 Named: file modified → hash differs. Adjacent: unchanged → identical hash. Adversarial: directory instead of file → BaselineError.
TestCreateDraftInstallDir 3 Named: custom install_dir honored. Adjacent: hashes in signed payload. Adversarial: all values are sha256-prefixed.
TestGovernanceCheckHashes 3 Named: hashes match → None. Adjacent: legacy baseline (no hashes) → None. Adversarial: file is directory → (53).
TestGovernanceCheckExit52 3 Named: one file modified → (52, names file). Adjacent: two modified → (52, first sorted). Adversarial: revert → None.
TestGovernanceCheckExit53 3 Named: file deleted → (53). Adjacent: multiple missing → (53, first sorted). Adversarial: replaced by directory → (53).
TestICFG1 3 Named: hashes match → no violations. Adjacent: no active baseline → no violations. Adversarial: mismatch → I-CFG-1 fires.
TestICFG2 3 Named: canonical paths accepted. Adjacent: absolute path rejected. Adversarial: traversal rejected.
TestBaselineRecordSchema 4 Named: round-trip with hashes. Adjacent: round-trip without hashes (None). Named: legacy v47 baseline loads (field=None). Adversarial: corrupted hash value refused.
TestDockerWpCliProfile 2 Named: _docker_wp produces run --rm -T cli wp. Adjacent: create_draft's wp calls all use cli profile.
Utility functions 7 _sha256_hex, _validate_config_file_hashes, CONFIG_FILE_PATHS, BaselineRecord default field.

New CI guards (appended to tests/test_ci_guards.py)

Guard What it enforces
test_baseline_record_has_optional_config_field config_file_hashes defaults to None; prevents future regression to required field
test_no_wp_content_hashing_in_baseline_service baseline.py contains no wp-content/, plugins/, or themes/ filesystem path references; prevents scope creep into the delegated zone

What is explicitly NOT in H.0 scope

Per the brief (Section 3):

  • wp-content/plugins and wp-content/themes hashing — delegated to Wordfence, Sucuri, MalCare per v1.1 decision
  • Reconciliation workflow for legitimate config changes — the existing four-stage approval workflow is the reconciliation workflow
  • Per-file approval granularity — approval is per-baseline
  • wpgovern.env hashing — wpgovern.env is the secret zone per v1.1; never baselined
  • R4 broader fixbuild_paths does not yet honour explicit install_dir overrides; only the specific install_dir field is activated
  • Exit code 54 — "extra file present but not in baseline" — deferred; fail-closed-at-create-time means the manifest always has all four entries
  • Bash-side code — H.0 is pure Python

Test count

Suite Before H.0 After H.0
Fast (no Hypothesis) 694 733
Full (with test extras) 710 749
CI guards 7 9

39 net new tests: 37 in test_h0_config_file_hashing.py + 2 CI guards in test_ci_guards.py.


Known limits (H.0 additions to v47's known-limits table)

ID Description Disposition
R9 governance-check exit 52/53 fires on the first divergent file in sorted order, not all of them. A single run with multiple divergent files returns one exit code. Acceptable: the operator re-runs after fixing each file; this is the same model as all other governance-check findings.
R10 config-file hashing follows symlinks (reads the symlink target). A symlink replacement that points to a different file will show as a hash change (exit 52), not a symlink-type change. Acceptable for H.0; symlink governance is a future scope item.

v48 is the canonical foundation for the bash arc. H.1 (bash installer skeleton) builds on this codebase.