diff --git a/Makefile b/Makefile index 25f5eeb..e10dca5 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-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging 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-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging 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 -check: check-scope check-headers check-python-floor check-packaging 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-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 @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-native-catalog D-111: the control catalog is ISEDRAF's own, and stays that way +check-native-catalog: + @python3 scripts/ci/check_native_catalog.py + ## check-licensing D-84/D-90: MPL covers what we own; unknown licensing is not distributable check-licensing: @python3 scripts/ci/check_licensing.py diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 8135288..7aa0c30 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -62,6 +62,7 @@ Designed, not built. No part of this runs. - `framework_mapping_packs` — design only - no pack loader, no manifest reader, no entitlement mechanism and no signing exists; no third-party mapping is licensed, reviewed or bundled (design: `docs/licensing/FRAMEWORK_PACK_ARCHITECTURE.md`) - `journald_recording` - `mounts` +- `native_control_catalog` — D-111 freezes the invariant and the ISE-* namespace: 14 families, ISE-IDENT reserved and not in use. NO native criterion is authored yet - they arrive in W1-D. make check-native-catalog enforces that the registry and the catalog document agree, that criteria hold to the namespace, that no criterion is derived from a framework, and that no production module is named after a provider (design: `docs/architecture/NATIVE_CONTROL_CATALOG.md`) - `pam` - `report_pdf` - `services` @@ -104,8 +105,8 @@ Not asserted. Each number is counted at generation time. | | | |---|---| -| Gates | 17 | -| Falsification injections | 70 | +| Gates | 18 | +| Falsification injections | 73 | | Golden vector cases | 15 | | Frozen artifacts | 7 | | Test files | 3 | diff --git a/docs/architecture/NATIVE_CONTROL_CATALOG.md b/docs/architecture/NATIVE_CONTROL_CATALOG.md new file mode 100644 index 0000000..5d41a6c --- /dev/null +++ b/docs/architecture/NATIVE_CONTROL_CATALOG.md @@ -0,0 +1,147 @@ + +# ISEDRAF Native Control Catalog + +Implements: D-78, D-79, D-82, D-84, D-111 + +**Architecture invariant, owner decision 2026-09-19 (`D-111`), frozen before W1-D is designed.** + +> The ISEDRAF native control catalog is authored first. Framework authority is mapped second, and +> only where licensing permits. **An external framework is never the source of a control.** + +## The layering + +```text +LINUX FACT + ↓ +ISEDRAF NATIVE CONTROL + ↓ +ISEDRAF RESULT / EVIDENCE + ↓ +OPTIONAL AUTHORITY MAPPING + ├── open / public mapping + ├── provider-licensed mapping + └── no mapping +``` + +The arrow runs one way. A framework requirement never defines how a collector is implemented or what +a criterion says, and removing every mapping leaves the catalog exactly as useful. + +## Three ownerships, kept apart + +| | | +|---|---| +| **Control ownership** | ISEDRAF | +| **Evidence ownership** | the customer, and the host observed | +| **Framework mapping rights** | framework- and provider-specific | + +Separating these is the whole point. The first two are ours and the customer's and carry no +third-party licensing question. Only the third does, and it is isolated so that a licensing problem +in one framework cannot reach the engine, the evidence, or any other framework. + +## What ISEDRAF claims about authorship — and what it does not + +> ISEDRAF independently authors technical host-assurance criteria based on Linux system behaviour, +> security engineering principles, and sources whose reuse rights permit that use. External framework +> content is not required to define those criteria. + +That is the claim, and it is deliberately narrower than *"nothing could ever forbid a control"*. This +project does not assert that no patent, contract, trademark, copyright, database right or other +restriction could exist anywhere in the world. It asserts what it can support: the criteria are +independently authored, and they do not depend on licensed framework content. + +The distinction matters in a legal document. An absolute claim is the kind of sentence that is +cost-free to write and expensive to defend. + +## Namespace + +Authority: `scripts/ci/native_controls.json`. `make check-native-catalog` requires this document and +that file to agree, because two authorities that can disagree are how a namespace drifts. + +| Family | Domain | +|---|---| +| `ISE-ASSET-*` | asset and host inventory | +| `ISE-SW-*` | software and package state | +| `ISE-ACCOUNT-*` | user, service and administrative accounts | +| `ISE-AUTH-*` | authentication | +| `ISE-PRIV-*` | sudo and privilege | +| `ISE-SSH-*` | SSH posture | +| `ISE-SVC-*` | service exposure and state | +| `ISE-KERNEL-*` | sysctl and kernel security | +| `ISE-LOG-*` | audit and logging | +| `ISE-TIME-*` | clock and time synchronization | +| `ISE-CRYPTO-*` | cryptographic posture | +| `ISE-STORAGE-*` | filesystem, storage and mount security | +| `ISE-NET-*` | network configuration | +| `ISE-UPDATE-*` | update and support posture | + +**`ISE-IDENT-*` is reserved and not in use.** An earlier draft used it for accounts. Host *identity* +(`machine-id`, `host_id`) and user *accounts* are different domains, and one prefix meaning both +would have been a permanent source of confusion. Accounts are `ISE-ACCOUNT-*`; the reservation stands +so the earlier draft resolves to this explanation rather than to silence. + +## What a criterion contains + +Required: `criterion_id` · `purpose` · `facts_required` · `dimensions` (declared / resolved / active, +where applicable) · `evaluation_semantics` · `applicability` · `limitations` · `evidence_pointers` · +`version`. Optional: `remediation_guidance`. + +Result states: `PASS` · `FAIL` · `PARTIAL` · `NOT_EVALUATED`. + +Every field is ISEDRAF-authored. No framework identifier, title, description or safeguard text +appears in a criterion — a mapping is a separate object in a separate layer. + +### Illustrative shape + +```text +ISE-SSH-001 + purpose Determine the effective SSH root-login posture. + facts effective PermitRootLogin value · configuration source · resolution status + states PASS / FAIL / PARTIAL / NOT_EVALUATED + evidence the exact normalized observed state +``` + +Nobody's permission is required to write a Linux security criterion about effective SSH +configuration. The licensing question begins one layer later, at *"this corresponds to provider X +control Y"* — which can involve another party's identifiers, taxonomy, titles, descriptions, +profiles, selection and arrangement, trademarks or proprietary mapping data. That is exactly why it +is isolated. + +## Build order + +**Stage 1 — the catalog.** Author the ISEDRAF control universe. No CIS, ISO, SCF or anything else is +required anywhere in it. *(W1-D and later. Nothing is authored today.)* + +**Stage 2 — open authorities.** For sources whose exact reuse rights are verified, add mappings. The +ISEDRAF criterion remains authoritative for the engine even here. + +**Stage 3 — restricted authorities.** An encrypted licensed mapping pack plus a provider entitlement. + +```text +ISEDRAF control always available +ISEDRAF evidence always available +provider mapping entitlement required +provider report view entitlement required +provider content provider controlled +``` + +## Why this is frozen before W1-D + +It means the technical engine can be finished without waiting for anyone. + +```text +provider says yes later → existing controls + licensed mapping pack +provider says no → existing controls +``` + +Either way there is no collector rewrite and nothing is lost — which is what +*measure once, map everywhere, fix only the delta* has to mean in practice if it is going to survive +contact with a licensing negotiation. + +## Current state + +**No native criterion is authored.** The namespace is frozen; the catalog is empty; no mapping of any +kind exists. `scripts/ci/native_controls.json` says so, and the gate does not treat an empty catalog +as a pass by omission. diff --git a/docs/licensing/FRAMEWORK_MAPPING_POLICY.md b/docs/licensing/FRAMEWORK_MAPPING_POLICY.md index 3fca16f..8f49a4a 100644 --- a/docs/licensing/FRAMEWORK_MAPPING_POLICY.md +++ b/docs/licensing/FRAMEWORK_MAPPING_POLICY.md @@ -30,7 +30,12 @@ ISEDRAF EVIDENCE / RESULT observed, with its limits stated OPTIONAL FRAMEWORK MAPPING a downstream overlay, if one is licensed ``` -The arrow never runs the other way. A framework requirement does not define how a collector is +The arrow never runs the other way. This is frozen as an architecture invariant in +[`NATIVE_CONTROL_CATALOG.md`](../architecture/NATIVE_CONTROL_CATALOG.md) (`D-111`), which fixes the +`ISE-*` namespace and separates three ownerships: **control ownership** is ISEDRAF's, **evidence +ownership** is the customer's and the observed host's, and only **framework mapping rights** are +framework-specific. The practical effect is that the technical engine is completed without waiting +for any provider — a later agreement adds a mapping pack, and a refusal costs nothing already built. A framework requirement does not define how a collector is implemented, and there is no `cis_collector.py`, `iso_collector.py` or `scf_collector.py` — there is a users collector, an SSH collector, a logging collector. One host collection can then serve many mappings. diff --git a/scripts/ci/check_native_catalog.py b/scripts/ci/check_native_catalog.py new file mode 100644 index 0000000..f88d8f9 --- /dev/null +++ b/scripts/ci/check_native_catalog.py @@ -0,0 +1,129 @@ +# ============================================================================= +# ISEDRAF — Linux Host Assurance, State Delta & Evidence Bridge (codename) +# ============================================================================= +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 Antonios Voulvoulis / ITCMS +# +# Purpose: The native control catalog is ISEDRAF's own, and stays that way. +# Implements: D-78, D-79, D-84, D-111, GOV-002 +# +# D-111 freezes the layering: LINUX FACT -> NATIVE CONTROL -> EVIDENCE -> OPTIONAL MAPPING. +# An invariant with no gate is a sentence, and this project has learned what those are +# worth. Four things are checked, and the first is the one that keeps the namespace honest. +# +# 1. The registry and the architecture document must AGREE on the families. Two +# authorities that can disagree are how a namespace drifts - the same defect class as +# the rpm spec staging two documents while build.sh staged four. +# 2. Every criterion ID matches the frozen namespace pattern and a declared family. +# 3. No criterion may cite a framework as its source. The control is ours or it is not a +# native control. +# 4. No production module may be named after a framework provider. There is no +# cis_collector.py, and the gate is what makes that a fact rather than an intention. +# +# meta:type="ci-gate" +# meta:owner="Antonios Voulvoulis / ITCMS" +# meta:stability="EXPERIMENTAL" +# meta:privilege="unprivileged" +# meta:mutates="none" +# meta:binaries="git,python3" +# ============================================================================= + +"""usage: check_native_catalog.py""" +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True).strip()) +REG = json.loads((ROOT / "scripts" / "ci" / "native_controls.json").read_text()) +DOC = ROOT / "docs" / "architecture" / "NATIVE_CONTROL_CATALOG.md" +FAIL = [] + + +def bad(msg): + FAIL.append(msg) + print(" FAIL %s" % msg) + + +print("--- native control catalog (D-111) ---") + +families = REG["families"] +# Z-18: count the input before judging it. A namespace with no families is not a pass. +if not families: + bad("the registry declares no control families") + sys.exit(1) + +# --- 1. the registry and the document agree ------------------------------------------ +if not DOC.exists(): + bad("the catalog document is missing: %s" % DOC.relative_to(ROOT)) +else: + text = DOC.read_text(encoding="utf-8") + in_doc = set(re.findall(r"`(ISE-[A-Z]+)-\*`", text)) + declared = set(families) + reserved = set(REG.get("reserved_families", {})) + for f in sorted(declared - in_doc): + bad("%s is in the registry and not in the catalog document" % f) + for f in sorted(in_doc - declared - reserved): + bad("%s is in the catalog document and not in the registry" % f) + for f in sorted(reserved): + if f not in text: + bad("%s is reserved but the document does not explain why" % f) + if not (declared - in_doc) and not (in_doc - declared - reserved): + print(" OK %d families: registry and catalog document agree" % len(declared)) + +# --- 2. criterion IDs hold to the frozen namespace ----------------------------------- +pattern = re.compile(REG["namespace_pattern"]) +required = set(REG["required_criterion_fields"]) +seen = set() +for c in REG["criteria"]: + cid = c.get("criterion_id", "") + if not pattern.match(cid or ""): + bad("%r does not match the frozen namespace %s" % (cid, REG["namespace_pattern"])) + continue + if cid in seen: + bad("%s is defined more than once" % cid) + seen.add(cid) + fam = cid.rsplit("-", 1)[0] + if fam not in families: + bad("%s belongs to family %s, which is not declared" % (cid, fam)) + missing = required - set(c) + if missing: + bad("%s is missing required field(s): %s" % (cid, ", ".join(sorted(missing)))) + +# --- 3. a native control is not derived from a framework ----------------------------- +RESTRICTED = re.compile( + r"\b(CIS|ISO[/ ]?IEC|ISO\s*27\d{3}|SCF|HITRUST|COBIT|PCI[- ]?DSS|NIS2|DORA|CCM)\b") +for c in REG["criteria"]: + blob = json.dumps(c) + m = RESTRICTED.search(blob) + if m: + bad("%s names %s in the criterion itself. A native control is authored from Linux " + "behaviour; a framework reference belongs in a mapping, which is a separate " + "layer." % (c.get("criterion_id", ""), m.group(0))) +if REG["criteria"]: + print(" OK %d criteria: namespace, fields and independence hold" % len(seen)) +else: + print(" OK catalog is empty — no native criterion is authored yet (W1-D), and the " + "registry says so rather than implying otherwise") + +# --- 4. no production module named after a framework provider ------------------------ +PROVIDER_NAME = re.compile(r"(^|[_/-])(cis|iso27\d*|iso|scf|hitrust|cobit|pci|nist|ccm)" + r"([_/-]|\.py$)", re.I) +tracked = subprocess.check_output(["git", "ls-files", "lib/"], cwd=str(ROOT), text=True).split() +if not tracked: + bad("no production files found under lib/ — nothing was checked") +else: + offenders = [p for p in tracked if PROVIDER_NAME.search(pathlib.Path(p).name)] + for p in offenders: + bad("%s is named after a framework provider. Collectors describe the host, not a " + "framework: there is no cis_collector.py." % p) + if not offenders: + print(" OK %d production files: none named after a framework provider" + % len(tracked)) + +if FAIL: + print("=== native control catalog gate FAILED ===") + print(" The control is ISEDRAF's, or it is not a native control.") + sys.exit(1) diff --git a/scripts/ci/falsifiable.sh b/scripts/ci/falsifiable.sh index 6501535..459b5d4 100755 --- a/scripts/ci/falsifiable.sh +++ b/scripts/ci/falsifiable.sh @@ -212,6 +212,35 @@ p.write_text(s.replace(old, "echo \"X-Build-Stamp: $(date +%s%N)\" >> \"$DEBROOT PYX' \ 'reproducible build gate FAILED|artifacts identical' +# D-111. The native control catalog is authored first and is ISEDRAF's own. The invariant +# is frozen; these prove the gate enforcing it can refuse. +inject "D-111 a production module is named after a framework provider" \ + 'python3 scripts/ci/check_native_catalog.py' \ + 'cp lib/isedraf/identity.py lib/isedraf/cis_collector.py && git add -f -A' \ + 'named after a framework provider|native control catalog gate FAILED' + +inject "D-111 the namespace and the catalog document disagree" \ + 'python3 scripts/ci/check_native_catalog.py' \ + 'python3 - <<'"'"'PYX'"'"' +import json, pathlib +p = pathlib.Path("scripts/ci/native_controls.json"); d = json.loads(p.read_text()) +d["families"]["ISE-GHOST"] = "a family the catalog document has never heard of" +p.write_text(json.dumps(d, indent=2)) +PYX' \ + 'registry and not in the catalog document|native control catalog gate FAILED' + +inject "D-111 a native criterion is derived from a framework" \ + 'python3 scripts/ci/check_native_catalog.py' \ + 'python3 - <<'"'"'PYX'"'"' +import json, pathlib +p = pathlib.Path("scripts/ci/native_controls.json"); d = json.loads(p.read_text()) +d["criteria"].append({f: "x" for f in d["required_criterion_fields"]}) +d["criteria"][0]["criterion_id"] = "ISE-SSH-001" +d["criteria"][0]["purpose"] = "Implements CIS Controls safeguard 4.1" +p.write_text(json.dumps(d, indent=2)) +PYX' \ + 'names CIS in the criterion itself|native control catalog gate FAILED' + # D-84/D-90. Third-party framework content is not relicensed by sitting in this # repository. The registry is deny-by-default, and "deny by default" is a claim that has # to be shown to deny something. diff --git a/scripts/ci/gate_coverage.json b/scripts/ci/gate_coverage.json index b3d61c7..d097faa 100644 --- a/scripts/ci/gate_coverage.json +++ b/scripts/ci/gate_coverage.json @@ -165,6 +165,15 @@ "REUSE.toml", "scripts/ci/framework_sources.json" ] + }, + "check-native-catalog": { + "script": "scripts/ci/check_native_catalog.py", + "implements": "D-78, D-79, D-84, D-111", + "falsification": "D-111 a production module is named after a framework provider", + "eligible": [ + "scripts/ci/native_controls.json", + "docs/architecture/NATIVE_CONTROL_CATALOG.md" + ] } }, "unpublished_paths": { diff --git a/scripts/ci/native_controls.json b/scripts/ci/native_controls.json new file mode 100644 index 0000000..72ec7cd --- /dev/null +++ b/scripts/ci/native_controls.json @@ -0,0 +1,32 @@ +{ + "$comment": "The ISEDRAF NATIVE CONTROL CATALOG registry. Authority for the ISE-* namespace. Consumed by scripts/ci/check_native_catalog.py, which requires this file and docs/architecture/NATIVE_CONTROL_CATALOG.md to agree - two authorities that can disagree are how a namespace drifts. Criteria are authored in W1-D and later; `criteria` is empty today and that is the accurate state.", + "catalog_version": 1, + "namespace_pattern": "^ISE-[A-Z]+-[0-9]{3}$", + "families": { + "ISE-ASSET": "asset and host inventory", + "ISE-SW": "software and package state", + "ISE-ACCOUNT": "user, service and administrative accounts", + "ISE-AUTH": "authentication", + "ISE-PRIV": "sudo and privilege", + "ISE-SSH": "SSH posture", + "ISE-SVC": "service exposure and state", + "ISE-KERNEL": "sysctl and kernel security", + "ISE-LOG": "audit and logging", + "ISE-TIME": "clock and time synchronization", + "ISE-CRYPTO": "cryptographic posture", + "ISE-STORAGE": "filesystem, storage and mount security", + "ISE-NET": "network configuration", + "ISE-UPDATE": "update and support posture" + }, + "reserved_families": { + "ISE-IDENT": "RESERVED, NOT IN USE. An earlier draft of the namespace used ISE-IDENT-* for accounts. Host IDENTITY (machine-id, host_id) and user ACCOUNTS are different domains, and one prefix meaning both would have been a permanent source of confusion. Accounts are ISE-ACCOUNT-*. ISE-IDENT stays reserved so it cannot be reused for something else and so the earlier draft resolves to an explanation rather than to silence." + }, + "required_criterion_fields": [ + "criterion_id", "purpose", "facts_required", "dimensions", "evaluation_semantics", + "applicability", "limitations", "evidence_pointers", "version" + ], + "optional_criterion_fields": ["remediation_guidance"], + "result_states": ["PASS", "FAIL", "PARTIAL", "NOT_EVALUATED"], + "criteria": [], + "$criteria_comment": "Empty. No native criterion is authored yet; they arrive in W1-D. The gate proves the namespace is consistent and that nothing framework-derived has entered it - it does not treat an empty catalog as a pass by omission." +} diff --git a/scripts/ci/project_status.json b/scripts/ci/project_status.json index 7dc68fe..c4db9b6 100644 --- a/scripts/ci/project_status.json +++ b/scripts/ci/project_status.json @@ -174,6 +174,11 @@ "framework_specific_reports": { "status": "NOT_TESTED", "note": "not available: framework-specific output does not exist, and core reports are complete without it" + }, + "native_control_catalog": { + "status": "PLANNED", + "design": "docs/architecture/NATIVE_CONTROL_CATALOG.md", + "note": "D-111 freezes the invariant and the ISE-* namespace: 14 families, ISE-IDENT reserved and not in use. NO native criterion is authored yet - they arrive in W1-D. make check-native-catalog enforces that the registry and the catalog document agree, that criteria hold to the namespace, that no criterion is derived from a framework, and that no production module is named after a provider" } }, "runtime": {