fix(assessment): harden capability evidence trust
This commit is contained in:
@@ -360,7 +360,11 @@ windows. Only `active` and `next` entries are trusted; unknown statuses,
|
|||||||
duplicate key IDs and malformed structured keyrings fail closed, while revoked,
|
duplicate key IDs and malformed structured keyrings fail closed, while revoked,
|
||||||
disabled and retired entries are ignored. Do not establish this trust file by
|
disabled and retired entries are ignored. Do not establish this trust file by
|
||||||
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
|
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
|
||||||
Use `--json` or `--output PATH` for automation.
|
Use `--json` or `--output PATH` for automation. Evidence and report files are
|
||||||
|
written through a bounded same-directory atomic replacement, with mode `0600`
|
||||||
|
and file and directory `fsync`. All parent directories must already exist, no
|
||||||
|
parent component may be a symlink, and an existing symlink or non-regular output
|
||||||
|
target is rejected.
|
||||||
|
|
||||||
Exit status `0` means the assessed release metadata is current, `2` means
|
Exit status `0` means the assessed release metadata is current, `2` means
|
||||||
explicit review is required, and `1` means the schema or trust checks are
|
explicit review is required, and `1` means the schema or trust checks are
|
||||||
@@ -385,7 +389,7 @@ that observation to the assessment and signed catalog:
|
|||||||
--output /var/tmp/govoplan-fit-review.json
|
--output /var/tmp/govoplan-fit-review.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The collector follows the strict
|
The collector follows the strict version `0.2.0`
|
||||||
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
||||||
contract. It enumerates all installed distributions whose normalized name starts
|
contract. It enumerates all installed distributions whose normalized name starts
|
||||||
with `govoplan-`, compares the enabled assessed package and module-manifest
|
with `govoplan-`, compares the enabled assessed package and module-manifest
|
||||||
@@ -395,11 +399,18 @@ entries and evidence-backed conclusions.
|
|||||||
|
|
||||||
For each distribution, the collector also:
|
For each distribution, the collector also:
|
||||||
|
|
||||||
- verifies every supported SHA-256 entry in installed wheel `RECORD` metadata,
|
- verifies every supported SHA-256 immutable wheel-payload entry in installed
|
||||||
|
`RECORD` metadata,
|
||||||
within fixed limits of 256 GovOPlaN distributions, 10,000 files and 512 MiB
|
within fixed limits of 256 GovOPlaN distributions, 10,000 files and 512 MiB
|
||||||
hashed per distribution, 50,000 files and 2 GiB hashed for one collection,
|
hashed per distribution, 50,000 files and 2 GiB hashed for one collection,
|
||||||
and 64 MiB for any single file;
|
and 64 MiB for any single file;
|
||||||
- distinguishes immutable VCS commits and archive hashes from editable installs,
|
- accepts package-owned data-file targets below the installation prefix and
|
||||||
|
declared `console_scripts`, while rejecting other traversal, undeclared
|
||||||
|
scripts, paths outside the prefix and leaf symlinks before opening a file;
|
||||||
|
- reports pip-generated, unhashed `__pycache__/*.pyc` entries separately only
|
||||||
|
when their derived source is a hashed payload entry, including generated
|
||||||
|
files below a confined package-owned runtime-data prefix;
|
||||||
|
- distinguishes coherent PEP 610 Git commits and archive hashes from editable installs,
|
||||||
local directories, package-index/unknown origins and malformed metadata;
|
local directories, package-index/unknown origins and malformed metadata;
|
||||||
- compares a non-editable VCS commit with the assessment commit and, when one is
|
- compares a non-editable VCS commit with the assessment commit and, when one is
|
||||||
present, the signed catalog `selected_units` commit;
|
present, the signed catalog `selected_units` commit;
|
||||||
@@ -407,16 +418,29 @@ For each distribution, the collector also:
|
|||||||
and stable error codes. It never writes direct URLs, paths, hostnames,
|
and stable error codes. It never writes direct URLs, paths, hostnames,
|
||||||
usernames, exception text or file contents.
|
usernames, exception text or file contents.
|
||||||
|
|
||||||
`RECORD` agreement proves that the files agree with their installed metadata; it
|
`RECORD` `verified` means complete matching coverage of the immutable wheel
|
||||||
does not make self-consistent metadata a trusted release origin. Similarly, a
|
payload, not every runtime byte. The proof scope exposes the aggregate count of
|
||||||
version string is not artifact integrity. Editable and directory-backed installs
|
generated unhashed bytecode; runtime activation remains unchecked. `RECORD`
|
||||||
|
agreement proves that payload files agree with installed metadata, but does not
|
||||||
|
make self-consistent metadata a trusted release origin. Similarly, a version
|
||||||
|
string is not artifact integrity. PEP 610 VCS evidence is accepted only for
|
||||||
|
`vcs: git`, a full 40–64 hexadecimal commit, one mutually exclusive provenance
|
||||||
|
form and a coherent bounded URL shape. Editable and directory-backed installs
|
||||||
remain mutable even when their small editable-install `RECORD` is valid. Archive
|
remain mutable even when their small editable-install `RECORD` is valid. Archive
|
||||||
or index artifacts without a hash anchored by the assessed release remain
|
or index artifacts without a hash anchored by the assessed release remain
|
||||||
unanchored. These conditions cause review rather than being promoted to
|
unanchored. These conditions cause review rather than being promoted to
|
||||||
immutable installed-release proof.
|
immutable installed-release proof.
|
||||||
|
|
||||||
Use `--installed-evidence PATH` to review evidence collected in a separate
|
Only evidence produced in-process by `--collect-installed-evidence` is a local
|
||||||
environment. Evidence input and output are bounded to 4 MiB. Collection loads
|
observation, and it must be no more than five minutes old (with at most 30
|
||||||
|
seconds of future clock skew) at the selected verification time. Use
|
||||||
|
`--installed-evidence PATH` to compare evidence collected in a separate
|
||||||
|
environment. Imported JSON is unsigned and therefore always carries a blocker:
|
||||||
|
it can identify differences but cannot establish checked or valid installed
|
||||||
|
proof. A future signed collector-attestation format is required to cross that
|
||||||
|
boundary. The proof scope records observation mode, collection/evaluation time,
|
||||||
|
freshness, and whether evaluation used current time or an explicit historical
|
||||||
|
override. Evidence input and output are bounded to 4 MiB. Collection loads
|
||||||
the `govoplan.modules` entry-point factories in order to read module IDs and
|
the `govoplan.modules` entry-point factories in order to read module IDs and
|
||||||
manifest versions; that executes installed GovOPlaN manifest code. Run it only
|
manifest versions; that executes installed GovOPlaN manifest code. Run it only
|
||||||
inside the installation being assessed and with the same isolation expected for
|
inside the installation being assessed and with the same isolation expected for
|
||||||
@@ -453,16 +477,35 @@ the tool's deterministic canonicalization.
|
|||||||
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
||||||
--installed-evidence /srv/govoplan/evidence/installed.json \
|
--installed-evidence /srv/govoplan/evidence/installed.json \
|
||||||
--boundary-evidence /srv/govoplan/evidence/target-proof.json \
|
--boundary-evidence /srv/govoplan/evidence/target-proof.json \
|
||||||
--boundary-authority-keyring /srv/govoplan/trust/proof-authorities.json
|
--boundary-authority-keyring /srv/govoplan/trust/proof-authorities.json \
|
||||||
|
--expected-external-provider-subject provider-production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With `--installed-evidence`, this command performs comparison and proof-binding
|
||||||
|
diagnostics only: the imported installed document is unsigned, so neither it nor
|
||||||
|
the boundary claim becomes accepted proof. An accepted boundary review requires
|
||||||
|
a trusted orchestrator to retain the direct in-process observation, obtain the
|
||||||
|
external signature over its digest, and call the review library within the
|
||||||
|
five-minute observation window. A future signed collector-attestation format
|
||||||
|
should make that exchange durable across processes.
|
||||||
|
|
||||||
|
Target-environment and production-approval claims must use the assessment's
|
||||||
|
`deployment_profile.id` as `subject_id`. External-provider claims require the
|
||||||
|
operator to supply a bounded opaque expected subject with
|
||||||
|
`--expected-external-provider-subject`; without it, such a claim remains
|
||||||
|
unchecked and blocks. Expected and observed IDs are retained in proof scope.
|
||||||
|
|
||||||
The authority keyring is a governance trust root. Do not download or generate it
|
The authority keyring is a governance trust root. Do not download or generate it
|
||||||
from the proof bundle being checked, and do not reuse catalog signing authority
|
from the proof bundle being checked. The checker rejects proof-authority public
|
||||||
as implicit production authority. A target operator or approver must validate
|
keys reused by either the published or independently trusted catalog keyring,
|
||||||
|
and rejects the same proof public-key material assigned to multiple authority
|
||||||
|
IDs. Authority `not_after` is exclusive. A target operator or approver must validate
|
||||||
the referenced drill/result artifacts before signing. The rerun verifies the
|
the referenced drill/result artifacts before signing. The rerun verifies the
|
||||||
attestation and its bindings; it does not fetch or reinterpret those artifacts.
|
attestation and its bindings; it does not fetch or reinterpret those artifacts.
|
||||||
`--verification-time` exists for reproducible historical review. Live admission
|
`--verification-time` exists only for reproducible historical review. Supplying
|
||||||
must use the actual current time.
|
it always marks the machine and human report as a **HISTORICAL override**; callers
|
||||||
|
cannot relabel it as current. Live admission must omit it and use the actual
|
||||||
|
current time.
|
||||||
|
|
||||||
No boundary bundle or production authority has been supplied for this current
|
No boundary bundle or production authority has been supplied for this current
|
||||||
assessment. Target environment, provider and production proof therefore remain
|
assessment. Target environment, provider and production proof therefore remain
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"format": "uri-reference"
|
"format": "uri-reference"
|
||||||
},
|
},
|
||||||
"schema_version": {
|
"schema_version": {
|
||||||
"const": "0.1.0"
|
"const": "0.2.0"
|
||||||
},
|
},
|
||||||
"evidence_kind": {
|
"evidence_kind": {
|
||||||
"const": "govoplan.installed-composition"
|
"const": "govoplan.installed-composition"
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"required": ["kind", "commit"],
|
"required": ["kind", "commit"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"enum": ["vcs-commit", "editable-vcs"]
|
"const": "vcs-commit"
|
||||||
},
|
},
|
||||||
"commit": {
|
"commit": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
"status",
|
"status",
|
||||||
"hashed_file_count",
|
"hashed_file_count",
|
||||||
"permitted_unhashed_file_count",
|
"permitted_unhashed_file_count",
|
||||||
|
"generated_unhashed_file_count",
|
||||||
"unverifiable_file_count",
|
"unverifiable_file_count",
|
||||||
"missing_file_count",
|
"missing_file_count",
|
||||||
"mismatched_file_count"
|
"mismatched_file_count"
|
||||||
@@ -190,6 +191,9 @@
|
|||||||
"permitted_unhashed_file_count": {
|
"permitted_unhashed_file_count": {
|
||||||
"$ref": "#/$defs/count"
|
"$ref": "#/$defs/count"
|
||||||
},
|
},
|
||||||
|
"generated_unhashed_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
"unverifiable_file_count": {
|
"unverifiable_file_count": {
|
||||||
"$ref": "#/$defs/count"
|
"$ref": "#/$defs/count"
|
||||||
},
|
},
|
||||||
@@ -199,7 +203,51 @@
|
|||||||
"mismatched_file_count": {
|
"mismatched_file_count": {
|
||||||
"$ref": "#/$defs/count"
|
"$ref": "#/$defs/count"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "verified" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "minimum": 1 },
|
||||||
|
"permitted_unhashed_file_count": { "minimum": 1 },
|
||||||
|
"unverifiable_file_count": { "const": 0 },
|
||||||
|
"missing_file_count": { "const": 0 },
|
||||||
|
"mismatched_file_count": { "const": 0 }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "partial" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "minimum": 1 },
|
||||||
|
"unverifiable_file_count": { "minimum": 1 },
|
||||||
|
"missing_file_count": { "const": 0 },
|
||||||
|
"mismatched_file_count": { "const": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "mismatch" } } },
|
||||||
|
"then": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "properties": { "missing_file_count": { "minimum": 1 } } },
|
||||||
|
{ "properties": { "mismatched_file_count": { "minimum": 1 } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "unavailable" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "const": 0 },
|
||||||
|
"missing_file_count": { "const": 0 },
|
||||||
|
"mismatched_file_count": { "const": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"collection_issue": {
|
"collection_issue": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
190
tests/test_capability_fit_atomic_io.py
Normal file
190
tests/test_capability_fit_atomic_io.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
||||||
|
if str(tools_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
|
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
atomic_write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityFitAtomicIOTests(unittest.TestCase):
|
||||||
|
def test_writes_private_json_with_expected_content(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
target = Path(temporary_directory) / "evidence.json"
|
||||||
|
payload = {"z": [1, 2], "message": "Grüße"}
|
||||||
|
created_in: list[Path] = []
|
||||||
|
original_mkstemp = tempfile.mkstemp
|
||||||
|
|
||||||
|
def observed_mkstemp(*args, **kwargs):
|
||||||
|
created_in.append(Path(kwargs["dir"]))
|
||||||
|
return original_mkstemp(*args, **kwargs)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.tempfile.mkstemp",
|
||||||
|
side_effect=observed_mkstemp,
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.fsync",
|
||||||
|
wraps=os.fsync,
|
||||||
|
) as fsync,
|
||||||
|
):
|
||||||
|
atomic_write_json(target, payload, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(payload, json.loads(target.read_text(encoding="utf-8")))
|
||||||
|
self.assertTrue(target.read_bytes().endswith(b"\n"))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
self.assertEqual([target.parent], created_in)
|
||||||
|
self.assertEqual(2, fsync.call_count)
|
||||||
|
|
||||||
|
def test_replaces_existing_regular_file_and_secures_mode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
target = Path(temporary_directory) / "report.json"
|
||||||
|
target.write_text('{"old": true}\n', encoding="utf-8")
|
||||||
|
target.chmod(0o644)
|
||||||
|
old_handle = target.open("rb")
|
||||||
|
self.addCleanup(old_handle.close)
|
||||||
|
|
||||||
|
atomic_write_json(target, {"new": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(b'{"old": true}\n', old_handle.read())
|
||||||
|
self.assertEqual({"new": True}, json.loads(target.read_text("utf-8")))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
|
||||||
|
def test_size_bound_leaves_existing_target_unchanged(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original = b'{"original": true}\n'
|
||||||
|
target.write_bytes(original)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "size limit"):
|
||||||
|
atomic_write_json(target, {"large": "x" * 100}, max_bytes=32)
|
||||||
|
|
||||||
|
self.assertEqual(original, target.read_bytes())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
|
||||||
|
def test_rejects_symlink_without_modifying_link_or_referent(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
referent = directory / "outside.json"
|
||||||
|
referent.write_bytes(b'{"keep": true}\n')
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
target.symlink_to(referent.name)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "symbolic link"):
|
||||||
|
atomic_write_json(target, {"replace": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertTrue(target.is_symlink())
|
||||||
|
self.assertEqual(b'{"keep": true}\n', referent.read_bytes())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
|
||||||
|
def test_rejects_symlinked_parent_component(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
real_parent = directory / "real-parent"
|
||||||
|
real_parent.mkdir()
|
||||||
|
linked_parent = directory / "linked-parent"
|
||||||
|
linked_parent.symlink_to(real_parent, target_is_directory=True)
|
||||||
|
target = linked_parent / "new" / "evidence.json"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "parent path"):
|
||||||
|
atomic_write_json(target, {"unsafe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse((real_parent / "new").exists())
|
||||||
|
|
||||||
|
def test_requires_existing_parent_without_creating_directories(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
missing_parent = directory / "missing" / "nested"
|
||||||
|
target = missing_parent / "evidence.json"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "already exist"):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse(missing_parent.exists())
|
||||||
|
|
||||||
|
def test_rejects_post_replace_mode_or_identity_change(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original_replace = os.replace
|
||||||
|
|
||||||
|
def insecure_replace(source, destination):
|
||||||
|
original_replace(source, destination)
|
||||||
|
Path(destination).chmod(0o644)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=insecure_replace,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
"permissions changed and were restored",
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
self.assertEqual({"safe": True}, json.loads(target.read_text("utf-8")))
|
||||||
|
|
||||||
|
def test_does_not_unlink_unknown_post_replace_inode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original_replace = os.replace
|
||||||
|
|
||||||
|
def replaced_again(source, destination):
|
||||||
|
original_replace(source, destination)
|
||||||
|
Path(destination).unlink()
|
||||||
|
Path(destination).write_bytes(b"unknown replacement\n")
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=replaced_again,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
"did not preserve",
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"secret": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(b"unknown replacement\n", target.read_bytes())
|
||||||
|
|
||||||
|
def test_replace_failure_removes_private_temporary_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=OSError("simulated replacement failure"),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError, "could not be written atomically"
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,6 +7,7 @@ import hashlib
|
|||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -24,8 +25,12 @@ for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
|||||||
if str(tools_root) not in sys.path:
|
if str(tools_root) not in sys.path:
|
||||||
sys.path.insert(0, str(tools_root))
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
from govoplan_assessment.capability_fit import review_capability_fit # noqa: E402
|
from govoplan_assessment.capability_fit import ( # noqa: E402
|
||||||
|
render_review,
|
||||||
|
review_capability_fit,
|
||||||
|
)
|
||||||
from govoplan_assessment.evidence import ( # noqa: E402
|
from govoplan_assessment.evidence import ( # noqa: E402
|
||||||
|
_bounded_file_sha256,
|
||||||
canonical_bytes,
|
canonical_bytes,
|
||||||
canonical_sha256,
|
canonical_sha256,
|
||||||
collect_installed_composition,
|
collect_installed_composition,
|
||||||
@@ -205,6 +210,8 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
selected_commits={},
|
selected_commits={},
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
schema=self.installed_schema,
|
schema=self.installed_schema,
|
||||||
|
observation_mode="direct_local",
|
||||||
|
verification_time=datetime(2026, 7, 23, 12, tzinfo=UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
codes = {item.code for item in result.findings}
|
codes = {item.code for item in result.findings}
|
||||||
@@ -213,6 +220,86 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
self.assertTrue(result.proof_scope["installed_artifacts"]["checked"])
|
self.assertTrue(result.proof_scope["installed_artifacts"]["checked"])
|
||||||
self.assertFalse(result.proof_scope["installed_artifacts"]["valid"])
|
self.assertFalse(result.proof_scope["installed_artifacts"]["valid"])
|
||||||
|
|
||||||
|
def test_imported_unsigned_installed_evidence_is_comparison_only(self) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installed_evidence_mode="imported_unsigned",
|
||||||
|
)
|
||||||
|
|
||||||
|
installed = report["proof_scope"]["installed_artifacts"]
|
||||||
|
self.assertEqual("blocked", report["status"])
|
||||||
|
self.assertFalse(installed["checked"])
|
||||||
|
self.assertIsNone(installed["valid"])
|
||||||
|
self.assertTrue(installed["comparison_valid"])
|
||||||
|
self.assertEqual(
|
||||||
|
"unsigned_import",
|
||||||
|
report["proof_scope"]["installed_evidence_observation"]["freshness"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"installed_evidence_unsigned_import",
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_direct_installed_evidence_must_be_fresh_and_not_from_future(self) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
cases = (
|
||||||
|
("2026-07-23T11:54:59Z", "installed_evidence_stale", "stale"),
|
||||||
|
("2026-07-23T12:00:31Z", "installed_evidence_from_future", "future"),
|
||||||
|
)
|
||||||
|
for collected_at, expected_code, expected_freshness in cases:
|
||||||
|
with self.subTest(collected_at=collected_at):
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
evidence["collected_at"] = collected_at
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", report["status"])
|
||||||
|
self.assertFalse(
|
||||||
|
report["proof_scope"]["installed_artifacts"]["checked"]
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
expected_code,
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
expected_freshness,
|
||||||
|
report["proof_scope"]["installed_evidence_observation"][
|
||||||
|
"freshness"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forged_verified_record_counters_fail_semantic_validation(self) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
evidence["artifacts"][0]["record_integrity"].update(
|
||||||
|
hashed_file_count=0,
|
||||||
|
mismatched_file_count=1,
|
||||||
|
)
|
||||||
|
relaxed_schema = deepcopy(self.installed_schema)
|
||||||
|
relaxed_schema["$defs"]["record_integrity"].pop("allOf")
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installed_schema=relaxed_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("review_required", report["status"])
|
||||||
|
self.assertIn(
|
||||||
|
"installed_record_integrity_invalid",
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertFalse(report["proof_scope"]["installed_record_integrity"]["valid"])
|
||||||
|
|
||||||
def test_collector_verifies_record_and_redacts_direct_url(self) -> None:
|
def test_collector_verifies_record_and_redacts_direct_url(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
root = Path(temp_dir)
|
root = Path(temp_dir)
|
||||||
@@ -251,6 +338,225 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
tampered["artifacts"][0]["record_integrity"]["mismatched_file_count"],
|
tampered["artifacts"][0]["record_integrity"]["mismatched_file_count"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_collector_accepts_real_core_wheel_payload_scripts_data_and_pyc(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
wheels = root / "wheels"
|
||||||
|
prefix = root / "prefix"
|
||||||
|
wheels.mkdir()
|
||||||
|
build_python = (
|
||||||
|
Path(sys.base_prefix)
|
||||||
|
/ "bin"
|
||||||
|
/ (f"python{sys.version_info.major}.{sys.version_info.minor}")
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
(
|
||||||
|
str(build_python),
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--no-build-isolation",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(wheels),
|
||||||
|
str(META_ROOT.parent / "govoplan-core"),
|
||||||
|
),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
wheel = next(wheels.glob("govoplan_core-*.whl"))
|
||||||
|
subprocess.run(
|
||||||
|
(
|
||||||
|
str(build_python),
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--no-deps",
|
||||||
|
"--ignore-installed",
|
||||||
|
"--prefix",
|
||||||
|
str(prefix),
|
||||||
|
str(wheel),
|
||||||
|
),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
site_packages = next(prefix.glob("lib/python*/site-packages"))
|
||||||
|
dist_info = next(site_packages.glob("govoplan_core-*.dist-info"))
|
||||||
|
distribution = metadata.PathDistribution(dist_info)
|
||||||
|
record_paths = {str(item) for item in distribution.files or ()}
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
path.startswith("../../../govoplan_core_runtime/")
|
||||||
|
for path in record_paths
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(path.startswith("../../../bin/govoplan-") for path in record_paths)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(sys, "path", [str(site_packages), *sys.path]),
|
||||||
|
mock.patch.object(sys, "prefix", str(prefix)),
|
||||||
|
):
|
||||||
|
evidence = collect_installed_composition(
|
||||||
|
assessment=self.assessment,
|
||||||
|
collected_at=datetime(2026, 7, 23, 11, 58, tzinfo=UTC),
|
||||||
|
distributions=[distribution],
|
||||||
|
)
|
||||||
|
|
||||||
|
record = evidence["artifacts"][0]["record_integrity"]
|
||||||
|
self.assertEqual("verified", record["status"])
|
||||||
|
self.assertGreater(record["hashed_file_count"], 0)
|
||||||
|
self.assertGreater(record["permitted_unhashed_file_count"], 0)
|
||||||
|
self.assertGreater(record["generated_unhashed_file_count"], 0)
|
||||||
|
self.assertEqual(0, record["unverifiable_file_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
(), validate_payload(payload=evidence, schema=self.installed_schema)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_record_traversal_and_leaf_symlinks_are_never_hashed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
prefix = Path(temp_dir) / "prefix"
|
||||||
|
site_packages = prefix / "lib" / "python-test" / "site-packages"
|
||||||
|
site_packages.mkdir(parents=True)
|
||||||
|
traversal = create_traversal_distribution(prefix, site_packages)
|
||||||
|
normal, payload = create_distribution(site_packages)
|
||||||
|
outside = Path(temp_dir) / "outside.py"
|
||||||
|
outside.write_text(payload.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
payload.unlink()
|
||||||
|
payload.symlink_to(outside)
|
||||||
|
with (
|
||||||
|
mock.patch.object(sys, "path", [str(site_packages), *sys.path]),
|
||||||
|
mock.patch.object(sys, "prefix", str(prefix)),
|
||||||
|
):
|
||||||
|
evidence = collect_installed_composition(
|
||||||
|
assessment=self.assessment,
|
||||||
|
collected_at=datetime(2026, 7, 23, 11, 58, tzinfo=UTC),
|
||||||
|
distributions=[traversal, normal],
|
||||||
|
)
|
||||||
|
|
||||||
|
records = {
|
||||||
|
item["package_name"]: item["record_integrity"]
|
||||||
|
for item in evidence["artifacts"]
|
||||||
|
}
|
||||||
|
self.assertEqual("partial", records["govoplan-traversal"]["status"])
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
records["govoplan-traversal"]["unverifiable_file_count"], 2
|
||||||
|
)
|
||||||
|
self.assertEqual("partial", records["govoplan-demo"]["status"])
|
||||||
|
self.assertGreater(records["govoplan-demo"]["unverifiable_file_count"], 0)
|
||||||
|
|
||||||
|
def test_bounded_hash_stops_at_opened_file_snapshot(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
path = Path(temp_dir) / "growing.bin"
|
||||||
|
path.write_bytes(b"0123456789")
|
||||||
|
actual_stat = path.stat()
|
||||||
|
initial_stat = SimpleNamespace(st_mode=actual_stat.st_mode, st_size=4)
|
||||||
|
final_stat = SimpleNamespace(st_mode=actual_stat.st_mode, st_size=10)
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.evidence.os.fstat",
|
||||||
|
side_effect=(initial_stat, final_stat),
|
||||||
|
):
|
||||||
|
_, hashed_bytes, exceeded = _bounded_file_sha256(path, max_bytes=8)
|
||||||
|
|
||||||
|
self.assertEqual(4, hashed_bytes)
|
||||||
|
self.assertTrue(exceeded)
|
||||||
|
|
||||||
|
def test_collector_rejects_unbounded_or_secret_shaped_metadata(self) -> None:
|
||||||
|
distributions = [
|
||||||
|
FakeDistribution(
|
||||||
|
name="govoplan-secret-user@private-host/path",
|
||||||
|
entry_points=[],
|
||||||
|
),
|
||||||
|
FakeDistribution(
|
||||||
|
version="1.0.0/private-user@host",
|
||||||
|
entry_points=[],
|
||||||
|
),
|
||||||
|
FakeDistribution(
|
||||||
|
entry_points=[
|
||||||
|
FakeEntryPoint(
|
||||||
|
name="safe-module",
|
||||||
|
manifest_id="secret-user@private-host/path",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
evidence = collect_installed_composition(
|
||||||
|
assessment=self.assessment,
|
||||||
|
collected_at=datetime(2026, 7, 23, 11, 58, tzinfo=UTC),
|
||||||
|
distributions=distributions,
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = json.dumps(evidence, sort_keys=True)
|
||||||
|
self.assertNotIn("secret-user", encoded)
|
||||||
|
self.assertNotIn("private-host", encoded)
|
||||||
|
self.assertNotIn("private-user", encoded)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item["code"]
|
||||||
|
in {
|
||||||
|
"distribution-metadata-invalid",
|
||||||
|
"module-entry-point-invalid",
|
||||||
|
}
|
||||||
|
for item in evidence["collection_issues"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pep610_git_provenance_requires_one_coherent_git_form(self) -> None:
|
||||||
|
valid_commit = "a" * 40
|
||||||
|
cases = (
|
||||||
|
(
|
||||||
|
"valid",
|
||||||
|
{
|
||||||
|
"url": "https://example.invalid/govoplan-many.git",
|
||||||
|
"vcs_info": {"vcs": "git", "commit_id": valid_commit},
|
||||||
|
},
|
||||||
|
"vcs-commit",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"svn",
|
||||||
|
{
|
||||||
|
"url": "https://example.invalid/govoplan-many.git",
|
||||||
|
"vcs_info": {"vcs": "svn", "commit_id": valid_commit},
|
||||||
|
},
|
||||||
|
"malformed-direct-url",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing-vcs",
|
||||||
|
{
|
||||||
|
"url": "https://example.invalid/govoplan-many.git",
|
||||||
|
"vcs_info": {"commit_id": valid_commit},
|
||||||
|
},
|
||||||
|
"malformed-direct-url",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ambiguous",
|
||||||
|
{
|
||||||
|
"url": "file:///govoplan-many",
|
||||||
|
"vcs_info": {"vcs": "git", "commit_id": valid_commit},
|
||||||
|
"dir_info": {"editable": True},
|
||||||
|
},
|
||||||
|
"malformed-direct-url",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for label, direct_url, expected_kind in cases:
|
||||||
|
with self.subTest(label=label):
|
||||||
|
evidence = collect_installed_composition(
|
||||||
|
assessment=self.assessment,
|
||||||
|
collected_at=datetime(2026, 7, 23, 11, 58, tzinfo=UTC),
|
||||||
|
distributions=[
|
||||||
|
FakeDistribution(entry_points=[], direct_url=direct_url)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
expected_kind,
|
||||||
|
evidence["artifacts"][0]["source_provenance"]["kind"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_entry_point_limit_is_explicit_instead_of_silent_truncation(self) -> None:
|
def test_entry_point_limit_is_explicit_instead_of_silent_truncation(self) -> None:
|
||||||
distribution = FakeDistribution(
|
distribution = FakeDistribution(
|
||||||
entry_points=[FakeEntryPoint(name=f"module-{index}") for index in range(17)]
|
entry_points=[FakeEntryPoint(name=f"module-{index}") for index in range(17)]
|
||||||
@@ -320,6 +626,248 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
self.assertTrue(report["proof_scope"]["target_environment"]["valid"])
|
self.assertTrue(report["proof_scope"]["target_environment"]["valid"])
|
||||||
self.assertFalse(report["proof_scope"]["external_providers"]["checked"])
|
self.assertFalse(report["proof_scope"]["external_providers"]["checked"])
|
||||||
self.assertFalse(report["proof_scope"]["production_approval"]["checked"])
|
self.assertFalse(report["proof_scope"]["production_approval"]["checked"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.assessment["deployment_profile"]["id"],
|
||||||
|
report["proof_scope"]["target_environment"]["expected_subject_id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.assessment["deployment_profile"]["id"],
|
||||||
|
report["proof_scope"]["target_environment"]["observed_subject_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_provider_claim_requires_explicit_matching_subject(self) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
installed = matching_installed_evidence(self.assessment)
|
||||||
|
proof, authority = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=installed,
|
||||||
|
claims=[boundary_claim("external_providers", "passed")],
|
||||||
|
allowed_scopes=["external_providers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
unconfigured = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=authority,
|
||||||
|
)
|
||||||
|
accepted = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=authority,
|
||||||
|
expected_external_provider_subject="provider:test",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", unconfigured["status"])
|
||||||
|
self.assertIn(
|
||||||
|
"boundary_provider_subject_unconfigured",
|
||||||
|
{item["code"] for item in unconfigured["findings"]},
|
||||||
|
)
|
||||||
|
self.assertEqual("current", accepted["status"])
|
||||||
|
provider_scope = accepted["proof_scope"]["external_providers"]
|
||||||
|
self.assertEqual("provider:test", provider_scope["expected_subject_id"])
|
||||||
|
self.assertEqual("provider:test", provider_scope["observed_subject_id"])
|
||||||
|
|
||||||
|
def test_boundary_subject_mismatch_blocks_without_echoing_invalid_config(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
installed = matching_installed_evidence(self.assessment)
|
||||||
|
proof, authority = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=installed,
|
||||||
|
claims=[
|
||||||
|
boundary_claim(
|
||||||
|
"target_environment",
|
||||||
|
"passed",
|
||||||
|
subject_id="another-deployment",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
allowed_scopes=["target_environment"],
|
||||||
|
)
|
||||||
|
mismatch = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=authority,
|
||||||
|
)
|
||||||
|
invalid_config = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
expected_external_provider_subject="https://private.invalid/secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"boundary_claim_subject_mismatch",
|
||||||
|
{item["code"] for item in mismatch["findings"]},
|
||||||
|
)
|
||||||
|
encoded = json.dumps(invalid_config, sort_keys=True)
|
||||||
|
self.assertIn("boundary_provider_subject_invalid", encoded)
|
||||||
|
self.assertNotIn("private.invalid", encoded)
|
||||||
|
self.assertNotIn("secret", encoded)
|
||||||
|
|
||||||
|
def test_authorized_negative_claim_creates_scope_specific_review_target(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
installed = matching_installed_evidence(self.assessment)
|
||||||
|
proof, authority = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=installed,
|
||||||
|
claims=[
|
||||||
|
boundary_claim("target_environment", "failed"),
|
||||||
|
boundary_claim("production_approval", "rejected"),
|
||||||
|
],
|
||||||
|
allowed_scopes=["target_environment", "production_approval"],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
targets = {item["id"] for item in report["review_targets"]}
|
||||||
|
self.assertIn(
|
||||||
|
"boundary.target_environment.production-like-dev",
|
||||||
|
targets,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"boundary.production_approval.production-like-dev",
|
||||||
|
targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proof_authorities_must_be_independent_and_key_ids_unambiguous(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
installed = matching_installed_evidence(self.assessment)
|
||||||
|
proof, authority = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=installed,
|
||||||
|
claims=[boundary_claim("target_environment", "passed")],
|
||||||
|
allowed_scopes=["target_environment"],
|
||||||
|
)
|
||||||
|
release_public_key = keyring["keys"][0]["public_key"]
|
||||||
|
reused = deepcopy(authority)
|
||||||
|
reused["keys"][0]["public_key"] = release_public_key
|
||||||
|
duplicate = deepcopy(authority)
|
||||||
|
duplicate["keys"].append(
|
||||||
|
{
|
||||||
|
**deepcopy(duplicate["keys"][0]),
|
||||||
|
"key_id": "authority:duplicate-material",
|
||||||
|
"allowed_scopes": ["production_approval"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
reused_report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=reused,
|
||||||
|
)
|
||||||
|
duplicate_report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=duplicate,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"boundary_authority_reuses_release_key",
|
||||||
|
{item["code"] for item in reused_report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"boundary_authority_public_key_duplicate",
|
||||||
|
{item["code"] for item in duplicate_report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
duplicate_report["proof_scope"]["target_environment"]["checked"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authority_not_after_is_exclusive(self) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
installed = matching_installed_evidence(self.assessment)
|
||||||
|
proof, authority = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=installed,
|
||||||
|
claims=[boundary_claim("target_environment", "passed")],
|
||||||
|
allowed_scopes=["target_environment"],
|
||||||
|
)
|
||||||
|
authority["keys"][0]["not_after"] = "2026-07-23T12:00:00Z"
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=installed,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"boundary_authority_untrusted",
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_historical_verification_and_generated_runtime_gap_are_visible(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
evidence["artifacts"][0]["record_integrity"][
|
||||||
|
"generated_unhashed_file_count"
|
||||||
|
] = 3
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
observation = report["proof_scope"]["installed_evidence_observation"]
|
||||||
|
self.assertEqual("historical_override", observation["verification_mode"])
|
||||||
|
self.assertEqual(
|
||||||
|
3,
|
||||||
|
report["proof_scope"]["installed_record_integrity"][
|
||||||
|
"generated_unhashed_file_count"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertIn("HISTORICAL override", render_review(report))
|
||||||
|
|
||||||
|
def test_invalid_internal_verification_mode_is_bounded_and_blocking(self) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
result = review_installed_composition(
|
||||||
|
assessment=self.assessment,
|
||||||
|
catalog_entries={},
|
||||||
|
selected_versions={},
|
||||||
|
selected_commits={},
|
||||||
|
evidence=evidence,
|
||||||
|
schema=self.installed_schema,
|
||||||
|
observation_mode="direct_local",
|
||||||
|
verification_time=datetime(2026, 7, 23, 12, tzinfo=UTC),
|
||||||
|
verification_mode="https://private.invalid/secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = json.dumps(result.proof_scope, sort_keys=True)
|
||||||
|
self.assertIn(
|
||||||
|
"installed_evidence_verification_mode",
|
||||||
|
{item.code for item in result.findings},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"invalid",
|
||||||
|
result.proof_scope["installed_evidence_observation"]["verification_mode"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result.proof_scope["installed_artifacts"]["checked"])
|
||||||
|
self.assertNotIn("private.invalid", encoded)
|
||||||
|
|
||||||
def test_unauthorized_production_claim_keeps_every_claim_unchecked(self) -> None:
|
def test_unauthorized_production_claim_keeps_every_claim_unchecked(self) -> None:
|
||||||
catalog, keyring = signed_catalog(self.assessment)
|
catalog, keyring = signed_catalog(self.assessment)
|
||||||
@@ -401,6 +949,10 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
installed_evidence: dict[str, object] | None = None,
|
installed_evidence: dict[str, object] | None = None,
|
||||||
boundary_evidence: dict[str, object] | None = None,
|
boundary_evidence: dict[str, object] | None = None,
|
||||||
authority_keyring: dict[str, object] | None = None,
|
authority_keyring: dict[str, object] | None = None,
|
||||||
|
installed_evidence_mode: str = "direct_local",
|
||||||
|
installed_schema: dict[str, object] | None = None,
|
||||||
|
expected_external_provider_subject: str | None = None,
|
||||||
|
verification_time: datetime | None = datetime(2026, 7, 23, 12, tzinfo=UTC),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return review_capability_fit(
|
return review_capability_fit(
|
||||||
assessment=deepcopy(self.assessment),
|
assessment=deepcopy(self.assessment),
|
||||||
@@ -409,7 +961,7 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
published_keyring=keyring,
|
published_keyring=keyring,
|
||||||
trusted_keyring=keyring,
|
trusted_keyring=keyring,
|
||||||
installed_evidence=installed_evidence,
|
installed_evidence=installed_evidence,
|
||||||
installed_evidence_schema=self.installed_schema
|
installed_evidence_schema=(installed_schema or self.installed_schema)
|
||||||
if installed_evidence is not None
|
if installed_evidence is not None
|
||||||
else None,
|
else None,
|
||||||
boundary_evidence=boundary_evidence,
|
boundary_evidence=boundary_evidence,
|
||||||
@@ -420,18 +972,20 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
boundary_authority_keyring_schema=self.authority_schema
|
boundary_authority_keyring_schema=self.authority_schema
|
||||||
if boundary_evidence is not None
|
if boundary_evidence is not None
|
||||||
else None,
|
else None,
|
||||||
verification_time=datetime(2026, 7, 23, 12, tzinfo=UTC),
|
verification_time=verification_time,
|
||||||
|
installed_evidence_mode=installed_evidence_mode,
|
||||||
|
expected_external_provider_subject=expected_external_provider_subject,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def matching_installed_evidence(assessment: dict[str, object]) -> dict[str, object]:
|
def matching_installed_evidence(assessment: dict[str, object]) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"$schema": "./installed-composition-evidence.schema.json",
|
"$schema": "./installed-composition-evidence.schema.json",
|
||||||
"schema_version": "0.1.0",
|
"schema_version": "0.2.0",
|
||||||
"evidence_kind": "govoplan.installed-composition",
|
"evidence_kind": "govoplan.installed-composition",
|
||||||
"assessment_id": assessment["assessment_id"],
|
"assessment_id": assessment["assessment_id"],
|
||||||
"assessment_release": assessment["release"]["ref"],
|
"assessment_release": assessment["release"]["ref"],
|
||||||
"collected_at": "2026-07-22T12:00:00Z",
|
"collected_at": "2026-07-23T11:58:00Z",
|
||||||
"scope": "current-python-environment.govoplan-distributions",
|
"scope": "current-python-environment.govoplan-distributions",
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
artifact(
|
artifact(
|
||||||
@@ -461,6 +1015,7 @@ def artifact(
|
|||||||
"status": "verified",
|
"status": "verified",
|
||||||
"hashed_file_count": 1,
|
"hashed_file_count": 1,
|
||||||
"permitted_unhashed_file_count": 1,
|
"permitted_unhashed_file_count": 1,
|
||||||
|
"generated_unhashed_file_count": 0,
|
||||||
"unverifiable_file_count": 0,
|
"unverifiable_file_count": 0,
|
||||||
"missing_file_count": 0,
|
"missing_file_count": 0,
|
||||||
"mismatched_file_count": 0,
|
"mismatched_file_count": 0,
|
||||||
@@ -468,11 +1023,19 @@ def artifact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def boundary_claim(scope: str, result: str) -> dict[str, object]:
|
def boundary_claim(
|
||||||
|
scope: str,
|
||||||
|
result: str,
|
||||||
|
*,
|
||||||
|
subject_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
default_subject = (
|
||||||
|
"provider:test" if scope == "external_providers" else "production-like-dev"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"result": result,
|
"result": result,
|
||||||
"subject_id": f"subject:{scope}",
|
"subject_id": subject_id or default_subject,
|
||||||
"control_ids": [f"control:{scope}"],
|
"control_ids": [f"control:{scope}"],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@@ -579,19 +1142,72 @@ def create_distribution(
|
|||||||
return metadata.PathDistribution(dist_info), payload_file
|
return metadata.PathDistribution(dist_info), payload_file
|
||||||
|
|
||||||
|
|
||||||
|
def create_traversal_distribution(
|
||||||
|
prefix: Path,
|
||||||
|
site_packages: Path,
|
||||||
|
) -> metadata.PathDistribution:
|
||||||
|
dist_info = site_packages / "govoplan_traversal-1.0.0.dist-info"
|
||||||
|
dist_info.mkdir()
|
||||||
|
metadata_file = dist_info / "METADATA"
|
||||||
|
metadata_file.write_text(
|
||||||
|
"Metadata-Version: 2.1\nName: govoplan-traversal\nVersion: 1.0.0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
unowned_file = prefix / "unowned_runtime" / "private.py"
|
||||||
|
unowned_file.parent.mkdir()
|
||||||
|
unowned_file.write_text("private = True\n", encoding="utf-8")
|
||||||
|
undeclared_script = prefix / "bin" / "govoplan-not-declared"
|
||||||
|
undeclared_script.parent.mkdir()
|
||||||
|
undeclared_script.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
metadata_path = metadata_file.relative_to(site_packages).as_posix()
|
||||||
|
unowned_path = "../../../unowned_runtime/private.py"
|
||||||
|
record_path = (dist_info / "RECORD").relative_to(site_packages).as_posix()
|
||||||
|
(dist_info / "RECORD").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
record_row(metadata_path, metadata_file.read_bytes()),
|
||||||
|
record_row(unowned_path, unowned_file.read_bytes()),
|
||||||
|
record_row(
|
||||||
|
"../../../bin/govoplan-not-declared",
|
||||||
|
undeclared_script.read_bytes(),
|
||||||
|
),
|
||||||
|
f"{record_path},,",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return metadata.PathDistribution(dist_info)
|
||||||
|
|
||||||
|
|
||||||
|
def record_row(path: str, content: bytes) -> str:
|
||||||
|
digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode("ascii")
|
||||||
|
return f"{path},sha256={digest.rstrip('=')},{len(content)}"
|
||||||
|
|
||||||
|
|
||||||
class FakeEntryPoint:
|
class FakeEntryPoint:
|
||||||
group = "govoplan.modules"
|
group = "govoplan.modules"
|
||||||
value = "fixture:get_manifest"
|
value = "fixture:get_manifest"
|
||||||
|
|
||||||
def __init__(self, *, name: str) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
manifest_id: str | None = None,
|
||||||
|
manifest_version: str = "1.0.0",
|
||||||
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.manifest_id = manifest_id or name
|
||||||
|
self.manifest_version = manifest_version
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
return lambda: SimpleNamespace(id=self.name, version="1.0.0")
|
return lambda: SimpleNamespace(
|
||||||
|
id=self.manifest_id,
|
||||||
|
version=self.manifest_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeDistribution:
|
class FakeDistribution:
|
||||||
version = "1.0.0"
|
|
||||||
files = ()
|
files = ()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -599,8 +1215,11 @@ class FakeDistribution:
|
|||||||
*,
|
*,
|
||||||
entry_points: list[FakeEntryPoint],
|
entry_points: list[FakeEntryPoint],
|
||||||
direct_url: dict[str, object] | None = None,
|
direct_url: dict[str, object] | None = None,
|
||||||
|
name: str = "govoplan-many",
|
||||||
|
version: str = "1.0.0",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.metadata = {"Name": "govoplan-many"}
|
self.metadata = {"Name": name}
|
||||||
|
self.version = version
|
||||||
self.entry_points = entry_points
|
self.entry_points = entry_points
|
||||||
self.direct_url = direct_url
|
self.direct_url = direct_url
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,10 @@ from govoplan_assessment import ( # noqa: E402
|
|||||||
render_review,
|
render_review,
|
||||||
review_capability_fit,
|
review_capability_fit,
|
||||||
)
|
)
|
||||||
|
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
atomic_write_json,
|
||||||
|
)
|
||||||
from govoplan_assessment.evidence import validate_payload # noqa: E402
|
from govoplan_assessment.evidence import validate_payload # noqa: E402
|
||||||
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
|
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
|
||||||
|
|
||||||
@@ -29,6 +34,8 @@ from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa
|
|||||||
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
|
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
|
||||||
MAX_ASSESSMENT_INPUT_BYTES = 16 * 1024 * 1024
|
MAX_ASSESSMENT_INPUT_BYTES = 16 * 1024 * 1024
|
||||||
MAX_EVIDENCE_INPUT_BYTES = 4 * 1024 * 1024
|
MAX_EVIDENCE_INPUT_BYTES = 4 * 1024 * 1024
|
||||||
|
MAX_REPORT_OUTPUT_BYTES = 16 * 1024 * 1024
|
||||||
|
OPAQUE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
@@ -130,6 +137,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
"--verification-time",
|
"--verification-time",
|
||||||
help="Optional ISO-8601 time for reproducible boundary-proof verification.",
|
help="Optional ISO-8601 time for reproducible boundary-proof verification.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--expected-external-provider-subject",
|
||||||
|
help=(
|
||||||
|
"Opaque provider-environment ID that external-provider proof must match; "
|
||||||
|
"never supply a URL, credential, or endpoint identifier."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--json", action="store_true", help="Print the machine-readable review report."
|
"--json", action="store_true", help="Print the machine-readable review report."
|
||||||
)
|
)
|
||||||
@@ -157,6 +171,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"--boundary-evidence requires --installed-evidence or --collect-installed-evidence"
|
"--boundary-evidence requires --installed-evidence or --collect-installed-evidence"
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
args.expected_external_provider_subject is not None
|
||||||
|
and OPAQUE_ID_PATTERN.fullmatch(args.expected_external_provider_subject) is None
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
"--expected-external-provider-subject must be a bounded opaque ID"
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
args.collect_installed_evidence is not None
|
args.collect_installed_evidence is not None
|
||||||
and args.output is not None
|
and args.output is not None
|
||||||
@@ -232,7 +253,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Collected installed evidence did not satisfy its bounded schema"
|
"Collected installed evidence did not satisfy its bounded schema"
|
||||||
)
|
)
|
||||||
write_object(args.collect_installed_evidence, installed_evidence)
|
write_object(
|
||||||
|
args.collect_installed_evidence,
|
||||||
|
installed_evidence,
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
label="installed evidence",
|
||||||
|
)
|
||||||
|
|
||||||
boundary_evidence = None
|
boundary_evidence = None
|
||||||
boundary_evidence_schema = None
|
boundary_evidence_schema = None
|
||||||
@@ -260,6 +286,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
verification_time = parse_datetime(args.verification_time)
|
||||||
report = review_capability_fit(
|
report = review_capability_fit(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -275,12 +302,22 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
boundary_evidence_schema=boundary_evidence_schema,
|
boundary_evidence_schema=boundary_evidence_schema,
|
||||||
boundary_authority_keyring=boundary_authority_keyring,
|
boundary_authority_keyring=boundary_authority_keyring,
|
||||||
boundary_authority_keyring_schema=boundary_authority_keyring_schema,
|
boundary_authority_keyring_schema=boundary_authority_keyring_schema,
|
||||||
verification_time=parse_datetime(args.verification_time),
|
verification_time=verification_time,
|
||||||
|
installed_evidence_mode=(
|
||||||
|
"direct_local"
|
||||||
|
if args.collect_installed_evidence is not None
|
||||||
|
else "imported_unsigned"
|
||||||
|
),
|
||||||
|
expected_external_provider_subject=args.expected_external_provider_subject,
|
||||||
)
|
)
|
||||||
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
|
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
|
||||||
if args.output is not None:
|
if args.output is not None:
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
write_object(
|
||||||
args.output.write_text(encoded, encoding="utf-8")
|
args.output,
|
||||||
|
report,
|
||||||
|
max_bytes=MAX_REPORT_OUTPUT_BYTES,
|
||||||
|
label="review report",
|
||||||
|
)
|
||||||
sys.stdout.write(encoded if args.json else render_review(report))
|
sys.stdout.write(encoded if args.json else render_review(report))
|
||||||
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
|
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
|
||||||
|
|
||||||
@@ -307,16 +344,19 @@ def read_object(path: Path | None, *, label: str, max_bytes: int) -> dict[str, o
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def write_object(path: Path | None, payload: dict[str, object]) -> None:
|
def write_object(
|
||||||
|
path: Path | None,
|
||||||
|
payload: dict[str, object],
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
if path is None:
|
if path is None:
|
||||||
raise SystemExit("Missing installed evidence output path")
|
raise SystemExit(f"Missing {label} output path")
|
||||||
encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
try:
|
||||||
if len(encoded.encode("utf-8")) > MAX_EVIDENCE_INPUT_BYTES:
|
atomic_write_json(path, payload, max_bytes=max_bytes)
|
||||||
raise SystemExit(
|
except AtomicJsonWriteError as exc:
|
||||||
"Collected installed evidence exceeds the bounded output limit"
|
raise SystemExit(f"Could not securely write {label}") from exc
|
||||||
)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(encoded, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_datetime(value: str | None):
|
def parse_datetime(value: str | None):
|
||||||
|
|||||||
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Bounded, private, atomic JSON output for assessment evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AtomicJsonWriteError(RuntimeError):
|
||||||
|
"""Raised when JSON output cannot be written with the required guarantees."""
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(
|
||||||
|
path: str | os.PathLike[str],
|
||||||
|
payload: Any,
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
) -> None:
|
||||||
|
"""Write bounded JSON through a private same-directory temporary file.
|
||||||
|
|
||||||
|
A successful return means the file contents and the containing directory
|
||||||
|
entry have both been flushed. If flushing the directory fails after the
|
||||||
|
atomic replacement, the replacement may be visible but its crash
|
||||||
|
durability is unknown, so the function reports failure. Every parent must
|
||||||
|
already exist as a real directory; symbolic-link components are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||||
|
raise ValueError("max_bytes must be a positive integer")
|
||||||
|
|
||||||
|
encoded = _encode_bounded_json(payload, max_bytes=max_bytes)
|
||||||
|
target = Path(os.path.abspath(os.fspath(path)))
|
||||||
|
parent = target.parent
|
||||||
|
descriptor = -1
|
||||||
|
verification_descriptor = -1
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
temporary_identity: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_assert_real_parent(parent)
|
||||||
|
_assert_replaceable_target(target)
|
||||||
|
try:
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
dir=parent,
|
||||||
|
prefix=".govoplan-json-",
|
||||||
|
suffix=".tmp",
|
||||||
|
)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON temporary file could not be secured."
|
||||||
|
)
|
||||||
|
temporary_identity = (metadata.st_dev, metadata.st_ino)
|
||||||
|
verification_descriptor = os.dup(descriptor)
|
||||||
|
|
||||||
|
handle = os.fdopen(descriptor, "wb")
|
||||||
|
descriptor = -1
|
||||||
|
with handle:
|
||||||
|
handle.write(encoded)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
# Recheck after creating and flushing the temporary file so an
|
||||||
|
# unsafe target discovered before replacement is never followed.
|
||||||
|
_assert_real_parent(parent)
|
||||||
|
_assert_replaceable_target(target)
|
||||||
|
os.replace(temporary_path, target)
|
||||||
|
temporary_path = None
|
||||||
|
_assert_installed_target(
|
||||||
|
target,
|
||||||
|
expected_identity=temporary_identity,
|
||||||
|
recovery_descriptor=verification_descriptor,
|
||||||
|
)
|
||||||
|
_fsync_directory(parent)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if verification_descriptor >= 0:
|
||||||
|
os.close(verification_descriptor)
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
try:
|
||||||
|
temporary_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except AtomicJsonWriteError:
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output could not be written atomically."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_bounded_json(payload: Any, *, max_bytes: int) -> bytes:
|
||||||
|
encoder = json.JSONEncoder(
|
||||||
|
allow_nan=False,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
encoded_size = 1 # The document always ends with one newline.
|
||||||
|
for chunk in encoder.iterencode(payload):
|
||||||
|
encoded_chunk = chunk.encode("utf-8")
|
||||||
|
encoded_size += len(encoded_chunk)
|
||||||
|
if encoded_size > max_bytes:
|
||||||
|
raise AtomicJsonWriteError("JSON output exceeds its size limit.")
|
||||||
|
chunks.append(encoded_chunk)
|
||||||
|
return b"".join(chunks) + b"\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_replaceable_target(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output target could not be inspected safely."
|
||||||
|
) from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output target must not be a symbolic link.")
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output target must be a regular file.")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_real_parent(path: Path) -> None:
|
||||||
|
current = Path(path.anchor)
|
||||||
|
for part in path.parts[1:]:
|
||||||
|
current /= part
|
||||||
|
try:
|
||||||
|
metadata = current.lstat()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent directory must already exist."
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent directory could not be inspected safely."
|
||||||
|
) from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent path must not contain symbolic links."
|
||||||
|
)
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent path must contain only directories."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_installed_target(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
expected_identity: tuple[int, int] | None,
|
||||||
|
recovery_descriptor: int,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement could not be verified."
|
||||||
|
) from exc
|
||||||
|
observed_identity = (metadata.st_dev, metadata.st_ino)
|
||||||
|
if (
|
||||||
|
expected_identity is None
|
||||||
|
or observed_identity != expected_identity
|
||||||
|
or not stat.S_ISREG(metadata.st_mode)
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement did not preserve the secured regular file."
|
||||||
|
)
|
||||||
|
if stat.S_IMODE(metadata.st_mode) == 0o600:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
recovery_metadata = os.fstat(recovery_descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(recovery_metadata.st_mode)
|
||||||
|
or (recovery_metadata.st_dev, recovery_metadata.st_ino) != expected_identity
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement recovery descriptor is invalid."
|
||||||
|
)
|
||||||
|
os.fchmod(recovery_descriptor, 0o600)
|
||||||
|
os.fsync(recovery_descriptor)
|
||||||
|
recovered_metadata = os.fstat(recovery_descriptor)
|
||||||
|
installed_metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions could not be restored."
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
stat.S_IMODE(recovered_metadata.st_mode) != 0o600
|
||||||
|
or (installed_metadata.st_dev, installed_metadata.st_ino) != expected_identity
|
||||||
|
or not stat.S_ISREG(installed_metadata.st_mode)
|
||||||
|
or stat.S_IMODE(installed_metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions could not be restored."
|
||||||
|
)
|
||||||
|
_fsync_directory(path.parent)
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions changed and were restored."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
flags = os.O_RDONLY
|
||||||
|
if hasattr(os, "O_DIRECTORY"):
|
||||||
|
flags |= os.O_DIRECTORY
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
try:
|
||||||
|
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output parent path must be a directory.")
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
@@ -28,6 +28,7 @@ from govoplan_release.version_alignment import candidate_catalog_version_issues
|
|||||||
from .evidence import (
|
from .evidence import (
|
||||||
BoundaryEvidenceReview,
|
BoundaryEvidenceReview,
|
||||||
InstalledEvidenceReview,
|
InstalledEvidenceReview,
|
||||||
|
public_key_material_from_keyrings,
|
||||||
review_boundary_evidence,
|
review_boundary_evidence,
|
||||||
review_installed_composition,
|
review_installed_composition,
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,8 @@ def review_capability_fit(
|
|||||||
boundary_authority_keyring: dict[str, Any] | None = None,
|
boundary_authority_keyring: dict[str, Any] | None = None,
|
||||||
boundary_authority_keyring_schema: dict[str, Any] | None = None,
|
boundary_authority_keyring_schema: dict[str, Any] | None = None,
|
||||||
verification_time: datetime | None = None,
|
verification_time: datetime | None = None,
|
||||||
|
installed_evidence_mode: str = "imported_unsigned",
|
||||||
|
expected_external_provider_subject: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return a secret-free release-drift report for one fit assessment."""
|
"""Return a secret-free release-drift report for one fit assessment."""
|
||||||
|
|
||||||
@@ -356,6 +359,10 @@ def review_capability_fit(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
evidence_verification_time = verification_time or datetime.now(tz=UTC)
|
||||||
|
evidence_verification_mode = (
|
||||||
|
"historical_override" if verification_time is not None else "current"
|
||||||
|
)
|
||||||
installed_review = review_installed_composition(
|
installed_review = review_installed_composition(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
catalog_entries=catalog_entries,
|
catalog_entries=catalog_entries,
|
||||||
@@ -363,6 +370,9 @@ def review_capability_fit(
|
|||||||
selected_commits=source_selection.selected_commits,
|
selected_commits=source_selection.selected_commits,
|
||||||
evidence=installed_evidence,
|
evidence=installed_evidence,
|
||||||
schema=installed_evidence_schema,
|
schema=installed_evidence_schema,
|
||||||
|
observation_mode=installed_evidence_mode,
|
||||||
|
verification_time=evidence_verification_time,
|
||||||
|
verification_mode=evidence_verification_mode,
|
||||||
)
|
)
|
||||||
findings.extend(
|
findings.extend(
|
||||||
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
||||||
@@ -378,7 +388,13 @@ def review_capability_fit(
|
|||||||
evidence_schema=boundary_evidence_schema,
|
evidence_schema=boundary_evidence_schema,
|
||||||
authority_keyring=boundary_authority_keyring,
|
authority_keyring=boundary_authority_keyring,
|
||||||
authority_keyring_schema=boundary_authority_keyring_schema,
|
authority_keyring_schema=boundary_authority_keyring_schema,
|
||||||
verification_time=verification_time,
|
verification_time=evidence_verification_time,
|
||||||
|
verification_mode=evidence_verification_mode,
|
||||||
|
expected_external_provider_subject=expected_external_provider_subject,
|
||||||
|
forbidden_authority_public_keys=public_key_material_from_keyrings(
|
||||||
|
published_keyring,
|
||||||
|
trusted_keyring,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
findings.extend(
|
findings.extend(
|
||||||
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
||||||
@@ -396,6 +412,7 @@ def review_capability_fit(
|
|||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
installed_review=installed_review,
|
installed_review=installed_review,
|
||||||
),
|
),
|
||||||
|
boundary_review.review_targets,
|
||||||
)
|
)
|
||||||
return build_report(
|
return build_report(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
@@ -908,7 +925,7 @@ def build_report(
|
|||||||
**boundary_scope,
|
**boundary_scope,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"report_version": "0.2.0",
|
"report_version": "0.3.0",
|
||||||
"status": status,
|
"status": status,
|
||||||
"assessment_id": assessment.get("assessment_id"),
|
"assessment_id": assessment.get("assessment_id"),
|
||||||
"assessment_release": assessment.get("release", {}).get("ref")
|
"assessment_release": assessment.get("release", {}).get("ref")
|
||||||
@@ -930,9 +947,11 @@ def build_report(
|
|||||||
|
|
||||||
|
|
||||||
def render_review(report: dict[str, Any]) -> str:
|
def render_review(report: dict[str, Any]) -> str:
|
||||||
installed_checked = (
|
proof_scope = report.get("proof_scope", {})
|
||||||
report.get("proof_scope", {}).get("installed_artifacts", {}).get("checked")
|
installed_scope = proof_scope.get("installed_artifacts", {})
|
||||||
is True
|
installed_checked = installed_scope.get("checked") is True
|
||||||
|
installed_supplied = bool(installed_scope.get("evidence_sha256")) or (
|
||||||
|
"installed_evidence_observation" in proof_scope
|
||||||
)
|
)
|
||||||
lines = [
|
lines = [
|
||||||
f"Capability fit rerun: {report['status']}",
|
f"Capability fit rerun: {report['status']}",
|
||||||
@@ -941,12 +960,29 @@ def render_review(report: dict[str, Any]) -> str:
|
|||||||
(
|
(
|
||||||
"Scope: schema, signed release metadata, optional local tag provenance, "
|
"Scope: schema, signed release metadata, optional local tag provenance, "
|
||||||
+ (
|
+ (
|
||||||
"and supplied installed-composition evidence."
|
"and locally observed installed-composition evidence."
|
||||||
if installed_checked
|
if installed_checked
|
||||||
|
else (
|
||||||
|
"and supplied installed-composition evidence that did not establish local proof."
|
||||||
|
if installed_supplied
|
||||||
else "and no installed-composition evidence."
|
else "and no installed-composition evidence."
|
||||||
)
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
verification = proof_scope.get("boundary_evidence_verification") or proof_scope.get(
|
||||||
|
"installed_evidence_observation"
|
||||||
|
)
|
||||||
|
if isinstance(verification, dict) and verification.get("evaluated_at"):
|
||||||
|
mode = verification.get("verification_mode")
|
||||||
|
mode_label = (
|
||||||
|
"HISTORICAL override"
|
||||||
|
if mode == "historical_override"
|
||||||
|
else "current-time evaluation"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"Evidence verification: {mode_label} at {verification['evaluated_at']}."
|
||||||
|
)
|
||||||
findings = report.get("findings") or []
|
findings = report.get("findings") or []
|
||||||
if findings:
|
if findings:
|
||||||
lines.append("Findings:")
|
lines.append("Findings:")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user