diff --git a/Makefile b/Makefile index 2896938..4b877e7 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ # CI invokes these same targets rather than re-implementing them in YAML, which is # what prevents a gate silently degrading into a warning. There is no warning tier. -.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help +.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-sample-report check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help -check: check-scope check-headers check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs +check: check-scope check-headers check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-sample-report check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs @echo "make check: all gates passed" ## check-scope D-96: no product implementation before architecture freeze @@ -85,6 +85,10 @@ check-python-floor: check-packaging: @python3 scripts/ci/check_packaging.py +## check-sample-report IQ-011: the published sample regenerates from committed evidence +check-sample-report: + @python3 scripts/docs/sample_report.py check + ## check-storage-vocabulary D-114: a retired inference does not return as vocabulary check-storage-vocabulary: @python3 scripts/ci/check_storage_vocabulary.py --self-test diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 40a1f84..10be720 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -111,8 +111,8 @@ Not asserted. Each number is counted at generation time. | | | |---|---| -| Gates | 19 | -| Falsification injections | 111 | +| Gates | 20 | +| Falsification injections | 113 | | Golden vector cases | 15 | | Frozen artifacts | 7 | | Test files | 3 | diff --git a/docs/IMPLEMENTATION_QUESTIONS.md b/docs/IMPLEMENTATION_QUESTIONS.md index 8275a7d..c1a056b 100644 --- a/docs/IMPLEMENTATION_QUESTIONS.md +++ b/docs/IMPLEMENTATION_QUESTIONS.md @@ -22,4 +22,4 @@ Assumptions only — **never authority**. The owner resolves; resolutions become | IQ-008 | CONFLICT | STORE-001 | `/run/isedraf/` | Implemented the lock at `/var/lib/isedraf/.lock` | The sketch placed the lock at `/run/isedraf/isedraf.lock`. `STORE-001` puts `.lock` inside the state root, which also makes the lock and the store it protects share a filesystem — a lock on a different filesystem cannot guarantee exclusion if the store is mounted elsewhere. `/run/isedraf/` remains available for future ephemeral state. | `STORE-001` | OPEN | | IQ-009 | GAP | SCOPE-077, SNAP-015 | `lib/isedraf/cli.py`, `lib/isedraf/verify.py` | Verification runs in-process at the end of `identity`; a failure exits `64` (engine error) | `SNAP-015` assigns `exit 5` to `integrity_failure`, and `SCOPE-077` states `5` is not reachable in W1-A. A standalone `isedraf verify` therefore has **no frozen exit code for "verification failed"**. No exit code was invented: verification stays in-process, and the standalone command is deferred until the W1-A exit set is extended or `verify` is scoped to a later set. | discovered implementing W1-B | OPEN | | IQ-010 | CONFLICT | SCOPE-070, SCOPE-071, SCOPE-072, STORE-001, PRIV-005 | `lib/isedraf/stateroot.py` | `resolve()` refuses to fall back to a production root this slice cannot reach, and names the requirements | **Not a contradiction between frozen rules — an implementation defect.** `SCOPE-070` already states W1 *"runs under `ISEDRAF_STATE_ROOT`"*, and `PRIV-005`'s Mode A — `sudo isedraf`, root supervisor inside a sandbox, state-root writability preflight — is the only execution topology that ever owns `/var/lib/isedraf`. Mode A is `DEFERRED_TO_FREEZE_SET_2` by `SCOPE-072`, so **W1 has no production mode by design**. The implementation was offering one and failing with a bare permission error. Answers for Freeze Set 2, when Mode A lands: the **root supervisor** creates and owns the root at mode `0700`; packaging creates it at install time; no group-readable relaxation, since `STORE-001` freezes `0700`; `sudo -u` is not a path, because `SCOPE-071` refuses anything reached through sudo and `PRIV-004` refuses when `SUDO_USER` is set | `env -u ISEDRAF_STATE_ROOT isedraf identity` now explains rather than failing on `EACCES` | **CLOSED_IMPLEMENTATION_FIX** | -| IQ-011 | GAP | D-114, STORAGE-SEMANTICS-001 | `docs/reference/samples/` (withdrawn), `docs/reference/CONTROL_EVIDENCE_MAP.md` | The rendered sample report was **withdrawn** rather than edited. It was real output from disposable lab VM `isd-a9` (AlmaLinux 9.7, KVM) and reported a storage device `type` of `ROTATIONAL` / `OPTICAL` — the exact inference D-114 retired, published as an example of what the tool claims. | It could not be regenerated: `isd-a9` no longer exists, the surviving lab corpus belongs to another project and is read-only, and `kernel_subsystem`, `queue_rotational`, `kernel_removable` and `scsi_peripheral_type` were never collected from that host, so re-rendering the old collection cannot produce them. Writing plausible values would be inventing evidence for a machine nobody can re-observe, which is the one failure this project exists to prevent. **Proposal:** regenerate after the RC on a disposable VM created for the purpose, and add a documentation generator so the sample is reproducible from committed bytes rather than from a VM that only one person ever had. A `check_storage_vocabulary` gate now rejects the retired vocabulary if it returns. | `git log 6ca913e`; storage semantics proofs, this milestone | OPEN | +| IQ-011 | GAP | D-114, STORAGE-SEMANTICS-001 | `docs/reference/samples/` (withdrawn), `docs/reference/CONTROL_EVIDENCE_MAP.md` | The rendered sample report was **withdrawn** rather than edited. It was real output from disposable lab VM `isd-a9` (AlmaLinux 9.7, KVM) and reported a storage device `type` of `ROTATIONAL` / `OPTICAL` — the exact inference D-114 retired, published as an example of what the tool claims. | It could not be regenerated: `isd-a9` no longer exists, the surviving lab corpus belongs to another project and is read-only, and `kernel_subsystem`, `queue_rotational`, `kernel_removable` and `scsi_peripheral_type` were never collected from that host, so re-rendering the old collection cannot produce them. Writing plausible values would be inventing evidence for a machine nobody can re-observe, which is the one failure this project exists to prevent. **Proposal:** regenerate after the RC on a disposable VM created for the purpose, and add a documentation generator so the sample is reproducible from committed bytes rather than from a VM that only one person ever had. A `check_storage_vocabulary` gate now rejects the retired vocabulary if it returns. | `git log 6ca913e`; storage semantics proofs, this milestone | **CLOSED_IMPLEMENTED** — a disposable Debian 12 VM was built for the purpose, the released 0.1.0-alpha1 package was installed on it, and an unprivileged collection produced the sample. The evidence is committed under `docs/reference/samples/evidence/` and the sample is generated from it by `scripts/docs/sample_report.py`, gated by `make check-sample-report` and two falsification injections. No value was supplied by hand. **Lab disclosure review, owner decision 2026-09-20:** the sample is kept exactly as captured. The QEMU machine description (`Ubuntu 24.04 PC (Q35 + ICH9, 2009)`), the CPU model string (`Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz`) and the libvirt default network `192.168.122.0/24` are ACCEPTED: disposable lab observations, containing no credentials, customer data, unique private addressing, restricted content or secret infrastructure information. No re-capture. Re-collecting behind `--cpu qemu64` and a synthetic machine type purely to make the output less specific would make the sample a cosmetically sanitized synthetic host, which is not what it is for: it demonstrates what ISEDRAF actually observes. The virtio result is the clearest evidence of that - `kernel_subsystem=virtio` with `queue_rotational=true` over SSD-backed storage is a real-world demonstration of why D-114 was necessary, because the queue flag is evidence and "HDD" would have been interpretation beyond it. | diff --git a/docs/reference/CONTROL_EVIDENCE_MAP.md b/docs/reference/CONTROL_EVIDENCE_MAP.md index 6710b6b..8638928 100644 --- a/docs/reference/CONTROL_EVIDENCE_MAP.md +++ b/docs/reference/CONTROL_EVIDENCE_MAP.md @@ -202,13 +202,22 @@ Report | Collection completeness | both | per-subdomain status | | Limitations | both | what the evidence does not support | -A rendered sample of the real output is **not published with this preview**. The previous sample was -generated on a disposable lab VM that no longer exists, and it describes the retired storage schema: -it reports a device `type` of `ROTATIONAL` or `OPTICAL`, which is exactly the inference the storage -observation model now refuses to make. It could not be migrated, because the fields that replaced -that one - `kernel_subsystem`, `queue_rotational`, `kernel_removable`, `scsi_peripheral_type` - were -never collected from that host, and writing plausible values for them would be inventing evidence. -A sample returns when it can be produced by running the tool, not by editing a document. +A rendered sample of the real output is in [`samples/SAMPLE_REPORT.md`](samples/SAMPLE_REPORT.md), +with the same report as JSON beside it. It is real `isedraf report` output, collected by an +unprivileged user on a disposable Debian 12 VM using the released `0.1.0-alpha1` package. + +It is **generated from committed evidence**, not pasted in. The collection it came from is in +[`samples/evidence/`](samples/evidence/) — the inventory as collected, and the snapshot, manifest +and ledger as written — and `python3 scripts/docs/sample_report.py generate` rebuilds both +documents from those bytes. Regenerating re-verifies the ledger chain as it goes, so the sample +demonstrates the verification rather than asserting it. `make check` fails if the published +sample and its evidence disagree. + +The previous sample was withdrawn because it described a retired storage schema and its source +machine no longer existed, so it could not be regenerated. That cannot happen again: the sample +no longer depends on anyone still having the host. What committed bytes cannot reproduce is the +collection itself — reading `/sys` and `/proc` needs the machine — so the evidence is captured +once and the report is reproducible from it. ## Domains not yet mapped diff --git a/docs/reference/samples/SAMPLE_REPORT.json b/docs/reference/samples/SAMPLE_REPORT.json new file mode 100644 index 0000000..a3b1c5b --- /dev/null +++ b/docs/reference/samples/SAMPLE_REPORT.json @@ -0,0 +1,430 @@ +{ + "assessment": null, + "collection_summary": [ + { + "collection_status": "COLLECTED", + "method": "/proc/sys/kernel/hostname + /etc/hosts", + "reason": null, + "subdomain": "host" + }, + { + "collection_status": "COLLECTED", + "method": "/etc/os-release + uname(2) + /proc/1/comm", + "reason": null, + "subdomain": "platform" + }, + { + "collection_status": "COLLECTED", + "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", + "reason": null, + "subdomain": "machine" + }, + { + "collection_status": "COLLECTED", + "method": "/proc/cpuinfo", + "reason": null, + "subdomain": "compute" + }, + { + "collection_status": "COLLECTED", + "method": "/proc/meminfo", + "reason": null, + "subdomain": "memory" + }, + { + "collection_status": "COLLECTED", + "method": "/sys/block + /proc/self/mounts + statvfs(2)", + "reason": null, + "subdomain": "storage" + }, + { + "collection_status": "COLLECTED", + "method": "ip -json addr/route", + "reason": null, + "subdomain": "network" + }, + { + "collection_status": "COLLECTED", + "method": "/etc/resolv.conf", + "reason": null, + "subdomain": "dns" + }, + { + "collection_status": "COLLECTED", + "method": "timedatectl show", + "reason": null, + "subdomain": "time" + } + ], + "identity_evidence": { + "available": true, + "collection_status": "COLLECTED", + "created_at": "2026-09-20T17:15:41Z", + "evidence_root": "docs/reference/samples/evidence/state-root", + "host_id": "sha256:4d3e3c2abb1f71c13a45ba590cda91e3ded6c7ccdc3f28dc63b5ac20f74dcfd1", + "ledger_record_hash": "sha256:1dab505f28bdb65d63fe62e1ecaeecb4f099e6ef4b4109bd6784b2a319f12698", + "ledger_sequence": 1, + "manifest_hash": "sha256:32f41a2a89693063b85580791a257363833f0438f32732a645ec63fbcdd09c8f", + "maturity": "W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained", + "reason": null, + "run_id": "RUN-20260920T171541Z-706d3c21624288c6", + "snapshot_id": "SDS-20260920T171541Z-4810e53a3805777d", + "state_hash": "sha256:8a69abe5f51d61d333818427118548224c0bc9d3efb31415e7355caf60e8d5b4", + "state_root_class": "DEV", + "verification": { + "problems": [], + "verified": true + } + }, + "inventory": { + "artifact_digest": "sha256:267f19bf54a7532559919a57e6b6b12c34db87d8c80141369e64c237b7e8f010", + "collected_at": "2026-09-20T17:15:48Z", + "collection_id": "INV-20260920T171548Z-11885ac7cb292572", + "collection_status": "COLLECTED", + "maturity": "W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021)", + "schema_version": 1, + "subdomains": { + "compute": { + "collection_status": "COLLECTED", + "data": { + "cores_per_socket": 1, + "cpu_model": "Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz", + "cpu_vendor": "GenuineIntel", + "logical_cpus": 2, + "sockets": 2 + }, + "method": "/proc/cpuinfo", + "reason": null, + "state_dimension": "ACTIVE" + }, + "dns": { + "collection_status": "COLLECTED", + "data": { + "method": "/etc/resolv.conf", + "note": null, + "servers": [ + "192.168.122.1" + ] + }, + "method": "/etc/resolv.conf", + "reason": null, + "state_dimension": "RESOLVED" + }, + "host": { + "collection_status": "COLLECTED", + "data": { + "fqdn": null, + "fqdn_source": "NOT_AVAILABLE_LOCALLY", + "hostname": "isd-sample" + }, + "method": "/proc/sys/kernel/hostname + /etc/hosts", + "reason": null, + "state_dimension": "ACTIVE" + }, + "machine": { + "collection_status": "COLLECTED", + "data": { + "hypervisor": "kvm", + "product": "Ubuntu 24.04 PC (Q35 + ICH9, 2009)", + "vendor": "QEMU", + "virtualized": true + }, + "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", + "reason": null, + "state_dimension": "ACTIVE" + }, + "memory": { + "collection_status": "COLLECTED", + "data": { + "swap_total_bytes": 0, + "total_bytes": 2075668480 + }, + "method": "/proc/meminfo", + "reason": null, + "state_dimension": "ACTIVE" + }, + "network": { + "collection_status": "COLLECTED", + "data": { + "default_routes": [ + { + "family": "inet", + "gateway": "192.168.122.1", + "interface": "enp1s0" + } + ], + "interfaces": [ + { + "loopback": true, + "mtu": 65536, + "name": "lo", + "state": "UNKNOWN" + }, + { + "loopback": false, + "mtu": 1500, + "name": "enp1s0", + "state": "UP" + } + ], + "ipv4": [ + { + "address": "127.0.0.1", + "interface": "lo", + "loopback": true, + "prefix_length": 8, + "scope": "host" + }, + { + "address": "192.168.122.128", + "interface": "enp1s0", + "loopback": false, + "prefix_length": 24, + "scope": "global" + } + ], + "ipv6_stable": [ + { + "address": "::1", + "classification": "LOOPBACK", + "interface": "lo", + "prefix_length": 128, + "scope": "host" + }, + { + "address": "fe80::5054:ff:fe9c:1a7c", + "classification": "LINK_LOCAL", + "interface": "enp1s0", + "prefix_length": 64, + "scope": "link" + } + ], + "ipv6_volatile": [] + }, + "method": "ip -json addr/route", + "reason": null, + "state_dimension": "ACTIVE" + }, + "platform": { + "collection_status": "COLLECTED", + "data": { + "architecture": "x86_64", + "family": null, + "id": "debian", + "init_system": "systemd", + "kernel_release": "6.1.0-53-cloud-amd64", + "pretty_name": "Debian GNU/Linux 12 (bookworm)", + "version_id": "12" + }, + "method": "/etc/os-release + uname(2) + /proc/1/comm", + "reason": null, + "state_dimension": "ACTIVE" + }, + "storage": { + "collection_status": "COLLECTED", + "data": { + "devices": [ + { + "kernel_removable": false, + "kernel_subsystem": "virtio", + "model": null, + "name": "vda", + "queue_rotational": true, + "scsi_peripheral_type": null, + "size_bytes": 6442450944, + "vendor": "0x1af4" + } + ], + "filesystems": [ + { + "fstype": "tmpfs", + "mount_point": "/run", + "options": [ + "inode64", + "mode=755", + "nodev", + "noexec", + "nosuid", + "relatime", + "rw", + "size=202704k" + ], + "read_only": false, + "source": "tmpfs" + }, + { + "fstype": "ext4", + "mount_point": "/", + "options": [ + "discard", + "errors=remount-ro", + "relatime", + "rw" + ], + "read_only": false, + "source": "/dev/vda1" + }, + { + "fstype": "tmpfs", + "mount_point": "/dev/shm", + "options": [ + "inode64", + "nodev", + "nosuid", + "rw" + ], + "read_only": false, + "source": "tmpfs" + }, + { + "fstype": "tmpfs", + "mount_point": "/run/lock", + "options": [ + "inode64", + "nodev", + "noexec", + "nosuid", + "relatime", + "rw", + "size=5120k" + ], + "read_only": false, + "source": "tmpfs" + }, + { + "fstype": "vfat", + "mount_point": "/boot/efi", + "options": [ + "codepage=437", + "dmask=0022", + "errors=remount-ro", + "fmask=0022", + "iocharset=ascii", + "relatime", + "rw", + "shortname=mixed", + "utf8" + ], + "read_only": false, + "source": "/dev/vda15" + }, + { + "fstype": "tmpfs", + "mount_point": "/run/user/1000", + "options": [ + "gid=1000", + "inode64", + "mode=700", + "nodev", + "nosuid", + "nr_inodes=50675", + "relatime", + "rw", + "size=202700k", + "uid=1000" + ], + "read_only": false, + "source": "tmpfs" + } + ], + "utilisation": [ + { + "available_bytes": 207032320, + "mount_point": "/run", + "total_bytes": 207568896, + "used_permille": 3 + }, + { + "available_bytes": 5068783616, + "mount_point": "/", + "total_bytes": 6130061312, + "used_permille": 173 + }, + { + "available_bytes": 1037832192, + "mount_point": "/dev/shm", + "total_bytes": 1037832192, + "used_permille": 0 + }, + { + "available_bytes": 5242880, + "mount_point": "/run/lock", + "total_bytes": 5242880, + "used_permille": 0 + }, + { + "available_bytes": 117364736, + "mount_point": "/boot/efi", + "total_bytes": 129718272, + "used_permille": 95 + }, + { + "available_bytes": 207564800, + "mount_point": "/run/user/1000", + "total_bytes": 207564800, + "used_permille": 0 + } + ] + }, + "method": "/sys/block + /proc/self/mounts + statvfs(2)", + "reason": null, + "state_dimension": "ACTIVE" + }, + "time": { + "collection_status": "COLLECTED", + "data": { + "ntp_enabled": true, + "offset_nanoseconds": null, + "provider": "systemd-timesyncd", + "source": null, + "stratum": null, + "synchronized": true, + "timezone": "Etc/UTC", + "uptime_seconds": 53 + }, + "method": "timedatectl show", + "reason": null, + "state_dimension": "RESOLVED" + } + } + }, + "limitations": [ + "Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from.", + "Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id.", + "Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift.", + "Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it.", + "Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour.", + "Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash.", + "Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed." + ], + "provenance": { + "collection_methods": { + "compute": "/proc/cpuinfo", + "dns": "/etc/resolv.conf", + "host": "/proc/sys/kernel/hostname + /etc/hosts", + "machine": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", + "memory": "/proc/meminfo", + "network": "ip -json addr/route", + "platform": "/etc/os-release + uname(2) + /proc/1/comm", + "storage": "/sys/block + /proc/self/mounts + statvfs(2)", + "time": "timedatectl show" + }, + "engine_version": "0.1.0-alpha1", + "inventory_schema_version": 1, + "report_schema_version": 1 + }, + "report": { + "engine_version": "0.1.0-alpha1", + "generated_at": "2026-09-20T17:15:48Z", + "report_id": "RPT-20260920T171548Z-71072fdd5e6cd97e", + "status": "COMPLETE" + }, + "report_schema_version": 1, + "target": { + "fqdn": null, + "fqdn_source": "NOT_AVAILABLE_LOCALLY", + "host_id": "sha256:4d3e3c2abb1f71c13a45ba590cda91e3ded6c7ccdc3f28dc63b5ac20f74dcfd1", + "hostname": "isd-sample" + } +} + diff --git a/docs/reference/samples/SAMPLE_REPORT.md b/docs/reference/samples/SAMPLE_REPORT.md new file mode 100644 index 0000000..1e4648b --- /dev/null +++ b/docs/reference/samples/SAMPLE_REPORT.md @@ -0,0 +1,179 @@ + + + +# ISEDRAF System Assurance Report + +| | | +|---|---| +| Report ID | `RPT-20260920T171548Z-71072fdd5e6cd97e` | +| Generated (UTC) | 2026-09-20T17:15:48Z | +| ISEDRAF version | 0.1.0-alpha1 | +| Report status | **COMPLETE** | + +> This report describes what was **observed**. It contains no secure/insecure verdict, because host inventory is evidence and a score would be a judgement the data does not support. + +## Target + +| | | +|---|---| +| Hostname | isd-sample | +| FQDN | *not resolvable locally — no resolver was consulted* | +| Host ID | `sha256:4d3e3c2abb1f71c13a45ba590cda91e3ded6c7ccdc3f28dc63b5ac20f74dcfd1` | + +## Evidence + +### Host identity — W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained + +| | | +|---|---| +| Snapshot ID | `SDS-20260920T171541Z-4810e53a3805777d` | +| Manifest hash | `sha256:32f41a2a89693063b85580791a257363833f0438f32732a645ec63fbcdd09c8f` | +| State hash | `sha256:8a69abe5f51d61d333818427118548224c0bc9d3efb31415e7355caf60e8d5b4` | +| Ledger record | sequence 1, `sha256:1dab505f28bdb65d63fe62e1ecaeecb4f099e6ef4b4109bd6784b2a319f12698` | +| Collection status | COLLECTED | +| Evidence root | `docs/reference/samples/evidence/state-root` (DEV) | +| Independent verification | **PASS** — every hash recomputed from the stored preimages | + +### Host inventory — W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021) + +| | | +|---|---| +| Collection ID | `INV-20260920T171548Z-11885ac7cb292572` | +| Artifact digest | `sha256:267f19bf54a7532559919a57e6b6b12c34db87d8c80141369e64c237b7e8f010` | +| Collected (UTC) | 2026-09-20T17:15:48Z | +| Inventory schema | 1 | +| Collection status | COLLECTED | + +The artifact digest is a content SHA-256 over the deterministic inventory artifact. It is **not** a snapshot hash and the inventory is **not** inside the frozen snapshot. + +## Platform + +| | | +|---|---| +| Operating system | Debian GNU/Linux 12 (bookworm) | +| Version | 12 | +| Family | — | +| Kernel | `6.1.0-53-cloud-amd64` | +| Architecture | x86_64 | +| Init system | systemd | +| Machine | virtual | +| Hypervisor | kvm | +| Vendor / product | QEMU / Ubuntu 24.04 PC (Q35 + ICH9, 2009) | + +## Compute and memory + +*Hardware observations. They change legitimately on virtualized hosts and do not by themselves indicate configuration drift.* + +| | | +|---|---| +| CPU | Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz | +| Vendor | GenuineIntel | +| Sockets / cores per socket | 2 / 1 | +| Logical CPUs | 2 | +| Memory | 1.9 GiB | +| Swap | — | + +## Storage + +| Device | Size | Kernel subsystem | Queue rotational | Kernel removable flag | Vendor | Model | +|---|---|---|---|---|---|---| +| `vda` | 6.4 GB | virtio | true | false | 0x1af4 | — | + +*Kernel-reported block-device attributes. `Queue rotational` and `Kernel removable flag` describe how Linux presents the block queue; neither establishes the physical storage medium, and a block device does not necessarily correspond to one physical disk (D-114).* + +| Mount point | Source | Type | Mode | Options | +|---|---|---|---|---| +| `/run` | `tmpfs` | tmpfs | read-write | `inode64,mode=755,nodev,noexec,nosuid,relatime` | +| `/` | `/dev/vda1` | ext4 | read-write | `discard,errors=remount-ro,relatime,rw` | +| `/dev/shm` | `tmpfs` | tmpfs | read-write | `inode64,nodev,nosuid,rw` | +| `/run/lock` | `tmpfs` | tmpfs | read-write | `inode64,nodev,noexec,nosuid,relatime,rw` | +| `/boot/efi` | `/dev/vda15` | vfat | read-write | `codepage=437,dmask=0022,errors=remount-ro,fmask=0022,iocharset=ascii,relatime` | +| `/run/user/1000` | `tmpfs` | tmpfs | read-write | `gid=1000,inode64,mode=700,nodev,nosuid,nr_inodes=50675` | + +**Utilisation** — a volatile observation, not configuration: + +| Mount point | Used | +|---|---| +| `/run` | 0.3% | +| `/` | 17.3% | +| `/dev/shm` | 0.0% | +| `/run/lock` | 0.0% | +| `/boot/efi` | 9.5% | +| `/run/user/1000` | 0.0% | + +## Network + +| Interface | State | MTU | +|---|---|---| +| `enp1s0` | UP | 1500 | + +**IPv4** + +| Address | Interface | Scope | +|---|---|---| +| `192.168.122.128/24` | `enp1s0` | global | + +**IPv6** + +| Address | Interface | Classification | +|---|---|---| +| `fe80::5054:ff:fe9c:1a7c/64` | `enp1s0` | LINK_LOCAL | + +| Default route | Gateway | Interface | +|---|---|---| +| inet | `192.168.122.1` | `enp1s0` | + +**DNS** — `192.168.122.1` + +Source: `/etc/resolv.conf` + +Addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. + +## Time + +| | | +|---|---| +| Timezone | Etc/UTC | +| NTP provider | systemd-timesyncd | +| NTP enabled | yes | +| **Clock synchronized** | yes | + +*NTP enabled and clock synchronized are different facts.* Configuration says what was intended; synchronization says what is true now. + +## Collection completeness + +| Subdomain | Status | Method | Reason | +|---|---|---|---| +| host | COLLECTED | `/proc/sys/kernel/hostname + /etc/hosts` | — | +| platform | COLLECTED | `/etc/os-release + uname(2) + /proc/1/comm` | — | +| machine | COLLECTED | `systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id` | — | +| compute | COLLECTED | `/proc/cpuinfo` | — | +| memory | COLLECTED | `/proc/meminfo` | — | +| storage | COLLECTED | `/sys/block + /proc/self/mounts + statvfs(2)` | — | +| network | COLLECTED | `ip -json addr/route` | — | +| dns | COLLECTED | `/etc/resolv.conf` | — | +| time | COLLECTED | `timedatectl show` | — | + +An incomplete observation is reported as incomplete. A missing tool is `NOT_TESTED`, a present tool that failed is `ERROR`, and neither is rendered as an empty success. + +## Limitations + +- Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from. +- Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id. +- Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift. +- Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. +- Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour. +- Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash. +- Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed. + diff --git a/docs/reference/samples/evidence/collected_inventory.json b/docs/reference/samples/evidence/collected_inventory.json new file mode 100644 index 0000000..cc658b8 --- /dev/null +++ b/docs/reference/samples/evidence/collected_inventory.json @@ -0,0 +1,2 @@ +{"collection_status":"COLLECTED","schema_version":1,"subdomains":{"compute":{"collection_status":"COLLECTED","data":{"cores_per_socket":1,"cpu_model":"Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz","cpu_vendor":"GenuineIntel","logical_cpus":2,"sockets":2},"method":"/proc/cpuinfo","reason":null,"state_dimension":"ACTIVE"},"dns":{"collection_status":"COLLECTED","data":{"method":"/etc/resolv.conf","note":null,"servers":["192.168.122.1"]},"method":"/etc/resolv.conf","reason":null,"state_dimension":"RESOLVED"},"host":{"collection_status":"COLLECTED","data":{"fqdn":null,"fqdn_source":"NOT_AVAILABLE_LOCALLY","hostname":"isd-sample"},"method":"/proc/sys/kernel/hostname + /etc/hosts","reason":null,"state_dimension":"ACTIVE"},"machine":{"collection_status":"COLLECTED","data":{"hypervisor":"kvm","product":"Ubuntu 24.04 PC (Q35 + ICH9, 2009)","vendor":"QEMU","virtualized":true},"method":"systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id","reason":null,"state_dimension":"ACTIVE"},"memory":{"collection_status":"COLLECTED","data":{"swap_total_bytes":0,"total_bytes":2075668480},"method":"/proc/meminfo","reason":null,"state_dimension":"ACTIVE"},"network":{"collection_status":"COLLECTED","data":{"default_routes":[{"family":"inet","gateway":"192.168.122.1","interface":"enp1s0"}],"interfaces":[{"loopback":true,"mtu":65536,"name":"lo","state":"UNKNOWN"},{"loopback":false,"mtu":1500,"name":"enp1s0","state":"UP"}],"ipv4":[{"address":"127.0.0.1","interface":"lo","loopback":true,"prefix_length":8,"scope":"host"},{"address":"192.168.122.128","interface":"enp1s0","loopback":false,"prefix_length":24,"scope":"global"}],"ipv6_stable":[{"address":"::1","classification":"LOOPBACK","interface":"lo","prefix_length":128,"scope":"host"},{"address":"fe80::5054:ff:fe9c:1a7c","classification":"LINK_LOCAL","interface":"enp1s0","prefix_length":64,"scope":"link"}],"ipv6_volatile":[]},"method":"ip -json addr/route","reason":null,"state_dimension":"ACTIVE"},"platform":{"collection_status":"COLLECTED","data":{"architecture":"x86_64","family":null,"id":"debian","init_system":"systemd","kernel_release":"6.1.0-53-cloud-amd64","pretty_name":"Debian GNU/Linux 12 (bookworm)","version_id":"12"},"method":"/etc/os-release + uname(2) + /proc/1/comm","reason":null,"state_dimension":"ACTIVE"},"storage":{"collection_status":"COLLECTED","data":{"devices":[{"kernel_removable":false,"kernel_subsystem":"virtio","model":null,"name":"vda","queue_rotational":true,"scsi_peripheral_type":null,"size_bytes":6442450944,"vendor":"0x1af4"}],"filesystems":[{"fstype":"tmpfs","mount_point":"/run","options":["inode64","mode=755","nodev","noexec","nosuid","relatime","rw","size=202704k"],"read_only":false,"source":"tmpfs"},{"fstype":"ext4","mount_point":"/","options":["discard","errors=remount-ro","relatime","rw"],"read_only":false,"source":"/dev/vda1"},{"fstype":"tmpfs","mount_point":"/dev/shm","options":["inode64","nodev","nosuid","rw"],"read_only":false,"source":"tmpfs"},{"fstype":"tmpfs","mount_point":"/run/lock","options":["inode64","nodev","noexec","nosuid","relatime","rw","size=5120k"],"read_only":false,"source":"tmpfs"},{"fstype":"vfat","mount_point":"/boot/efi","options":["codepage=437","dmask=0022","errors=remount-ro","fmask=0022","iocharset=ascii","relatime","rw","shortname=mixed","utf8"],"read_only":false,"source":"/dev/vda15"},{"fstype":"tmpfs","mount_point":"/run/user/1000","options":["gid=1000","inode64","mode=700","nodev","nosuid","nr_inodes=50675","relatime","rw","size=202700k","uid=1000"],"read_only":false,"source":"tmpfs"}],"utilisation":[{"available_bytes":207032320,"mount_point":"/run","total_bytes":207568896,"used_permille":3},{"available_bytes":5068783616,"mount_point":"/","total_bytes":6130061312,"used_permille":173},{"available_bytes":1037832192,"mount_point":"/dev/shm","total_bytes":1037832192,"used_permille":0},{"available_bytes":5242880,"mount_point":"/run/lock","total_bytes":5242880,"used_permille":0},{"available_bytes":117364736,"mount_point":"/boot/efi","total_bytes":129718272,"used_permille":95},{"available_bytes":207564800,"mount_point":"/run/user/1000","total_bytes":207564800,"used_permille":0}]},"method":"/sys/block + /proc/self/mounts + statvfs(2)","reason":null,"state_dimension":"ACTIVE"},"time":{"collection_status":"COLLECTED","data":{"ntp_enabled":true,"offset_nanoseconds":null,"provider":"systemd-timesyncd","source":null,"stratum":null,"synchronized":true,"timezone":"Etc/UTC","uptime_seconds":53},"method":"timedatectl show","reason":null,"state_dimension":"RESOLVED"}}} + diff --git a/docs/reference/samples/evidence/environment.txt b/docs/reference/samples/evidence/environment.txt new file mode 100644 index 0000000..3d4bdd6 --- /dev/null +++ b/docs/reference/samples/evidence/environment.txt @@ -0,0 +1,7 @@ +isedraf_version: isedraf 0.1.0-alpha1 +deb_version: 0.1.0~alpha1 +python: Python 3.11.2 (main, May 12 2026, 05:17:27) [GCC 12.2.0] +kernel: 6.1.0-53-cloud-amd64 x86_64 +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +VERSION_ID="12" +ID=debian diff --git a/docs/reference/samples/evidence/state-root/ledger/segment-000001.jsonl b/docs/reference/samples/evidence/state-root/ledger/segment-000001.jsonl new file mode 100644 index 0000000..16ba6c4 --- /dev/null +++ b/docs/reference/samples/evidence/state-root/ledger/segment-000001.jsonl @@ -0,0 +1 @@ +{"record_core":{"event":"snapshot_committed","event_id":"EVT-20260920T171541Z-928303e6ea57f903","ledger_schema_version":1,"manifest_hash":"sha256:32f41a2a89693063b85580791a257363833f0438f32732a645ec63fbcdd09c8f","occurred_at":"2026-09-20T17:15:41Z","previous_record_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","sequence":1,"snapshot_id":"SDS-20260920T171541Z-4810e53a3805777d","state_root":"DEV"},"record_hash":"sha256:1dab505f28bdb65d63fe62e1ecaeecb4f099e6ef4b4109bd6784b2a319f12698"} diff --git a/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/manifest.json b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/manifest.json new file mode 100644 index 0000000..18d9b04 --- /dev/null +++ b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/manifest.json @@ -0,0 +1 @@ +{"manifest_core":{"created_at":"2026-09-20T17:15:41Z","engine_version":"0.1.0-alpha1","host_id":"sha256:4d3e3c2abb1f71c13a45ba590cda91e3ded6c7ccdc3f28dc63b5ac20f74dcfd1","run_id":"RUN-20260920T171541Z-706d3c21624288c6","schema_version":1,"sections":{"host_identity":{"classification_table_version":1,"collection_status":"COLLECTED","collector_id":"host_identity","collector_version":1,"parser_version":1,"reason":null,"source_id":"file:/etc/machine-id","state_hash":"sha256:8a69abe5f51d61d333818427118548224c0bc9d3efb31415e7355caf60e8d5b4"}},"snapshot_id":"SDS-20260920T171541Z-4810e53a3805777d","state_root":"DEV"},"manifest_hash":"sha256:32f41a2a89693063b85580791a257363833f0438f32732a645ec63fbcdd09c8f"} diff --git a/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/method/host_identity.json b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/method/host_identity.json new file mode 100644 index 0000000..7132606 --- /dev/null +++ b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/method/host_identity.json @@ -0,0 +1 @@ +{"classification_table_version":1,"collector_id":"host_identity","collector_version":1,"identity_scheme":"machine-id-sha256-v1","parser_version":1,"source_id":"file:/etc/machine-id"} diff --git a/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/state/host_identity.json b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/state/host_identity.json new file mode 100644 index 0000000..e524fd3 --- /dev/null +++ b/docs/reference/samples/evidence/state-root/snapshots/SDS-20260920T171541Z-4810e53a3805777d/state/host_identity.json @@ -0,0 +1 @@ +{"host_id":"sha256:4d3e3c2abb1f71c13a45ba590cda91e3ded6c7ccdc3f28dc63b5ac20f74dcfd1"} diff --git a/scripts/ci/falsifiable.sh b/scripts/ci/falsifiable.sh index 4917f62..8c26b87 100755 --- a/scripts/ci/falsifiable.sh +++ b/scripts/ci/falsifiable.sh @@ -507,6 +507,26 @@ inject "D-86 the rpm changelog states a weekday the date never fell on" \ 'sed -i "s|^\* Fri Sep 18 2026|* Thu Sep 18 2026|" packaging/rpm/isedraf.spec.in' \ 'which was a|packaging metadata gate FAILED' +# IQ-011. The previous sample described a retired storage schema for three versions and +# nothing noticed, because no gate compared a published document against the evidence it +# came from. These two prove the sample cannot drift from its evidence in either +# direction: the document edited by hand, and the evidence changed underneath it. +inject "IQ-011 the published sample drifts from the evidence it is generated from" \ + 'python3 scripts/docs/sample_report.py check' \ + 'sed -i "s/| Kernel subsystem |/| Class |/" docs/reference/samples/SAMPLE_REPORT.md' \ + 'SAMPLE_REPORT.md|does not match the committed evidence' + +inject "IQ-011 the committed evidence changes without the sample being regenerated" \ + 'python3 scripts/docs/sample_report.py check' \ + 'python3 - < +# +# Purpose: Render the published sample report from committed evidence bytes. +# Implements: IQ-011, D-88, D-89, GOV-002 +# +# The previous sample was real output from a lab VM, and when the storage schema changed +# it could not be regenerated: the machine was gone, and the fields that replaced the +# retired ones had never been collected from it. It was withdrawn rather than corrected, +# because writing plausible values for a host nobody can re-observe is inventing evidence. +# +# This generator makes that failure impossible to repeat. The evidence is committed: +# +# evidence/collected_inventory.json verbatim `isedraf inventory --json` output +# evidence/state-root/ the snapshot, manifest and ledger, as written +# evidence/environment.txt what produced them, and on what +# +# Regenerating re-runs the real report model and the real renderers over those bytes, and +# re-verifies the ledger chain while doing it. Anyone can reproduce the published sample +# from a clone. Nobody needs the VM, which is the point - it no longer exists. +# +# What this does NOT reproduce is the collection itself. Reading /sys and /proc requires +# the host, and no committed byte can stand in for it. The sample therefore reproduces +# the REPORT from captured evidence, not the act of collecting - and says so. +# +# meta:type="doc-generator" +# meta:owner="Antonios Voulvoulis / ITCMS" +# meta:stability="EXPERIMENTAL" +# meta:privilege="unprivileged" +# meta:mutates="docs/reference/samples/SAMPLE_REPORT.{json,md}" +# meta:binaries="python3" +# ============================================================================= + +"""usage: sample_report.py [generate|check]""" +import json +import os +import subprocess +import sys + +ROOT = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() +sys.path.insert(0, os.path.join(ROOT, "lib")) + +from isedraf.report import artifact # noqa: E402 +from isedraf.report import model as report_model # noqa: E402 +from isedraf.report import render # noqa: E402 + +SAMPLES = os.path.join(ROOT, "docs", "reference", "samples") +EVIDENCE = os.path.join(SAMPLES, "evidence") +INVENTORY = os.path.join(EVIDENCE, "collected_inventory.json") + +# Repo-relative, and resolved from the repository root, because the report records the +# evidence root it read. An absolute path would bake one machine's checkout location into +# a published document and make the sample regenerate differently on every clone - which +# is the opposite of the property this file exists to provide. +STATE_ROOT = os.path.join("docs", "reference", "samples", "evidence", "state-root") + +# Pinned so the sample is stable. Both are recorded in NON_REPRODUCIBLE precisely because +# they MUST differ between two renderings of the same evidence; pinning them here is what +# lets the rest of the document be compared byte for byte. These are the real values from +# the collection that produced the committed evidence, not invented ones. +REPORT_ID = "RPT-20260920T171548Z-71072fdd5e6cd97e" +GENERATED_AT = "2026-09-20T17:15:48Z" + +# The inventory artifact identity is stamped when a report is built, not when the +# inventory is collected, so rendering twice would otherwise produce two collection_ids +# and two artifact_digests for the same bytes. These are the real values from the +# collection that produced the committed evidence - taken from that run's own report, +# not chosen - and pinning them is what makes the digest a function of the evidence. +COLLECTION_ID = "INV-20260920T171548Z-11885ac7cb292572" +COLLECTED_AT = "2026-09-20T17:15:48Z" + +HEADER = """ + + +""" + + +def build(): + os.chdir(ROOT) + with open(INVENTORY, "rb") as fh: + inventory = json.loads(fh.read().decode("utf-8")) + art = artifact.build(inventory, collected_at=COLLECTED_AT, + collection_id=COLLECTION_ID) + return report_model.build(STATE_ROOT, inventory, generated_at=GENERATED_AT, + report_id=REPORT_ID, inventory_artifact=art) + + +def rendered(): + m = build() + return render.to_json(m) + "\n", HEADER + render.to_markdown(m) + + +def main(argv): + action = argv[1] if len(argv) > 1 else "generate" + js, md = rendered() + paths = ((os.path.join(SAMPLES, "SAMPLE_REPORT.json"), js), + (os.path.join(SAMPLES, "SAMPLE_REPORT.md"), md)) + if action == "generate": + for path, text in paths: + with open(path, "w") as fh: + fh.write(text) + print(" generated %s" % os.path.relpath(path, ROOT)) + return 0 + if action == "check": + stale = [] + for path, text in paths: + try: + with open(path) as fh: + current = fh.read() + except OSError: + stale.append(os.path.relpath(path, ROOT) + " (missing)") + continue + if current != text: + stale.append(os.path.relpath(path, ROOT)) + if stale: + print("=== the published sample does not match the committed evidence ===") + for s in stale: + print(" FAIL %s" % s) + print(" The sample is generated from docs/reference/samples/evidence/.") + print(" run: python3 scripts/docs/sample_report.py generate") + return 1 + print(" OK sample report regenerates byte-identically from committed evidence") + return 0 + print(__doc__.strip(), file=sys.stderr) + return 64 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))