From 760627713d1572e2fe9c3fb401f8f34e9462d940 Mon Sep 17 00:00:00 2001 From: anthonyfuller <38588965+anthfuller@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:35:06 -0400 Subject: [PATCH 1/2] feat: complete release-readiness corrections --- .github/CODEOWNERS | 26 ++ .github/workflows/f7las-ci.yml | 13 + README.md | 18 +- RELEASE_NOTES.md | 57 +++ ROADMAP.md | 4 +- VERSION | 1 + config/control-traceability.json | 426 +++++++++++++++++++++ docs/F7-LAS-QA.md | 12 +- docs/README.md | 8 +- docs/clean-user-acceptance.md | 2 +- docs/f7-las-implementation-guide/README.md | 2 +- docs/release-process.md | 46 +++ docs/supply-chain-and-ci.md | 15 + examples/canonical-workflow/README.md | 5 + scripts/run-clean-user-acceptance.sh | 2 + scripts/validate-citation.py | 113 ++++++ scripts/validate-control-traceability.py | 195 ++++++++++ scripts/validate-documentation.py | 36 ++ scripts/validate-supply-chain.py | 20 +- src/agents/placeholder | 1 - src/canonical/cli.py | 21 +- src/core/placeholder | 1 - src/tools/placeholder | 1 - tests/test_canonical_workflow.py | 111 ++++++ tests/test_citation.py | 51 +++ tests/test_control_traceability.py | 90 +++++ tests/test_supply_chain.py | 34 ++ 27 files changed, 1288 insertions(+), 23 deletions(-) create mode 100644 RELEASE_NOTES.md create mode 100644 VERSION create mode 100644 config/control-traceability.json create mode 100644 docs/release-process.md create mode 100644 scripts/validate-citation.py create mode 100644 scripts/validate-control-traceability.py delete mode 100644 src/agents/placeholder delete mode 100644 src/core/placeholder delete mode 100644 src/tools/placeholder create mode 100644 tests/test_citation.py create mode 100644 tests/test_control_traceability.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2c5545e..23facf5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,29 @@ +# Default ownership +* @anthfuller + +# Canonical contracts, policy, workflow, and evidence +/src/canonical/ @anthfuller +/schemas/contracts/ @anthfuller +/config/policies/ @anthfuller +/examples/canonical-workflow/ @anthfuller +/tests/test_canonical_workflow.py @anthfuller +/tests/test_contracts.py @anthfuller +/tests/test_evidence_replay.py @anthfuller +/tests/test_behavioral_scenarios.py @anthfuller + +# Licensing, citation, diagrams, traceability, and release metadata +/LICENSE* @anthfuller +/CITATION.cff @anthfuller +/docs/images/ @anthfuller +/docs/architecture-diagrams.md @anthfuller +/config/control-traceability.json @anthfuller +/scripts/validate-control-traceability.py @anthfuller +/scripts/validate-citation.py @anthfuller +/VERSION @anthfuller +/RELEASE_NOTES.md @anthfuller +/docs/release-process.md @anthfuller +/ROADMAP.md @anthfuller + # Golden dataset & CI guardrails /tests/golden_dataset/ @anthfuller /src/demo_runner/run_golden_dataset.py @anthfuller diff --git a/.github/workflows/f7las-ci.yml b/.github/workflows/f7las-ci.yml index 3898c2e..dbe4570 100644 --- a/.github/workflows/f7las-ci.yml +++ b/.github/workflows/f7las-ci.yml @@ -4,6 +4,8 @@ name: F7-LAS CI push: branches: - main + tags: + - 'v*' pull_request: branches: - main @@ -38,6 +40,17 @@ jobs: - name: Validate documentation run: python scripts/validate-documentation.py + - name: Validate citation metadata + run: python scripts/validate-citation.py + + - name: Validate CITATION.cff against the CFF schema + uses: citation-file-format/cffconvert-github-action@4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084 # 2.0.0 + with: + args: "--validate" + + - name: Validate control traceability + run: python scripts/validate-control-traceability.py + - name: Audit dependencies and generate CycloneDX SBOM run: | mkdir -p "$RUNNER_TEMP/f7las-sbom" diff --git a/README.md b/README.md index 8fc233b..e3ada83 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ > **Current status:** Established reference model with a **prototype reference implementation**. The repository is not a production agent platform, control plane, SOC product, SIEM/SOAR replacement, or production-ready implementation. +Repository version **4.0.0** is prepared as an unpublished release candidate. +No tag, GitHub release, or Zenodo update is implied by the version file. + F7-LAS™ is Anthony Fuller's vendor-neutral, protocol-agnostic security model for designing, reviewing, and governing agentic AI systems. It separates security responsibilities across seven layers so that generative output is not treated as authority to act. This is independent personal work. It is not affiliated with, endorsed by, or representative of Microsoft or any other employer. @@ -28,12 +31,14 @@ Software supply-chain security is a cross-cutting supplemental domain, **Layer S - The immutable [F7-LAS whitepaper v3.0](docs/F7-LAS-model-whitepaper_v3.0.pdf) - A draft [implementation guide](docs/f7-las-implementation-guide/README.md) -- A draft [46-control catalog](docs/F7-LAS-Control-Catalog-v0.1.md) +- A draft catalog of [46 core Layers 1–7 controls plus five supplemental Layer S controls](docs/F7-LAS-Control-Catalog-v0.1.md) - Current [architecture diagrams](docs/architecture-diagrams.md), with explicit implementation boundaries, and engineering review material - Canonical v1.0.0 data contracts and one synthetic, offline Python + OPA workflow - Illustrative prompts, additional policies, validators, and runtime stubs - Supply-chain CI, behavioral tests, evidence verification, deterministic replay, and a clean-user acceptance gate for the canonical path +- Machine-readable [control-to-evidence traceability](config/control-traceability.json) + covering all 51 catalog controls The canonical workflow provides one deliberately constrained executable Layers 1–7 path. It does not make the other examples executable or production-ready. The behavioral scenario matrix exercises the canonical enforcement path; the separate golden-dataset runner validates scenario structure and does not prove the described allow/deny behavior. Non-canonical examples remain illustrative unless they are explicitly reclassified and tested. @@ -63,6 +68,8 @@ source .venv/bin/activate python -m pip install --require-hashes -r requirements-ci.lock python scripts/validate-supply-chain.py python scripts/validate-documentation.py +python scripts/validate-citation.py +python scripts/validate-control-traceability.py python scripts/validate-prompts.py python scripts/validate-policies.py python scripts/allowlist-validator.py @@ -87,6 +94,9 @@ These commands validate the **current prototype and repository structure**. They - [Supply-chain and CI controls](docs/supply-chain-and-ci.md) - [Clean-user acceptance](docs/clean-user-acceptance.md) - [Architecture diagrams](docs/architecture-diagrams.md) +- [Control-to-evidence traceability](config/control-traceability.json) +- [4.0.0 release-candidate notes](RELEASE_NOTES.md) +- [Release and exact-tag SBOM procedure](docs/release-process.md) - [Roadmap](ROADMAP.md) - [Security policy](SECURITY.md) @@ -98,10 +108,10 @@ Versions belong to individual artifacts: |---|---| | Seven-layer model | Established design baseline | | Whitepaper | v3.0, immutable historical artifact | -| Implementation guide | Draft; version will be assigned at reviewed release | +| Implementation guide | Draft; bundled with 4.0.0 but not independently versioned | | Control catalog | v0.1 draft | -| Executable reference implementation | Prototype | -| Repository overhaul target | 4.0.0 after all acceptance gates and approval | +| Executable reference implementation | Prototype included in the 4.0.0 candidate | +| Repository release candidate | 4.0.0; not tagged or published | Repository version numbers do not silently change the whitepaper, control catalog, or schema versions. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..2113762 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,57 @@ +# F7-LAS 4.0.0 Release Candidate + +**Status:** Prepared but not tagged or published. This document does not +authorize a GitHub release, Zenodo update, or change to the historical +whitepaper. + +F7-LAS 4.0.0 is the intended repository release for the evidence-driven +overhaul. It packages the established seven-layer reference model with one +bounded, deterministic, synthetic Python + OPA reference workflow. It remains +a prototype reference implementation, not a production agent platform. + +## Release contents + +- Canonical v1.0.0 contracts for request-through-audit records. +- One offline, fail-closed Python + OPA Layers 1–7 path. +- Approval binding to request, action, scope, authority, time window, and the + exact executable policy bundle. +- Behavioral scenarios, evidence integrity verification, and deterministic + replay. +- Hash-locked dependencies, immutable Action references, verified OPA and + Gitleaks downloads, vulnerability scanning, full-history secret scanning, + and a retained CycloneDX SBOM. +- Clean-user acceptance using Python 3.12.14 and OPA 1.20.2. +- Machine-readable traceability for all 46 core Layers 1–7 controls and five + supplemental Layer S controls. +- Corrected executive and layer-specific control-loop diagrams. + +## Explicit limitations + +- The supported executor is synthetic and in-process. It is not an OS or + container sandbox and does not enforce network isolation. +- No production identity, cloud, SIEM, SOAR, XDR, remediation, or human + approval integration is provided. +- The golden-dataset runner validates scenario and rubric structure; the + behavioral matrix separately exercises canonical enforcement paths. +- Evidence is digest-bound but not digitally signed, externally timestamped, + or backed by a provenance service. +- The control catalog and implementation guide remain draft authored content; + repository version 4.0.0 does not silently revise their independent versions + or the immutable whitepaper v3.0. + +## Required publication gate + +Publication requires all of the following after the release-candidate branch +is independently reviewed: + +1. merge the exact approved tree through a protected pull request; +2. verify the push-triggered `main` workflow against the resulting merge SHA; +3. create `v4.0.0` at that exact approved `main` SHA without additional files; +4. verify the tag-triggered workflow and its complete validation job; +5. download the CycloneDX artifact produced by that tag run, verify its run, + commit, name, and digest, and attach it unchanged to the GitHub release; +6. publish these notes only after the release assets and target SHA are + independently verified. + +The detailed procedure is in +[`docs/release-process.md`](docs/release-process.md). diff --git a/ROADMAP.md b/ROADMAP.md index be26541..fd78e82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,8 @@ The target is an **Executable Reference Implementation**: one offline, determini ## Release boundary -The intended repository release after all acceptance gates is **4.0.0**. The whitepaper remains **v3.0** until it is separately reviewed and revised. No milestone authorizes a merge to `main`, a release, or a Zenodo update without explicit approval. +Repository version **4.0.0** is prepared as an unpublished release candidate. The whitepaper remains **v3.0** until it is separately reviewed and revised. No milestone authorizes a merge to `main`, a tag, a release, or a Zenodo update without explicit approval. See [RELEASE_NOTES.md](RELEASE_NOTES.md) and the [release process](docs/release-process.md). ## Completion standard -Completion requires a clean-user install and walkthrough, meaningful behavior tests, control-to-evidence traceability, accurate diagrams, consistent licensing/versioning, independent review, and explicit merge approval. Test counts alone are not a maturity measure. +Completion requires a clean-user install and walkthrough, meaningful behavior tests, validated control-to-evidence traceability, accurate diagrams, consistent licensing/versioning, protected-branch enforcement, independent review, and explicit merge and release approval. Test counts alone are not a maturity measure. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..fcdb2e1 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +4.0.0 diff --git a/config/control-traceability.json b/config/control-traceability.json new file mode 100644 index 0000000..98aa693 --- /dev/null +++ b/config/control-traceability.json @@ -0,0 +1,426 @@ +{ + "schema_version": "1.0.0", + "catalog": { + "path": "docs/F7-LAS-Control-Catalog-v0.1.md", + "version": "0.1" + }, + "scope": "Repository implementation status for the bounded synthetic F7-LAS reference workflow; this is not an assessment of any production deployment.", + "summary": { + "total": 51, + "core_layers_1_7": 46, + "supplemental_layer_s": 5, + "implemented": 6, + "partial": 29, + "not_implemented": 16 + }, + "controls": [ + { + "control_id": "F7-L1-01", + "domain": "L1", + "title": "Prompt Version Control", + "status": "partial", + "limitations": "Prompt files are versioned in Git, but the repository is not a production prompt store and branch protection is a separate repository setting.", + "evidence": [{"kind": "configuration", "path": "config/prompts/system-prompt-v1.txt", "locator": "PSP-Version:"}] + }, + { + "control_id": "F7-L1-02", + "domain": "L1", + "title": "Role & Scope Definition", + "status": "partial", + "limitations": "Structured role and scope placeholders exist, while the canonical workflow admits only one fixed synthetic actor and scope.", + "evidence": [{"kind": "configuration", "path": "config/prompts/system-prompt-v1.txt", "locator": "[ROLE]"}] + }, + { + "control_id": "F7-L1-03", + "domain": "L1", + "title": "Prohibited Actions", + "status": "partial", + "limitations": "Prompt prohibitions are present as soft-policy templates; hard enforcement is demonstrated only for the fixed canonical action.", + "evidence": [{"kind": "configuration", "path": "config/prompts/system-prompt-v1.txt", "locator": "[PROHIBITED ACTIONS]"}] + }, + { + "control_id": "F7-L1-04", + "domain": "L1", + "title": "Abstain & Escalate Behavior", + "status": "partial", + "limitations": "Escalation rules are represented in prompt templates, but no interactive agent or human escalation service is implemented.", + "evidence": [{"kind": "configuration", "path": "config/prompts/system-prompt-v1.txt", "locator": "[ESCALATION RULES]"}] + }, + { + "control_id": "F7-L1-05", + "domain": "L1", + "title": "Prompt Change Control", + "status": "partial", + "limitations": "CI validates prompt structure, but production approval records and enforced repository protection remain outside the codebase.", + "evidence": [{"kind": "workflow", "path": ".github/workflows/f7las-ci.yml", "locator": "Validate prompts"}] + }, + { + "control_id": "F7-L1-06", + "domain": "L1", + "title": "Prompt Security Testing", + "status": "not_implemented", + "limitations": "The repository has structural prompt validation but no periodic prompt-injection or jailbreak test program and findings workflow.", + "evidence": [{"kind": "documentation", "path": "examples/README.md", "locator": "illustrative prototype demonstrations"}] + }, + { + "control_id": "F7-L2-01", + "domain": "L2", + "title": "RAG Source Inventory", + "status": "partial", + "limitations": "An illustrative source allowlist exists without owners, classifications, lifecycle governance, or a deployed retrieval inventory.", + "evidence": [{"kind": "configuration", "path": "examples/layer2-grounding/allowlist.json", "locator": "allowed_sources"}] + }, + { + "control_id": "F7-L2-02", + "domain": "L2", + "title": "Access-Controlled RAG Gateway", + "status": "not_implemented", + "limitations": "No authenticated retrieval gateway, rate limiting, deployed source authorization, or retrieval-call logging is provided.", + "evidence": [{"kind": "documentation", "path": "examples/README.md", "locator": "not production implementations"}] + }, + { + "control_id": "F7-L2-03", + "domain": "L2", + "title": "Curation & Retirement Process", + "status": "not_implemented", + "limitations": "No operational content-owner approval, scheduled review, update, or retirement workflow is implemented.", + "evidence": [{"kind": "documentation", "path": "docs/F7-LAS-QA.md", "locator": "not a production agent platform"}] + }, + { + "control_id": "F7-L2-04", + "domain": "L2", + "title": "Poisoning Defenses", + "status": "not_implemented", + "limitations": "No deployed ingestion pipeline, restricted authorship mechanism, poisoning detector, or high-impact content review is implemented.", + "evidence": [{"kind": "documentation", "path": "docs/F7-LAS-QA.md", "locator": "not a production agent platform"}] + }, + { + "control_id": "F7-L2-05", + "domain": "L2", + "title": "Treat Retrieved Content as Untrusted", + "status": "partial", + "limitations": "The canonical path records fixed synthetic evidence and never executes retrieved text, but it does not implement a live RAG pipeline.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "synthetic-inventory"}] + }, + { + "control_id": "F7-L2-06", + "domain": "L2", + "title": "RAG Injection Testing", + "status": "not_implemented", + "limitations": "There is no adversarial retrieval index or automated retrieved-content injection evaluation in the supported workflow.", + "evidence": [{"kind": "documentation", "path": "examples/README.md", "locator": "illustrative prototype demonstrations"}] + }, + { + "control_id": "F7-L3-01", + "domain": "L3", + "title": "Planner Identification", + "status": "partial", + "limitations": "The canonical deterministic planner is explicitly identified, but the illustrative planners are not a deployed orchestration architecture.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "deterministic-planner"}] + }, + { + "control_id": "F7-L3-02", + "domain": "L3", + "title": "Planning Contracts", + "status": "partial", + "limitations": "The canonical plan has fixed action, step, and duration limits, but it is not a general per-agent contract with a configurable allowed-tool set.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "\"limits\": {\"max_steps\""}] + }, + { + "control_id": "F7-L3-03", + "domain": "L3", + "title": "Bounded Reason–Act Loops", + "status": "partial", + "limitations": "The canonical path is structurally fixed to one step and one action; it does not implement a reusable loop counter, recursion limit, or runtime wall-clock controller.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "\"max_actions\": 1"}] + }, + { + "control_id": "F7-L3-04", + "domain": "L3", + "title": "Explicit Stop Conditions", + "status": "partial", + "limitations": "Permit, denial, refusal, failure, and recovery outcomes are tested, but no general planner state machine records all catalog stop conditions.", + "evidence": [{"kind": "test", "path": "tests/behavioral_scenarios.json", "locator": "recovery"}] + }, + { + "control_id": "F7-L3-05", + "domain": "L3", + "title": "Delegation Rules for Multi-Agent", + "status": "not_implemented", + "limitations": "The supported canonical workflow uses one fixed synthetic actor and contains no multi-agent delegation runtime or enforced call graph.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "single canonical executable path"}] + }, + { + "control_id": "F7-L3-06", + "domain": "L3", + "title": "Planner Telemetry", + "status": "partial", + "limitations": "Plan and audit records preserve the deterministic planner output, but there is no production telemetry sink, dashboard, or retention control.", + "evidence": [{"kind": "schema", "path": "schemas/contracts/f7las-records-v1.schema.json", "locator": "\"plan\""}] + }, + { + "control_id": "F7-L3-07", + "domain": "L3", + "title": "Goal/Role Drift Testing", + "status": "not_implemented", + "limitations": "The behavioral matrix covers fixed admission and authorization failures but does not provide a goal- or role-drift evaluation suite.", + "evidence": [{"kind": "documentation", "path": "docs/F7-LAS-QA.md", "locator": "golden-dataset evaluator"}] + }, + { + "control_id": "F7-L4-01", + "domain": "L4", + "title": "Tool Catalog", + "status": "partial", + "limitations": "A validated illustrative allowlist records owners and risk tiers, while the canonical runtime registers only one fixed synthetic tool.", + "evidence": [{"kind": "configuration", "path": "config/tools/allowlist.json", "locator": "\"tools\""}] + }, + { + "control_id": "F7-L4-02", + "domain": "L4", + "title": "Tool Risk Tiering", + "status": "partial", + "limitations": "Risk tiers are present in the illustrative allowlist and canonical action, but no general runtime routes all tools according to tier.", + "evidence": [{"kind": "configuration", "path": "config/tools/allowlist.json", "locator": "risk_tier"}] + }, + { + "control_id": "F7-L4-03", + "domain": "L4", + "title": "Read/Write Separation", + "status": "partial", + "limitations": "The only canonical operation is a synthetic read, but distinct read and write identities or execution environments are not implemented.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "EXPECTED_OPERATION = \"workspace-health\""}] + }, + { + "control_id": "F7-L4-04", + "domain": "L4", + "title": "Least-Privilege Tool Permissions", + "status": "not_implemented", + "limitations": "The offline synthetic executor has no external credential or IAM role, so real least-privilege permissions are not provisioned or reviewed.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "does not call an LLM"}] + }, + { + "control_id": "F7-L4-05", + "domain": "L4", + "title": "Agent-to-Tool Access Control", + "status": "partial", + "limitations": "The canonical policy binds one actor to one exact tool action, but there is no configurable multi-agent tool entitlement service.", + "evidence": [{"kind": "configuration", "path": "config/policies/canonical-workflow.rego", "locator": "input.action.actor_id == input.actor.subject_id"}] + }, + { + "control_id": "F7-L4-06", + "domain": "L4", + "title": "Tool Argument Validation", + "status": "implemented", + "limitations": "Implemented for the single canonical synthetic action only; it is not a generic validation service for illustrative tools.", + "evidence": [{"kind": "test", "path": "tests/test_canonical_workflow.py", "locator": "test_real_opa_policy_binds_arguments_and_action_digest"}] + }, + { + "control_id": "F7-L4-07", + "domain": "L4", + "title": "Tool Call Telemetry", + "status": "partial", + "limitations": "Canonical proposed-action, result, and audit records are correlated, but no deployed telemetry pipeline or sanitization service exists.", + "evidence": [{"kind": "schema", "path": "schemas/contracts/f7las-records-v1.schema.json", "locator": "proposed_action"}] + }, + { + "control_id": "F7-L4-08", + "domain": "L4", + "title": "HITL for Tier-3 Tools", + "status": "partial", + "limitations": "The allowlist represents a high-risk tool requiring HITL and the canonical path enforces synthetic approval, but no Tier-3 tool or live human workflow executes.", + "evidence": [{"kind": "configuration", "path": "config/tools/allowlist.json", "locator": "\"requires_hitl\": true"}] + }, + { + "control_id": "F7-L5-01", + "domain": "L5", + "title": "External PDP/PEP Architecture", + "status": "implemented", + "limitations": "Implemented for one offline OPA decision and in-process PEP path; no network PDP service or production tool gateway is claimed.", + "evidence": [{"kind": "test", "path": "tests/test_canonical_workflow.py", "locator": "test_real_opa_path_is_deterministic_permitted_and_contract_valid"}] + }, + { + "control_id": "F7-L5-02", + "domain": "L5", + "title": "Policy-as-Code", + "status": "implemented", + "limitations": "Implemented for the canonical Rego and reviewed policy metadata; other policy-engine examples remain illustrative.", + "evidence": [{"kind": "configuration", "path": "config/policies/canonical-workflow.rego", "locator": "package f7las.canonical"}] + }, + { + "control_id": "F7-L5-03", + "domain": "L5", + "title": "Decision Outcomes", + "status": "partial", + "limitations": "The canonical PDP emits permit or deny with obligations; clarify and human referral are architecture outcomes rather than executable PDP results.", + "evidence": [{"kind": "implementation", "path": "src/canonical/opa.py", "locator": "result[\"decision\"] not in {\"permit\", \"deny\"}"}] + }, + { + "control_id": "F7-L5-04", + "domain": "L5", + "title": "Tiered Policy Enforcement", + "status": "partial", + "limitations": "One fixed action always passes through OPA, but general Tier-2 and Tier-3 routing and differentiated obligations are not implemented.", + "evidence": [{"kind": "configuration", "path": "config/policies/canonical-workflow.rego", "locator": "input.action.risk_tier == \"low\""}] + }, + { + "control_id": "F7-L5-05", + "domain": "L5", + "title": "Policy Change Management", + "status": "partial", + "limitations": "CI validates policy changes, while production deployment approval and enforced branch review are repository-administration responsibilities.", + "evidence": [{"kind": "workflow", "path": ".github/workflows/f7las-ci.yml", "locator": "Validate policies"}] + }, + { + "control_id": "F7-L5-06", + "domain": "L5", + "title": "PDP/PEP Telemetry", + "status": "partial", + "limitations": "Policy decisions and execution results are correlated in local evidence, but no centralized production audit or incident-response ingestion exists.", + "evidence": [{"kind": "schema", "path": "schemas/contracts/f7las-records-v1.schema.json", "locator": "policy_decision"}] + }, + { + "control_id": "F7-L6-01", + "domain": "L6", + "title": "Segmented Runtime Environment", + "status": "not_implemented", + "limitations": "Layer 6 is a synthetic in-process executor and provides no separate tenant, account, VM, container, or production segmentation boundary.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "Layer 6 here is not"}] + }, + { + "control_id": "F7-L6-02", + "domain": "L6", + "title": "Network Egress Controls", + "status": "not_implemented", + "limitations": "The canonical action makes no network call, but no firewall, VNET, VPC, proxy, or enforced egress allowlist is deployed.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "does not enforce a network-isolation boundary"}] + }, + { + "control_id": "F7-L6-03", + "domain": "L6", + "title": "Environment Separation (Read vs Remediate)", + "status": "not_implemented", + "limitations": "Only a read-only synthetic action exists; there is no separate write-capable remediation environment or identity boundary.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "single canonical executable path"}] + }, + { + "control_id": "F7-L6-04", + "domain": "L6", + "title": "Identity Per Agent / Sandbox", + "status": "not_implemented", + "limitations": "Synthetic subject identifiers are evidence fields, not provisioned identities, credentials, or sandbox-specific roles.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "identity proofing system"}] + }, + { + "control_id": "F7-L6-05", + "domain": "L6", + "title": "Sandbox Profile Definitions", + "status": "partial", + "limitations": "A profile configuration and deterministic profile digest exist, but OS, resource, filesystem, and network restrictions are not enforced by the in-process executor.", + "evidence": [{"kind": "configuration", "path": "config/policies/sandbox-profile.yaml", "locator": "egress_controls"}] + }, + { + "control_id": "F7-L6-06", + "domain": "L6", + "title": "Ephemeral Sandboxes", + "status": "not_implemented", + "limitations": "No sandbox is created or destroyed; execution occurs in the current Python process using synthetic data.", + "evidence": [{"kind": "documentation", "path": "examples/canonical-workflow/README.md", "locator": "synthetic in-process executor"}] + }, + { + "control_id": "F7-L6-07", + "domain": "L6", + "title": "Blast Radius Statement", + "status": "partial", + "limitations": "The canonical boundary documents no network or external side effects, but there is no deployment-specific risk acceptance or owner sign-off.", + "evidence": [{"kind": "implementation", "path": "src/canonical/workflow.py", "locator": "\"side_effects\": []"}] + }, + { + "control_id": "F7-L7-01", + "domain": "L7", + "title": "Unified Telemetry Schema", + "status": "partial", + "limitations": "Eight canonical record schemas correlate the supported workflow, but they do not cover every production event type listed by the catalog.", + "evidence": [{"kind": "schema", "path": "schemas/contracts/README.md", "locator": "defines eight record types"}] + }, + { + "control_id": "F7-L7-02", + "domain": "L7", + "title": "Central Telemetry Ingestion", + "status": "not_implemented", + "limitations": "Evidence is written to a local JSON file and is not sent to a SIEM, XDR, log pipeline, or centralized monitoring service.", + "evidence": [{"kind": "documentation", "path": "examples/layer7-monitoring/README.md", "locator": "not a full observability pipeline"}] + }, + { + "control_id": "F7-L7-03", + "domain": "L7", + "title": "Detection Rules for Agent Misuse", + "status": "not_implemented", + "limitations": "No deployable misuse detections, alert rules, correlated incident logic, or alert-history evidence is included.", + "evidence": [{"kind": "documentation", "path": "examples/layer7-monitoring/README.md", "locator": "A production implementation would require"}] + }, + { + "control_id": "F7-L7-04", + "domain": "L7", + "title": "Golden Dataset Evaluation", + "status": "partial", + "limitations": "CI enforces structural golden thresholds, but the runner does not execute the scenarios against the canonical workflow or score behavioral outcomes.", + "evidence": [{"kind": "implementation", "path": "src/demo_runner/run_golden_dataset.py", "locator": "structural-v1"}] + }, + { + "control_id": "F7-L7-05", + "domain": "L7", + "title": "Red-Team & Adversarial Testing", + "status": "partial", + "limitations": "Negative behavioral tests cover authorization and tampering paths, but there is no periodic prompt, RAG, tool, or sandbox red-team exercise.", + "evidence": [{"kind": "test", "path": "tests/behavioral_scenarios.json", "locator": "tampered"}] + }, + { + "control_id": "F7-L7-06", + "domain": "L7", + "title": "SLOs for Agent Safety", + "status": "not_implemented", + "limitations": "Draft SLO examples exist without measured service telemetry, owners, alert thresholds, review records, or an operating service.", + "evidence": [{"kind": "documentation", "path": "docs/f7-las-implementation-guide/05-metrics-and-slos.md", "locator": "Key SLOs"}] + }, + { + "control_id": "F7-LS-01", + "domain": "LS", + "title": "SBOM Generation", + "status": "implemented", + "limitations": "CI generates a CycloneDX inventory for the locked Python graph; it does not inventory the full runner or establish signed provenance.", + "evidence": [{"kind": "workflow", "path": ".github/workflows/f7las-ci.yml", "locator": "Upload CycloneDX SBOM"}] + }, + { + "control_id": "F7-LS-02", + "domain": "LS", + "title": "Dependency Vulnerability Scanning", + "status": "implemented", + "limitations": "The locked Python graph is audited for published vulnerabilities; runner OS and non-Python components are outside this scan.", + "evidence": [{"kind": "workflow", "path": ".github/workflows/f7las-ci.yml", "locator": "Audit dependencies and generate CycloneDX SBOM"}] + }, + { + "control_id": "F7-LS-03", + "domain": "LS", + "title": "Framework Version Pinning", + "status": "implemented", + "limitations": "Python dependencies, Actions, OPA, and Gitleaks are pinned for CI; this does not attest the hosted runner image.", + "evidence": [{"kind": "configuration", "path": "requirements-ci.lock", "locator": "--hash=sha256:"}] + }, + { + "control_id": "F7-LS-04", + "domain": "LS", + "title": "Plugin & Tool Framework Governance", + "status": "partial", + "limitations": "The illustrative tool allowlist contains owners and review metadata, but no production plugin admission workflow or enforced approval record exists.", + "evidence": [{"kind": "configuration", "path": "config/tools/allowlist.json", "locator": "review_metadata"}] + }, + { + "control_id": "F7-LS-05", + "domain": "LS", + "title": "Framework Telemetry & Attestation", + "status": "partial", + "limitations": "CI associates an SBOM artifact with an exact commit SHA, but runtime telemetry lacks framework version and signed build-attestation identifiers.", + "evidence": [{"kind": "workflow", "path": ".github/workflows/f7las-ci.yml", "locator": "f7las-python-sbom-${{ github.sha }}"}] + } + ] +} diff --git a/docs/F7-LAS-QA.md b/docs/F7-LAS-QA.md index 0c6018f..3379b08 100644 --- a/docs/F7-LAS-QA.md +++ b/docs/F7-LAS-QA.md @@ -18,8 +18,12 @@ scenario and rubric structure; it does not execute or prove the stated security behavior. The canonical path has automated behavioral checks, evidence verification, -deterministic replay, and a clean-user acceptance gate. Repository-wide control -status is not yet machine-readable or evidence-linked. +deterministic replay, and a clean-user acceptance gate. The +[machine-readable traceability file](../config/control-traceability.json) covers +all 46 core Layers 1–7 controls plus five supplemental Layer S controls and +records implemented, partial, or not-implemented status, limitations, and +verifiable repository evidence locators. Its status is scoped to this bounded +repository implementation, not to a production deployment. ## Is the code production-ready? @@ -116,8 +120,8 @@ Use it as a design-review lens, threat-modeling aid, draft control catalog, and - Whitepaper: v3.0, immutable historical artifact. - Control catalog: v0.1 draft. -- Implementation guide: draft; version assigned at reviewed release. -- Reference code: prototype; intended to follow the future repository 4.0.0 release. +- Implementation guide: draft; bundled with repository 4.0.0 but not independently versioned. +- Reference code: prototype prepared as repository release candidate 4.0.0; not yet tagged or published. - Individual schemas: independently versioned. Repository release numbers do not silently revise the whitepaper or other artifacts. diff --git a/docs/README.md b/docs/README.md index bae4500..3660e2d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,8 +7,8 @@ This directory contains the governed documentation for the F7-LAS seven-layer re - [Whitepaper v3.0](F7-LAS-model-whitepaper_v3.0.pdf) — immutable historical publication. - [Whitepaper artifact record](whitepaper-v3.0-artifact.md) — integrity identifiers and handling rule. - [Whitepaper v3.0 errata](corrections/whitepaper-v3.0-errata.md) — corrections proposed for a future reviewed edition; does not alter the PDF. -- [Implementation guide](f7-las-implementation-guide/README.md) — draft engineering guidance. -- [Control catalog v0.1](F7-LAS-Control-Catalog-v0.1.md) — 46 draft controls across Layers 1–7. +- [Implementation guide](f7-las-implementation-guide/README.md) — draft engineering guidance bundled with the repository release candidate. +- [Control catalog v0.1](F7-LAS-Control-Catalog-v0.1.md) — 46 core Layers 1–7 controls plus five supplemental Layer S controls; all remain draft controls in this repository. - [Engineering review checklist](Engineering-Review-Checklist.md) — design-review aid. - [QA and maturity](F7-LAS-QA.md) — current repository truth and limitations. - [Canonical data contracts v1.0.0](../schemas/contracts/README.md) — machine-validated request-through-audit definitions used by the canonical executable path. @@ -16,6 +16,8 @@ This directory contains the governed documentation for the F7-LAS seven-layer re - [Supply-chain and CI controls](supply-chain-and-ci.md) — implemented dependency, action, download, vulnerability, secret, and SBOM checks and their limits. - [Clean-user acceptance](clean-user-acceptance.md) — the exact supported environment, complete walkthrough, command inventory, and assurance boundary. - [Architecture diagrams](architecture-diagrams.md) — executive and layer-specific F7-LAS control-loop views, with an explicit implementation mapping and assurance boundary. +- [Control-to-evidence traceability](../config/control-traceability.json) — machine-readable status, limitations, and evidence locators for all 51 controls. +- [Release process](release-process.md) — independent approval, exact-tag validation, and release-SBOM attachment procedure. ## Architecture graphics @@ -27,6 +29,6 @@ production readiness or control effectiveness. ## Status and use -The model is established; the repository implementation is a prototype. Documentation may specify desired controls that are not implemented. Treat only the canonical behavior covered by tests and correlated evidence as verified; control-to-evidence traceability remains a separate release gate. +The model is established; the repository implementation is a prototype. Documentation may specify desired controls that are not implemented. Treat only the canonical behavior covered by tests and correlated evidence as verified. The machine-readable traceability file explicitly distinguishes implemented, partial, and not-implemented controls within this repository boundary. See the root [license map](../LICENSE.md) and [security policy](../SECURITY.md). diff --git a/docs/clean-user-acceptance.md b/docs/clean-user-acceptance.md index 4aea81b..238ea78 100644 --- a/docs/clean-user-acceptance.md +++ b/docs/clean-user-acceptance.md @@ -5,7 +5,7 @@ reference implementation. It starts from repository files without Git metadata, creates a new virtual environment, installs only the reviewed hash-locked graph, and runs the documented validators, tests, canonical walkthrough, evidence verification, deterministic replay, golden structural evaluation, vulnerability -audit, and SBOM generation. +audit, CFF validation, complete control traceability, and SBOM generation. ## Exact prerequisites diff --git a/docs/f7-las-implementation-guide/README.md b/docs/f7-las-implementation-guide/README.md index df4e76b..7dd1c1d 100644 --- a/docs/f7-las-implementation-guide/README.md +++ b/docs/f7-las-implementation-guide/README.md @@ -2,7 +2,7 @@ This guide translates the seven-layer model into engineering guidance. It describes desired controls and patterns; it does **not** claim that the current repository implements or automatically verifies every requirement. -The guide remains draft until it is reconciled with the canonical offline Python + OPA workflow, control-to-evidence traceability, clean-user validation, and independent review. Its release version will be assigned then. +The guide remains draft authored content bundled with repository release candidate 4.0.0. It is reconciled with the canonical offline Python + OPA workflow and machine-readable control traceability, but it is not independently versioned or represented as fully implemented guidance. ## Contents diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..dd4a39e --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,46 @@ +# F7-LAS 4.0.0 release process + +This procedure prepares a reproducible repository release without changing the +historical whitepaper or implying production readiness. It is documentation, +not authorization to publish a tag, GitHub release, or Zenodo record. + +## Preconditions + +- `VERSION` and `RELEASE_NOTES.md` identify `4.0.0` as the reviewed candidate. +- `main` requires pull requests and the passing `validate` status check and + blocks force pushes and deletion. +- The candidate branch and complete diff have independent approval. +- Pull-request CI and the push-triggered post-merge `main` run pass against the + exact reviewed tree. +- The whitepaper and canonical diagram hashes match their documented values. + +## Exact tag and SBOM binding + +1. Record the approved `main` commit and tree SHAs. +2. Create the annotated tag `v4.0.0` at that exact commit. Do not rebuild or + amend the release candidate between approval and tagging. +3. Push only the tag. The CI workflow's `v*` trigger runs the same validation + job and generates `f7las-python-sbom-`. +4. Confirm the tag-triggered run has `head_sha` equal to the approved commit and + that every job step passed. +5. Download the CycloneDX JSON artifact from that run. Record the GitHub + artifact digest and independently calculate the extracted JSON SHA-256. +6. Create the GitHub release from the existing `v4.0.0` tag and attach the + unchanged `f7las-python.cdx.json` plus a checksum file identifying the tag, + commit SHA, artifact digest, and JSON digest. +7. Verify the published release target, assets, hashes, and availability before + announcing completion. + +The tag-triggered artifact is authoritative for the release. A pull-request or +earlier `main` SBOM must not be relabeled as the release SBOM, even if its +dependency content appears identical. + +## Rollback and immutability + +If the tag points to the wrong commit, CI fails, or the SBOM association cannot +be proven, stop without publishing the release. Do not move or reuse a public +release tag. Correct the repository through the normal reviewed pull-request +process and use a new version only after approval. + +The GitHub release does not update Zenodo. Any future Zenodo deposit is a +separate reviewed publication action. diff --git a/docs/supply-chain-and-ci.md b/docs/supply-chain-and-ci.md index dfcc5cc..d43a749 100644 --- a/docs/supply-chain-and-ci.md +++ b/docs/supply-chain-and-ci.md @@ -50,6 +50,21 @@ transitive version and hash change. An update is incomplete until the known-vulnerability audit, full tests, and complete dependency diff have been reviewed. Dependabot proposals are not auto-merged. +## Release-tag SBOM + +The workflow runs for version tags matching `v*` as well as pull requests and +pushes to `main`. The release process requires `v4.0.0` to point to the exact +independently approved `main` commit. That tag-triggered run generates +`f7las-python-sbom-` using the same hash-locked dependency graph and +validation job. + +Only the SBOM from the successful tag-triggered run may be attached to the +4.0.0 GitHub release. The maintainer must verify the workflow event, tag target, +head SHA, artifact association, GitHub artifact digest, and extracted JSON +SHA-256 before attaching the unchanged CycloneDX file and its checksum record. +The complete non-publishing procedure is documented in +[release-process.md](release-process.md). + ## Assurance boundary - Hash checking detects a downloaded Python distribution that does not match diff --git a/examples/canonical-workflow/README.md b/examples/canonical-workflow/README.md index 3cfbfe2..2fe3100 100644 --- a/examples/canonical-workflow/README.md +++ b/examples/canonical-workflow/README.md @@ -81,6 +81,11 @@ a signature, proof of origin, trusted timestamp, or external attestation; a party that can replace both evidence and its expected digest can construct a different self-consistent set. +Workflow input loading also rejects duplicate JSON keys. The CLI refuses an +output path that resolves to, symlinks to, or is hard-linked to the input before +policy evaluation or writing, so evidence generation cannot overwrite the +admitted request through path aliasing. + ## Deterministic replay Replay the same admitted input through the canonical workflow and require the diff --git a/scripts/run-clean-user-acceptance.sh b/scripts/run-clean-user-acceptance.sh index c1fee22..d5d0bc2 100755 --- a/scripts/run-clean-user-acceptance.sh +++ b/scripts/run-clean-user-acceptance.sh @@ -61,6 +61,8 @@ echo "[clean-user] Install the hash-locked dependency graph" echo "[clean-user] Validate documentation and repository invariants" "$python" scripts/validate-documentation.py +"$python" scripts/validate-citation.py +"$python" scripts/validate-control-traceability.py "$python" scripts/validate-supply-chain.py "$python" scripts/validate-prompts.py config/prompts "$python" scripts/validate-policies.py diff --git a/scripts/validate-citation.py b/scripts/validate-citation.py new file mode 100644 index 0000000..f31d8c8 --- /dev/null +++ b/scripts/validate-citation.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Validate repository citation metadata against the required CFF 1.2 profile.""" + +from __future__ import annotations + +import re +import sys +from datetime import date +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +CITATION_PATH = ROOT / "CITATION.cff" +DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$") +SPDX_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*$") + + +class CitationError(ValueError): + """Citation metadata violates the repository CFF profile.""" + + +class UniqueKeyLoader(yaml.SafeLoader): + """Safe YAML loader that refuses duplicate mapping keys.""" + + +def _construct_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False): + result: dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in result: + raise CitationError(f"duplicate YAML mapping key: {key}") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_mapping, +) + + +def _nonempty_string(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise CitationError(f"{field} must be a non-empty string") + return value + + +def validate_citation(path: Path = CITATION_PATH) -> None: + document = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) + if not isinstance(document, dict): + raise CitationError("CITATION.cff must contain a YAML mapping") + for field in ("cff-version", "message", "title", "authors"): + if field not in document: + raise CitationError(f"required CFF field is missing: {field}") + if document["cff-version"] != "1.2.0": + raise CitationError("cff-version must be 1.2.0") + _nonempty_string(document["message"], "message") + _nonempty_string(document["title"], "title") + + authors = document["authors"] + if not isinstance(authors, list) or not authors: + raise CitationError("authors must be a non-empty array") + for index, author in enumerate(authors): + if not isinstance(author, dict): + raise CitationError(f"authors[{index}] must be a mapping") + if "name" in author: + _nonempty_string(author["name"], f"authors[{index}].name") + else: + _nonempty_string(author.get("family-names"), f"authors[{index}].family-names") + _nonempty_string(author.get("given-names"), f"authors[{index}].given-names") + + if "version" in document: + _nonempty_string(document["version"], "version") + if "doi" in document and not DOI_RE.fullmatch(_nonempty_string(document["doi"], "doi")): + raise CitationError("doi is not syntactically valid") + if "url" in document: + parsed = urlsplit(_nonempty_string(document["url"], "url")) + if parsed.scheme != "https" or not parsed.netloc: + raise CitationError("url must be an absolute HTTPS URL") + if "license" in document: + licenses = document["license"] + if isinstance(licenses, str): + licenses = [licenses] + if not isinstance(licenses, list) or not licenses: + raise CitationError("license must be an SPDX identifier or non-empty array") + if not all(isinstance(item, str) and SPDX_RE.fullmatch(item) for item in licenses): + raise CitationError("license contains an invalid SPDX identifier") + if "date-released" in document: + released = document["date-released"] + if not isinstance(released, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", released): + raise CitationError("date-released must be a full YYYY-MM-DD string") + try: + date.fromisoformat(released) + except ValueError as exc: + raise CitationError("date-released is not a valid calendar date") from exc + + +def main() -> int: + try: + validate_citation() + except (OSError, UnicodeError, yaml.YAMLError, CitationError) as exc: + print(f"CFF validation failed: {exc}", file=sys.stderr) + return 1 + print("CITATION.cff validation PASSED for CFF 1.2.0.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-control-traceability.py b/scripts/validate-control-traceability.py new file mode 100644 index 0000000..a794afb --- /dev/null +++ b/scripts/validate-control-traceability.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Validate complete F7-LAS control-to-evidence traceability.""" + +from __future__ import annotations + +import json +import re +import sys +from collections import Counter +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / "docs" / "F7-LAS-Control-Catalog-v0.1.md" +TRACEABILITY_PATH = ROOT / "config" / "control-traceability.json" +CONTROL_RE = re.compile(r"^###\s+(F7-(L[1-7]|LS)-\d{2})\s+[–-]\s+(.+?)\s*$", re.MULTILINE) +STATUSES = {"implemented", "partial", "not_implemented"} +EVIDENCE_KINDS = { + "configuration", + "documentation", + "implementation", + "release", + "schema", + "test", + "workflow", +} +EXECUTABLE_EVIDENCE = { + "configuration", + "implementation", + "schema", + "test", + "workflow", +} + + +class TraceabilityError(ValueError): + """Traceability data does not match the catalog or repository.""" + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise TraceabilityError(f"duplicate JSON object key: {key}") + result[key] = value + return result + + +def _load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle, object_pairs_hook=_reject_duplicate_keys) + + +def catalog_controls(root: Path = ROOT) -> list[dict[str, str]]: + text = (root / CATALOG_PATH.relative_to(ROOT)).read_text(encoding="utf-8") + return [ + {"control_id": control_id, "domain": domain, "title": title} + for control_id, domain, title in CONTROL_RE.findall(text) + ] + + +def validate_traceability(root: Path = ROOT) -> dict[str, int]: + traceability = _load_json(root / TRACEABILITY_PATH.relative_to(ROOT)) + if set(traceability) != { + "schema_version", + "catalog", + "scope", + "summary", + "controls", + }: + raise TraceabilityError("traceability top-level fields do not match the contract") + if traceability["schema_version"] != "1.0.0": + raise TraceabilityError("schema_version must be 1.0.0") + if traceability["catalog"] != { + "path": "docs/F7-LAS-Control-Catalog-v0.1.md", + "version": "0.1", + }: + raise TraceabilityError("catalog reference does not identify v0.1") + if not isinstance(traceability["scope"], str) or len(traceability["scope"]) < 20: + raise TraceabilityError("scope must state the assessment boundary") + + catalog = catalog_controls(root) + if len(catalog) != 51: + raise TraceabilityError(f"catalog must contain 51 controls; found {len(catalog)}") + catalog_ids = [entry["control_id"] for entry in catalog] + if len(catalog_ids) != len(set(catalog_ids)): + raise TraceabilityError("catalog control identifiers are not unique") + + controls = traceability["controls"] + if not isinstance(controls, list): + raise TraceabilityError("controls must be an array") + trace_ids = [entry.get("control_id") for entry in controls if isinstance(entry, dict)] + if trace_ids != catalog_ids: + missing = sorted(set(catalog_ids) - set(trace_ids)) + extra = sorted(set(trace_ids) - set(catalog_ids)) + raise TraceabilityError( + f"traceability must match catalog order and membership; missing={missing} extra={extra}" + ) + + for expected, entry in zip(catalog, controls): + if set(entry) != { + "control_id", + "domain", + "title", + "status", + "limitations", + "evidence", + }: + raise TraceabilityError( + f"{expected['control_id']}: fields do not match the traceability contract" + ) + for field in ("control_id", "domain", "title"): + if entry[field] != expected[field]: + raise TraceabilityError( + f"{expected['control_id']}: {field} does not match the catalog" + ) + if entry["status"] not in STATUSES: + raise TraceabilityError(f"{expected['control_id']}: invalid status") + if not isinstance(entry["limitations"], str) or len(entry["limitations"]) < 20: + raise TraceabilityError(f"{expected['control_id']}: limitations are incomplete") + evidence = entry["evidence"] + if not isinstance(evidence, list) or not evidence: + raise TraceabilityError(f"{expected['control_id']}: evidence is required") + kinds: set[str] = set() + for reference in evidence: + if set(reference) != {"kind", "path", "locator"}: + raise TraceabilityError( + f"{expected['control_id']}: evidence fields do not match the contract" + ) + kind = reference["kind"] + if kind not in EVIDENCE_KINDS: + raise TraceabilityError(f"{expected['control_id']}: invalid evidence kind") + kinds.add(kind) + relative = Path(reference["path"]) + if relative.is_absolute() or ".." in relative.parts: + raise TraceabilityError(f"{expected['control_id']}: unsafe evidence path") + path = root / relative + if not path.is_file(): + raise TraceabilityError( + f"{expected['control_id']}: evidence path does not exist: {relative}" + ) + locator = reference["locator"] + if not isinstance(locator, str) or not locator: + raise TraceabilityError(f"{expected['control_id']}: evidence locator is required") + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise TraceabilityError( + f"{expected['control_id']}: evidence path is not UTF-8 text: {relative}" + ) from exc + if locator not in content: + raise TraceabilityError( + f"{expected['control_id']}: evidence locator not found in {relative}: {locator}" + ) + if entry["status"] in {"implemented", "partial"} and not ( + kinds & EXECUTABLE_EVIDENCE + ): + raise TraceabilityError( + f"{expected['control_id']}: {entry['status']} status lacks executable evidence" + ) + + counts = Counter(entry["status"] for entry in controls) + expected_summary = { + "total": 51, + "core_layers_1_7": 46, + "supplemental_layer_s": 5, + "implemented": counts["implemented"], + "partial": counts["partial"], + "not_implemented": counts["not_implemented"], + } + if traceability["summary"] != expected_summary: + raise TraceabilityError( + f"summary does not match controls: expected {expected_summary}" + ) + return dict(counts) + + +def main() -> int: + try: + counts = validate_traceability() + except (OSError, json.JSONDecodeError, TypeError, TraceabilityError) as exc: + print(f"control traceability validation failed: {exc}", file=sys.stderr) + return 1 + print( + "F7-LAS control traceability PASSED: " + f"implemented={counts.get('implemented', 0)} " + f"partial={counts.get('partial', 0)} " + f"not_implemented={counts.get('not_implemented', 0)} total=51." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-documentation.py b/scripts/validate-documentation.py index afa90d1..1bd80d4 100755 --- a/scripts/validate-documentation.py +++ b/scripts/validate-documentation.py @@ -52,6 +52,12 @@ "distinct terminal outcomes", "Agent Planning", } +EXPECTED_RELEASE_VERSION = "4.0.0" +RETIRED_PLACEHOLDERS = { + Path("src/agents/placeholder"), + Path("src/core/placeholder"), + Path("src/tools/placeholder"), +} class DocumentationError(ValueError): @@ -172,12 +178,42 @@ def validate_diagrams(root: Path) -> None: ) +def validate_release_candidate(root: Path) -> None: + version = (root / "VERSION").read_text(encoding="utf-8").strip() + if version != EXPECTED_RELEASE_VERSION: + raise DocumentationError( + f"VERSION must be {EXPECTED_RELEASE_VERSION}; found {version!r}" + ) + required_version_documents = { + Path("README.md"): "not tagged or published", + Path("RELEASE_NOTES.md"): "Prepared but not tagged or published", + Path("ROADMAP.md"): "unpublished release candidate", + Path("docs/release-process.md"): "v4.0.0", + } + for relative, boundary in required_version_documents.items(): + text = (root / relative).read_text(encoding="utf-8") + if EXPECTED_RELEASE_VERSION not in text or boundary not in text: + raise DocumentationError( + f"{relative} does not state the {EXPECTED_RELEASE_VERSION} release boundary" + ) + for relative in RETIRED_PLACEHOLDERS: + if (root / relative).exists(): + raise DocumentationError(f"retired placeholder returned: {relative}") + + readme = (root / "README.md").read_text(encoding="utf-8") + docs_index = (root / "docs" / "README.md").read_text(encoding="utf-8") + count_wording = "46 core Layers 1–7 controls plus five supplemental Layer S controls" + if count_wording not in readme or count_wording not in docs_index: + raise DocumentationError("control-catalog count wording is inconsistent") + + def validate_repository(root: Path = ROOT) -> None: for path in markdown_files(root): validate_links(path, root) validate_command_boundaries(path, root) validate_diagrams(root) + validate_release_candidate(root) illustrative_opa = ( root / "examples" / "layer5-policy-engines" / "opa-rego" / "README.md" diff --git a/scripts/validate-supply-chain.py b/scripts/validate-supply-chain.py index 3ac7a4b..3957dd0 100644 --- a/scripts/validate-supply-chain.py +++ b/scripts/validate-supply-chain.py @@ -17,6 +17,10 @@ ACTION_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$") VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +CFF_ACTION = ( + "citation-file-format/cffconvert-github-action@" + "4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084" +) class SupplyChainError(ValueError): @@ -74,13 +78,13 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: raise SupplyChainError("pull_request_target is not allowed") if data.get("permissions") != {"contents": "read"}: raise SupplyChainError("workflow permissions must be exactly contents: read") - jobs = data.get("jobs") if not isinstance(jobs, dict) or not jobs: raise SupplyChainError("workflow has no jobs") checkout_found = False python_found = False + cff_validator_found = False for job in jobs.values(): if job.get("runs-on") == "ubuntu-latest": raise SupplyChainError("runner image must not use ubuntu-latest") @@ -107,6 +111,8 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: version = str(step.get("with", {}).get("python-version", "")) if not VERSION_RE.fullmatch(version): raise SupplyChainError("Python must be pinned to an exact patch version") + if action_ref == CFF_ACTION: + cff_validator_found = step.get("with", {}).get("args") == "--validate" run = str(step.get("run", "")) if "curl " in run and "sha256sum -c -" not in run: @@ -123,6 +129,18 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: raise SupplyChainError("pinned actions/checkout step not found") if not python_found: raise SupplyChainError("pinned actions/setup-python step not found") + if not cff_validator_found: + raise SupplyChainError("official CFF schema validator is missing or misconfigured") + for required_command in ( + "python scripts/validate-citation.py", + "python scripts/validate-control-traceability.py", + ): + if required_command not in raw_text: + raise SupplyChainError(f"workflow is missing required validation: {required_command}") + + push = data.get("on", {}).get("push", {}) + if push.get("tags") != ["v*"]: + raise SupplyChainError("workflow must validate version tags matching v*") def validate_dependabot(data: dict[str, Any]) -> None: diff --git a/src/agents/placeholder b/src/agents/placeholder deleted file mode 100644 index 2b98e56..0000000 --- a/src/agents/placeholder +++ /dev/null @@ -1 +0,0 @@ -Temporary placeholder diff --git a/src/canonical/cli.py b/src/canonical/cli.py index e5a313b..7d24e7e 100644 --- a/src/canonical/cli.py +++ b/src/canonical/cli.py @@ -4,11 +4,21 @@ import argparse import json +import os from pathlib import Path +from .validation import load_json from .workflow import CanonicalWorkflow, WorkflowError +def _same_file(first: Path, second: Path) -> bool: + """Return whether two paths resolve to the same inode or destination.""" + + if first.resolve() == second.resolve(): + return True + return first.exists() and second.exists() and os.path.samefile(first, second) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", required=True, type=Path) @@ -17,14 +27,17 @@ def main() -> int: args = parser.parse_args() try: - with args.input.open("r", encoding="utf-8") as handle: - workflow_input = json.load(handle) + if _same_file(args.output, args.input): + raise WorkflowError("output must not overwrite input") + workflow_input = load_json(args.input) result = CanonicalWorkflow(opa_binary=args.opa_binary).run(workflow_input) - except (OSError, json.JSONDecodeError, WorkflowError) as exc: + if _same_file(args.output, args.input): + raise WorkflowError("output must not overwrite input") + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + except (OSError, json.JSONDecodeError, TypeError, ValueError, WorkflowError) as exc: print(f"F7-LAS canonical workflow refused input: {exc}") return 2 - args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") decision = next(record for record in result["records"] if record["record_type"] == "policy_decision") execution = next(record for record in result["records"] if record["record_type"] == "execution_result") print(f"decision={decision['decision']} execution={execution['status']} output={args.output}") diff --git a/src/core/placeholder b/src/core/placeholder deleted file mode 100644 index 2b98e56..0000000 --- a/src/core/placeholder +++ /dev/null @@ -1 +0,0 @@ -Temporary placeholder diff --git a/src/tools/placeholder b/src/tools/placeholder deleted file mode 100644 index 2b98e56..0000000 --- a/src/tools/placeholder +++ /dev/null @@ -1 +0,0 @@ -Temporary placeholder diff --git a/tests/test_canonical_workflow.py b/tests/test_canonical_workflow.py index a0fc10f..9f64af9 100644 --- a/tests/test_canonical_workflow.py +++ b/tests/test_canonical_workflow.py @@ -418,3 +418,114 @@ def test_cli_returns_nonzero_for_denial_and_preserves_records(tmp_path, monkeypa assert record(document, "policy_decision")["decision"] == "deny" assert record(document, "execution_result")["status"] == "not_executed" assert_valid(document) + + +def test_cli_rejects_duplicate_input_keys_without_emitting_records(tmp_path, monkeypatch): + input_path = tmp_path / "duplicate-input.json" + output_path = tmp_path / "records.json" + original = INPUT_PATH.read_text(encoding="utf-8") + input_path.write_text( + original.replace( + '"workflow_id": "workflow-0001",', + '"workflow_id": "workflow-other",\n "workflow_id": "workflow-0001",', + 1, + ), + encoding="utf-8", + ) + monkeypatch.setattr( + "sys.argv", + [ + "f7las-canonical", + "--input", + str(input_path), + "--output", + str(output_path), + "--opa-binary", + "/does/not/matter", + ], + ) + + assert cli.main() == 2 + assert not output_path.exists() + + +def test_cli_rejects_output_that_is_input_before_execution(tmp_path, monkeypatch): + input_path = tmp_path / "request.json" + input_path.write_bytes(INPUT_PATH.read_bytes()) + original = input_path.read_bytes() + monkeypatch.setattr( + "sys.argv", + [ + "f7las-canonical", + "--input", + str(input_path), + "--output", + str(input_path), + "--opa-binary", + "/does/not/matter", + ], + ) + + assert cli.main() == 2 + assert input_path.read_bytes() == original + + +@pytest.mark.parametrize("alias_kind", ["hardlink", "symlink"]) +def test_cli_rejects_output_alias_to_input_before_execution( + tmp_path, monkeypatch, alias_kind +): + input_path = tmp_path / "request.json" + output_path = tmp_path / "aliased-output.json" + input_path.write_bytes(INPUT_PATH.read_bytes()) + original = input_path.read_bytes() + if alias_kind == "hardlink": + os.link(input_path, output_path) + else: + output_path.symlink_to(input_path) + monkeypatch.setattr( + "sys.argv", + [ + "f7las-canonical", + "--input", + str(input_path), + "--output", + str(output_path), + "--opa-binary", + "/does/not/matter", + ], + ) + + assert cli.main() == 2 + assert input_path.read_bytes() == original + + +def test_cli_rechecks_output_alias_after_execution_before_writing(tmp_path, monkeypatch): + input_path = tmp_path / "request.json" + output_path = tmp_path / "records.json" + input_path.write_bytes(INPUT_PATH.read_bytes()) + original = input_path.read_bytes() + + class AliasingWorkflow: + def __init__(self, opa_binary): + self.opa_binary = opa_binary + + def run(self, workflow_input): + output_path.symlink_to(input_path) + return {"records": []} + + monkeypatch.setattr(cli, "CanonicalWorkflow", AliasingWorkflow) + monkeypatch.setattr( + "sys.argv", + [ + "f7las-canonical", + "--input", + str(input_path), + "--output", + str(output_path), + "--opa-binary", + "/does/not/matter", + ], + ) + + assert cli.main() == 2 + assert input_path.read_bytes() == original diff --git a/tests/test_citation.py b/tests/test_citation.py new file mode 100644 index 0000000..b3496ce --- /dev/null +++ b/tests/test_citation.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path + +import pytest + + +MODULE_PATH = Path("scripts/validate-citation.py") +SPEC = importlib.util.spec_from_file_location("validate_citation", MODULE_PATH) +assert SPEC and SPEC.loader +citation = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(citation) + + +def test_repository_citation_is_valid(): + citation.validate_citation() + + +def test_citation_rejects_year_only_release_date(tmp_path): + path = tmp_path / "CITATION.cff" + path.write_text( + """cff-version: 1.2.0 +message: Cite this work. +title: Test +authors: + - family-names: Fuller + given-names: Anthony +date-released: 2025 +""", + encoding="utf-8", + ) + + with pytest.raises(citation.CitationError, match="full YYYY-MM-DD"): + citation.validate_citation(path) + + +def test_citation_rejects_duplicate_keys(tmp_path): + path = tmp_path / "CITATION.cff" + path.write_text( + """cff-version: 1.2.0 +message: Cite this work. +message: Conflicting message. +title: Test +authors: + - family-names: Fuller + given-names: Anthony +""", + encoding="utf-8", + ) + + with pytest.raises(citation.CitationError, match="duplicate YAML mapping key"): + citation.validate_citation(path) diff --git a/tests/test_control_traceability.py b/tests/test_control_traceability.py new file mode 100644 index 0000000..deb36ec --- /dev/null +++ b/tests/test_control_traceability.py @@ -0,0 +1,90 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + + +MODULE_PATH = Path("scripts/validate-control-traceability.py") +SPEC = importlib.util.spec_from_file_location("validate_control_traceability", MODULE_PATH) +assert SPEC and SPEC.loader +traceability = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(traceability) + + +def copy_fixture(tmp_path: Path) -> Path: + (tmp_path / "config").mkdir() + (tmp_path / "docs").mkdir() + (tmp_path / "scripts").mkdir() + (tmp_path / "tests").mkdir() + catalog = Path("docs/F7-LAS-Control-Catalog-v0.1.md") + target_catalog = tmp_path / catalog + target_catalog.write_bytes(catalog.read_bytes()) + document = json.loads(Path("config/control-traceability.json").read_text(encoding="utf-8")) + paths = { + reference["path"] + for control in document["controls"] + for reference in control["evidence"] + } + for relative_text in paths: + relative = Path(relative_text) + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(relative.read_bytes()) + (tmp_path / "config" / "control-traceability.json").write_text( + json.dumps(document, indent=2) + "\n", encoding="utf-8" + ) + return tmp_path + + +def load_fixture(root: Path) -> dict: + return json.loads((root / "config" / "control-traceability.json").read_text()) + + +def save_fixture(root: Path, document: dict) -> None: + (root / "config" / "control-traceability.json").write_text( + json.dumps(document, indent=2) + "\n", encoding="utf-8" + ) + + +def test_complete_traceability_matches_all_51_catalog_controls(): + counts = traceability.validate_traceability() + + assert counts == {"partial": 29, "not_implemented": 16, "implemented": 6} + + +def test_traceability_rejects_missing_control(tmp_path): + root = copy_fixture(tmp_path) + document = load_fixture(root) + document["controls"].pop() + save_fixture(root, document) + + with pytest.raises(traceability.TraceabilityError, match="catalog order and membership"): + traceability.validate_traceability(root) + + +def test_traceability_rejects_missing_evidence_target(tmp_path): + root = copy_fixture(tmp_path) + document = load_fixture(root) + document["controls"][0]["evidence"][0]["path"] = "missing-evidence.txt" + save_fixture(root, document) + + with pytest.raises(traceability.TraceabilityError, match="evidence path does not exist"): + traceability.validate_traceability(root) + + +def test_traceability_rejects_unverifiable_implemented_claim(tmp_path): + root = copy_fixture(tmp_path) + document = load_fixture(root) + entry = next(item for item in document["controls"] if item["status"] == "implemented") + entry["evidence"] = [ + { + "kind": "documentation", + "path": "docs/F7-LAS-Control-Catalog-v0.1.md", + "locator": entry["control_id"], + } + ] + save_fixture(root, document) + + with pytest.raises(traceability.TraceabilityError, match="lacks executable evidence"): + traceability.validate_traceability(root) diff --git a/tests/test_supply_chain.py b/tests/test_supply_chain.py index 26dacd7..4949d55 100644 --- a/tests/test_supply_chain.py +++ b/tests/test_supply_chain.py @@ -19,6 +19,40 @@ def test_repository_supply_chain_invariants() -> None: MODULE.validate_repository(ROOT) +def test_workflow_requires_release_tag_validation() -> None: + path = ROOT / ".github" / "workflows" / "f7las-ci.yml" + text = path.read_text(encoding="utf-8") + workflow = yaml.safe_load(text) + workflow["on"]["push"].pop("tags") + + with pytest.raises(MODULE.SupplyChainError, match="version tags"): + MODULE.validate_workflow(workflow, text) + + +def test_workflow_requires_release_readiness_validators() -> None: + path = ROOT / ".github" / "workflows" / "f7las-ci.yml" + text = path.read_text(encoding="utf-8") + workflow = yaml.safe_load(text) + modified = text.replace("python scripts/validate-citation.py", "echo skipped-citation") + + with pytest.raises(MODULE.SupplyChainError, match="validate-citation"): + MODULE.validate_workflow(workflow, modified) + + +def test_workflow_requires_official_cff_schema_validator() -> None: + path = ROOT / ".github" / "workflows" / "f7las-ci.yml" + text = path.read_text(encoding="utf-8") + workflow = yaml.safe_load(text) + workflow["jobs"]["validate"]["steps"] = [ + step + for step in workflow["jobs"]["validate"]["steps"] + if step.get("uses") != MODULE.CFF_ACTION + ] + + with pytest.raises(MODULE.SupplyChainError, match="official CFF schema validator"): + MODULE.validate_workflow(workflow, text) + + def test_unpinned_requirement_is_rejected() -> None: with pytest.raises(MODULE.SupplyChainError, match="not exactly pinned"): MODULE.validate_pinned_requirements("requests>=2.34", require_hashes=False) From c084dd77c7c1d1271601d5bd57874d5fc43c6b62 Mon Sep 17 00:00:00 2001 From: anthonyfuller <38588965+anthfuller@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:59:11 -0400 Subject: [PATCH 2/2] fix: make CFF validation immutable --- .github/CODEOWNERS | 1 + .github/workflows/f7las-ci.yml | 7 +- docs/supply-chain-and-ci.md | 10 + schemas/cff/README.md | 15 + schemas/cff/cff-1.2.0.schema.json | 1882 +++++++++++++++++++++++++++++ scripts/validate-citation.py | 52 +- scripts/validate-supply-chain.py | 36 +- tests/test_citation.py | 19 + tests/test_supply_chain.py | 44 +- 9 files changed, 2037 insertions(+), 29 deletions(-) create mode 100644 schemas/cff/README.md create mode 100644 schemas/cff/cff-1.2.0.schema.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 23facf5..9edc507 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ # Licensing, citation, diagrams, traceability, and release metadata /LICENSE* @anthfuller /CITATION.cff @anthfuller +/schemas/cff/ @anthfuller /docs/images/ @anthfuller /docs/architecture-diagrams.md @anthfuller /config/control-traceability.json @anthfuller diff --git a/.github/workflows/f7las-ci.yml b/.github/workflows/f7las-ci.yml index dbe4570..1f7127f 100644 --- a/.github/workflows/f7las-ci.yml +++ b/.github/workflows/f7las-ci.yml @@ -40,14 +40,9 @@ jobs: - name: Validate documentation run: python scripts/validate-documentation.py - - name: Validate citation metadata + - name: Validate citation metadata against the official CFF schema run: python scripts/validate-citation.py - - name: Validate CITATION.cff against the CFF schema - uses: citation-file-format/cffconvert-github-action@4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084 # 2.0.0 - with: - args: "--validate" - - name: Validate control traceability run: python scripts/validate-control-traceability.py diff --git a/docs/supply-chain-and-ci.md b/docs/supply-chain-and-ci.md index d43a749..ec35c4e 100644 --- a/docs/supply-chain-and-ci.md +++ b/docs/supply-chain-and-ci.md @@ -65,6 +65,16 @@ SHA-256 before attaching the unchanged CycloneDX file and its checksum record. The complete non-publishing procedure is documented in [release-process.md](release-process.md). +## Citation metadata validation + +CI validates `CITATION.cff` against the official CFF 1.2.0 schema vendored +from immutable upstream commit `0c5b4aa07071490eaf261775ce96ccdd13a6e2d5`. +The schema is accepted only when its SHA-256 is +`0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b`. +This avoids the CFF Action wrapper's mutable tag-only container dependency. +Repository checks also reject any `docker://` Action reference that is not +pinned directly by a complete `sha256:` image digest. + ## Assurance boundary - Hash checking detects a downloaded Python distribution that does not match diff --git a/schemas/cff/README.md b/schemas/cff/README.md new file mode 100644 index 0000000..d8f4465 --- /dev/null +++ b/schemas/cff/README.md @@ -0,0 +1,15 @@ +# CFF 1.2.0 schema provenance + +`cff-1.2.0.schema.json` is the official Citation File Format schema copied +without modification from the upstream `citation-file-format/citation-file-format` +repository at immutable commit +`0c5b4aa07071490eaf261775ce96ccdd13a6e2d5`: + + + +SHA-256: +`0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b` + +The upstream schema is distributed under the Creative Commons Attribution +4.0 International license. The upstream project and schema authors are the +attributed source; no local authorship of the schema is claimed. diff --git a/schemas/cff/cff-1.2.0.schema.json b/schemas/cff/cff-1.2.0.schema.json new file mode 100644 index 0000000..762194b --- /dev/null +++ b/schemas/cff/cff-1.2.0.schema.json @@ -0,0 +1,1882 @@ +{ + "$id": "https://citation-file-format.github.io/1.2.0/schema.json", + "$schema": "http://json-schema.org/draft-07/schema", + "additionalProperties": false, + "definitions": { + "address": { + "description": "An address.", + "minLength": 1, + "type": "string" + }, + "alias": { + "description": "An alias.", + "minLength": 1, + "type": "string" + }, + "city": { + "description": "A city", + "minLength": 1, + "type": "string" + }, + "commit": { + "description": "The (e.g., Git) commit hash or (e.g., Subversion) revision number of the work.", + "minLength": 1, + "type": "string" + }, + "country": { + "$comment": "ISO 3166-1 alpha-2 codes can be found at https://en.wikipedia.org/wiki/ISO_3166-1", + "description": "The ISO 3166-1 alpha-2 country code for a country.", + "enum": [ + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW" + ], + "type": "string" + }, + "date": { + "$comment": "Note to tool implementers: it is necessary to cast YAML 'date' objects to string objects when validating against this schema.", + "examples": [ + "1900-01-01", + "2020-12-31" + ], + "format": "date", + "pattern": "^[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "doi": { + "description": "The DOI of the work (i.e., 10.5281/zenodo.1003150, not the resolver URL http://doi.org/10.5281/zenodo.1003150).", + "examples": [ + "10.5281/zenodo.1003150" + ], + "pattern": "^10\\.\\d{4,9}(\\.\\d+)?/[A-Za-z0-9:/_;\\-\\.\\(\\)\\[\\]\\\\]+$", + "type": "string" + }, + "email": { + "description": "An email address.", + "pattern": "^[\\S]+@[\\S]+\\.[\\S]{2,}$", + "type": "string" + }, + "entity": { + "additionalProperties": false, + "description": "An entity, i.e., an institution, team, research group, company, conference, etc., as opposed to a single natural person.", + "properties": { + "address": { + "$ref": "#/definitions/address", + "description": "The entity's address." + }, + "alias": { + "$ref": "#/definitions/alias", + "description": "The entity's alias." + }, + "city": { + "$ref": "#/definitions/city", + "description": "The entity's city." + }, + "country": { + "$ref": "#/definitions/country", + "description": "The entity's country." + }, + "date-end": { + "$ref": "#/definitions/date", + "description": "The entity's ending date, e.g., when the entity is a conference." + }, + "date-start": { + "$ref": "#/definitions/date", + "description": "The entity's starting date, e.g., when the entity is a conference." + }, + "email": { + "$ref": "#/definitions/email", + "description": "The entity's email address." + }, + "fax": { + "$ref": "#/definitions/fax", + "description": "The entity's fax number." + }, + "location": { + "description": "The entity's location, e.g., when the entity is a conference.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "The entity's name.", + "minLength": 1, + "type": "string" + }, + "orcid": { + "$ref": "#/definitions/orcid", + "description": "The entity's orcid." + }, + "post-code": { + "$ref": "#/definitions/post-code", + "description": "The entity's post code." + }, + "region": { + "$ref": "#/definitions/region", + "description": "The entity's region." + }, + "tel": { + "$ref": "#/definitions/tel", + "description": "The entity's telephone number." + }, + "website": { + "$ref": "#/definitions/url", + "description": "The entity's website." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "fax": { + "description": "A fax number.", + "minLength": 1, + "type": "string" + }, + "identifier": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "doi" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/doi" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "url" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/url" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "swh" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/swh-identifier" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "other" + ], + "type": "string" + }, + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + } + ], + "description": "An identifier for a work." + }, + "identifier-description": { + "description": "A description for a specific identifier value.", + "examples": [ + "The version DOI for this version, which has a relation childOf with the concept DOI specified in the doi field in the root of this file.", + "The identifier provided by Archival Repository, which points to this version of the software." + ], + "minLength": 1, + "type": "string" + }, + "license": { + "description": "An SPDX license identifier.", + "oneOf": [ + { + "$ref": "#/definitions/license-enum", + "examples": [ + "Apache-2.0", + "MIT" + ] + }, + { + "$comment": "When there are multiple licenses, it is assumed their relationship is OR, not AND", + "examples": [ + [ + "Apache-2.0", + "MIT" + ], + [ + "GPL-3.0", + "GPL-3.0-or-later" + ] + ], + "items": { + "$ref": "#/definitions/license-enum" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "license-enum": { + "$comment": "Use https://github.com/citation-file-format/get-spdx-licenses to update this enum in the future", + "description": "SPDX license list; releaseDate=2021-05-14; source=https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json", + "enum": [ + "0BSD", + "AAL", + "Abstyles", + "Adobe-2006", + "Adobe-Glyph", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "Afmparse", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Aladdin", + "AMDPLPA", + "AML", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APAFML", + "APL-1.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "Bahyph", + "Barr", + "Beerware", + "BitTorrent-1.0", + "BitTorrent-1.1", + "blessing", + "BlueOak-1.0.0", + "Borceux", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-Views", + "BSD-3-Clause", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-Protection", + "BSD-Source-Code", + "BSL-1.0", + "BUSL-1.1", + "bzip2-1.0.5", + "bzip2-1.0.6", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "Caldera", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "ClArtistic", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "Condor-1.1", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "Crossword", + "CrystalStacker", + "CUA-OPL-1.0", + "Cube", + "curl", + "D-FSL-1.0", + "diffmark", + "DOC", + "Dotseqn", + "DRL-1.0", + "DSDP", + "dvipdfm", + "ECL-1.0", + "ECL-2.0", + "eCos-2.0", + "EFL-1.0", + "EFL-2.0", + "eGenix", + "Entessa", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ErlPL-1.1", + "etalab-2.0", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Eurosym", + "Fair", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "FSFAP", + "FSFUL", + "FSFULLR", + "FTL", + "GD", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "Giftware", + "GL2PS", + "Glide", + "Glulxe", + "GLWTPL", + "gnuplot", + "GPL-1.0", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-1.0+", + "GPL-2.0", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-2.0+", + "GPL-3.0", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "GPL-3.0+", + "gSOAP-1.3b", + "HaskellReport", + "Hippocratic-2.1", + "HPND", + "HPND-sell-variant", + "HTMLTIDY", + "IBM-pibs", + "ICU", + "IJG", + "ImageMagick", + "iMatix", + "Imlib2", + "Info-ZIP", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "IPA", + "IPL-1.0", + "ISC", + "JasPer-2.0", + "JPNIC", + "JSON", + "LAL-1.2", + "LAL-1.3", + "Latex2e", + "Leptonica", + "LGPL-2.0", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.0+", + "LGPL-2.1", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-2.1+", + "LGPL-3.0", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPL-3.0+", + "LGPLLR", + "Libpng", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Linux-OpenIB", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "MakeIndex", + "MirOS", + "MIT", + "MIT-0", + "MIT-advertising", + "MIT-CMU", + "MIT-enna", + "MIT-feh", + "MIT-Modern-Variant", + "MIT-open-group", + "MITNFA", + "Motosoto", + "mpich2", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "MS-PL", + "MS-RL", + "MTLL", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NCGL-UK-2.0", + "NCSA", + "Net-SNMP", + "NetCDF", + "Newsletr", + "NGPL", + "NIST-PD", + "NIST-PD-fallback", + "NLOD-1.0", + "NLPL", + "Nokia", + "NOSL", + "Noweb", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTP", + "NTP-0", + "Nunit", + "O-UDA-1.0", + "OCCT-PL", + "OCLC-2.0", + "ODbL-1.0", + "ODC-By-1.0", + "OFL-1.0", + "OFL-1.0-no-RFN", + "OFL-1.0-RFN", + "OFL-1.1", + "OFL-1.1-no-RFN", + "OFL-1.1-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OML", + "OpenSSL", + "OPL-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "Parity-6.0.0", + "Parity-7.0.0", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "Plexus", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "PSF-2.0", + "psfrag", + "psutils", + "Python-2.0", + "Qhull", + "QPL-1.0", + "Rdisc", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Ruby", + "SAX-PD", + "Saxpath", + "SCEA", + "Sendmail", + "Sendmail-8.23", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SHL-0.5", + "SHL-0.51", + "SimPL-2.0", + "SISSL", + "SISSL-1.2", + "Sleepycat", + "SMLNJ", + "SMPPL", + "SNIA", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "SPL-1.0", + "SSH-OpenSSH", + "SSH-short", + "SSPL-1.0", + "StandardML-NJ", + "SugarCRM-1.1.3", + "SWL", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TMate", + "TORQUE-1.1", + "TOSL", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "UCL-1.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "Unlicense", + "UPL-1.0", + "Vim", + "VOSTROM", + "VSL-1.0", + "W3C", + "W3C-19980720", + "W3C-20150513", + "Watcom-1.0", + "Wsuipa", + "WTFPL", + "wxWindows", + "X11", + "Xerox", + "XFree86-1.1", + "xinetd", + "Xnet", + "xpp", + "XSkat", + "YPL-1.0", + "YPL-1.1", + "Zed", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "zlib-acknowledgement", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1" + ], + "type": "string" + }, + "orcid": { + "description": "Identifier for an author, see https://orcid.org.", + "format": "uri", + "pattern": "https://orcid\\.org/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]{1}", + "type": "string" + }, + "person": { + "additionalProperties": false, + "description": "A person.", + "properties": { + "address": { + "$ref": "#/definitions/address", + "description": "The person's address." + }, + "affiliation": { + "description": "The person's affilitation.", + "minLength": 1, + "type": "string" + }, + "alias": { + "$ref": "#/definitions/alias", + "description": "The person's alias." + }, + "city": { + "$ref": "#/definitions/city", + "description": "The person's city." + }, + "country": { + "$ref": "#/definitions/country", + "description": "The person's country." + }, + "email": { + "$ref": "#/definitions/email", + "description": "The person's email address." + }, + "family-names": { + "description": "The person's family names.", + "minLength": 1, + "type": "string" + }, + "fax": { + "$ref": "#/definitions/fax", + "description": "The person's fax number." + }, + "given-names": { + "description": "The person's given names.", + "minLength": 1, + "type": "string" + }, + "name-particle": { + "description": "The person's name particle, e.g., a nobiliary particle or a preposition meaning 'of' or 'from' (for example 'von' in 'Alexander von Humboldt').", + "examples": [ + "von" + ], + "minLength": 1, + "type": "string" + }, + "name-suffix": { + "description": "The person's name-suffix, e.g. 'Jr.' for Sammy Davis Jr. or 'III' for Frank Edwin Wright III.", + "examples": [ + "Jr.", + "III" + ], + "minLength": 1, + "type": "string" + }, + "orcid": { + "$ref": "#/definitions/orcid", + "description": "The person's ORCID." + }, + "post-code": { + "$ref": "#/definitions/post-code", + "description": "The person's post-code." + }, + "region": { + "$ref": "#/definitions/region", + "description": "The person's region." + }, + "tel": { + "$ref": "#/definitions/tel", + "description": "The person's phone number." + }, + "website": { + "$ref": "#/definitions/url", + "description": "The person's website." + } + }, + "type": "object" + }, + "post-code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "A post code." + }, + "reference": { + "additionalProperties": false, + "description": "A reference to a work.", + "properties": { + "abbreviation": { + "description": "The abbreviation of a work.", + "minLength": 1, + "type": "string" + }, + "abstract": { + "description": "The abstract of a work.", + "minLength": 1, + "type": "string" + }, + "authors": { + "description": "The author(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "collection-doi": { + "$ref": "#/definitions/doi", + "description": "The DOI of a collection containing the work." + }, + "collection-title": { + "description": "The title of a collection or proceedings.", + "minLength": 1, + "type": "string" + }, + "collection-type": { + "description": "The type of a collection.", + "minLength": 1, + "type": "string" + }, + "commit": { + "$ref": "#/definitions/commit" + }, + "conference": { + "$ref": "#/definitions/entity", + "description": "The conference where the work was presented." + }, + "contact": { + "description": "The contact person, group, company, etc. for a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "copyright": { + "description": "The copyright information pertaining to the work.", + "minLength": 1, + "type": "string" + }, + "data-type": { + "description": "The data type of a data set.", + "minLength": 1, + "type": "string" + }, + "database": { + "description": "The name of the database where a work was accessed/is stored.", + "minLength": 1, + "type": "string" + }, + "database-provider": { + "$ref": "#/definitions/entity", + "description": "The provider of the database where a work was accessed/is stored." + }, + "date-accessed": { + "$ref": "#/definitions/date", + "description": "The date the work was accessed." + }, + "date-downloaded": { + "$ref": "#/definitions/date", + "description": "The date the work has been downloaded." + }, + "date-published": { + "$ref": "#/definitions/date", + "description": "The date the work has been published." + }, + "date-released": { + "$ref": "#/definitions/date", + "description": "The date the work has been released." + }, + "department": { + "description": "The department where a work has been produced.", + "minLength": 1, + "type": "string" + }, + "doi": { + "$ref": "#/definitions/doi", + "description": "The DOI of the work." + }, + "edition": { + "description": "The edition of the work.", + "minLength": 1, + "type": "string" + }, + "editors": { + "description": "The editor(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "editors-series": { + "description": "The editor(s) of a series in which a work has been published.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "end": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The end page of the work." + }, + "entry": { + "description": "An entry in the collection that constitutes the work.", + "minLength": 1, + "type": "string" + }, + "filename": { + "description": "The name of the electronic file containing the work.", + "minLength": 1, + "type": "string" + }, + "format": { + "description": "The format in which a work is represented.", + "minLength": 1, + "type": "string" + }, + "identifiers": { + "description": "The identifier(s) of the work.", + "items": { + "$ref": "#/definitions/identifier" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "institution": { + "$ref": "#/definitions/entity", + "description": "The institution where a work has been produced or published." + }, + "isbn": { + "description": "The ISBN of the work.", + "pattern": "^[0-9\\- ]{10,17}X?$", + "type": "string" + }, + "issn": { + "description": "The ISSN of the work.", + "pattern": "^\\d{4}-\\d{3}[\\dxX]$", + "type": "string" + }, + "issue": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The issue of a periodical in which a work appeared." + }, + "issue-date": { + "description": "The publication date of the issue of a periodical in which a work appeared.", + "minLength": 1, + "type": "string" + }, + "issue-title": { + "description": "The name of the issue of a periodical in which the work appeared.", + "minLength": 1, + "type": "string" + }, + "journal": { + "description": "The name of the journal/magazine/newspaper/periodical where the work was published.", + "minLength": 1, + "type": "string" + }, + "keywords": { + "description": "Keywords pertaining to the work.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "languages": { + "description": "The language identifier(s) of the work according to ISO 639 language strings.", + "items": { + "maxLength": 3, + "minLength": 2, + "pattern": "^[a-z]{2,3}$", + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "license": { + "$ref": "#/definitions/license" + }, + "license-url": { + "$ref": "#/definitions/url", + "description": "The URL of the license text under which the work is licensed (only for non-standard licenses not included in the SPDX License List)." + }, + "loc-end": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The line of code in the file where the work ends." + }, + "loc-start": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The line of code in the file where the work starts." + }, + "location": { + "$ref": "#/definitions/entity", + "description": "The location of the work." + }, + "medium": { + "description": "The medium of the work.", + "minLength": 1, + "type": "string" + }, + "month": { + "anyOf": [ + { + "maximum": 12, + "minimum": 1, + "type": "integer" + }, + { + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12" + ], + "type": "string" + } + ], + "description": "The month in which a work has been published." + }, + "nihmsid": { + "description": "The NIHMSID of a work.", + "minLength": 1, + "type": "string" + }, + "notes": { + "description": "Notes pertaining to the work.", + "minLength": 1, + "type": "string" + }, + "number": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The accession number for a work." + }, + "number-volumes": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The number of volumes making up the collection in which the work has been published." + }, + "pages": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The number of pages of the work." + }, + "patent-states": { + "description": "The states for which a patent is granted.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "pmcid": { + "description": "The PMCID of a work.", + "pattern": "^PMC[0-9]{7}$", + "type": "string" + }, + "publisher": { + "$ref": "#/definitions/entity", + "description": "The publisher who has published the work." + }, + "recipients": { + "description": "The recipient(s) of a personal communication.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "repository": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a repository (when the repository is neither a source code repository nor a build artifact repository)." + }, + "repository-artifact": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a build artifact/binary repository." + }, + "repository-code": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a source code repository." + }, + "scope": { + "description": "The scope of the reference, e.g., the section of the work it adheres to.", + "minLength": 1, + "type": "string" + }, + "section": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The section of a work that is referenced." + }, + "senders": { + "description": "The sender(s) of a personal communication.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "start": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The start page of the work." + }, + "status": { + "description": "The publication status of the work.", + "enum": [ + "abstract", + "advance-online", + "in-preparation", + "in-press", + "preprint", + "submitted" + ], + "type": "string" + }, + "term": { + "description": "The term being referenced if the work is a dictionary or encyclopedia.", + "minLength": 1, + "type": "string" + }, + "thesis-type": { + "description": "The type of the thesis that is the work.", + "minLength": 1, + "type": "string" + }, + "title": { + "description": "The title of the work.", + "minLength": 1, + "type": "string" + }, + "translators": { + "description": "The translator(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "type": { + "description": "The type of the work.", + "enum": [ + "art", + "article", + "audiovisual", + "bill", + "blog", + "book", + "catalogue", + "conference-paper", + "conference", + "data", + "database", + "dictionary", + "edited-work", + "encyclopedia", + "film-broadcast", + "generic", + "government-document", + "grant", + "hearing", + "historical-work", + "legal-case", + "legal-rule", + "magazine-article", + "manual", + "map", + "multimedia", + "music", + "newspaper-article", + "pamphlet", + "patent", + "personal-communication", + "proceedings", + "report", + "serial", + "slides", + "software-code", + "software-container", + "software-executable", + "software-virtual-machine", + "software", + "sound-recording", + "standard", + "statute", + "thesis", + "unpublished", + "video", + "website" + ], + "type": "string" + }, + "url": { + "$ref": "#/definitions/url", + "description": "The URL of the work." + }, + "version": { + "$ref": "#/definitions/version", + "description": "The version of the work." + }, + "volume": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The volume of the periodical in which a work appeared." + }, + "volume-title": { + "description": "The title of the volume in which the work appeared.", + "minLength": 1, + "type": "string" + }, + "year": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The year in which a work has been published." + }, + "year-original": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The year of the original publication." + } + }, + "required": [ + "authors", + "title", + "type" + ], + "type": "object" + }, + "region": { + "description": "A region.", + "minLength": 1, + "type": "string" + }, + "swh-identifier": { + "$comment": "Software Heritage identifiers are documented here: https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html.", + "description": "The Software Heritage identifier (without further qualifiers such as origin, visit, anchor, path).", + "examples": [ + "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2", + "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505", + "swh:1:rev:309cf2674ee7a0749978cf8265ab91a60aea0f7d", + "swh:1:rel:22ece559cc7cc2364edc5e5593d63ae8bd229f9f", + "swh:1:snp:c7c108084bc0bf3d81436bf980b46e98bd338453" + ], + "pattern": "^swh:1:(snp|rel|rev|dir|cnt):[0-9a-fA-F]{40}$", + "type": "string" + }, + "tel": { + "description": "A phone number.", + "minLength": 1, + "type": "string" + }, + "url": { + "format": "uri", + "pattern": "^(https|http|ftp|sftp)://.+", + "type": "string" + }, + "version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "description": "A file with citation metadata for software or datasets.", + "properties": { + "abstract": { + "description": "A description of the software or dataset.", + "minLength": 1, + "type": "string" + }, + "authors": { + "description": "The author(s) of the software or dataset.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "cff-version": { + "description": "The version of CFF used for providing the citation metadata.", + "examples": [ + "1.2.0" + ], + "pattern": "^1\\.2\\.0$", + "type": "string" + }, + "commit": { + "$ref": "#/definitions/commit" + }, + "contact": { + "description": "The contact person, group, company, etc. for the software or dataset.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "date-released": { + "$ref": "#/definitions/date", + "description": "The date the work has been released." + }, + "doi": { + "$ref": "#/definitions/doi" + }, + "identifiers": { + "description": "The identifiers of the software or dataset.", + "items": { + "$ref": "#/definitions/identifier" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "keywords": { + "description": "Keywords that describe the work.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "license": { + "$ref": "#/definitions/license" + }, + "license-url": { + "$ref": "#/definitions/url", + "description": "The URL of the license text under which the software or dataset is licensed (only for non-standard licenses not included in the SPDX License List)." + }, + "message": { + "default": "If you use this software, please cite it using the metadata from this file.", + "description": "A message to the human reader of the file to let them know what to do with the citation metadata.", + "examples": [ + "If you use this software, please cite it using the metadata from this file.", + "Please cite this software using these metadata.", + "Please cite this software using the metadata from 'preferred-citation'." + ], + "minLength": 1, + "type": "string" + }, + "preferred-citation": { + "$ref": "#/definitions/reference", + "description": "A reference to another work that should be cited instead of the software or dataset itself." + }, + "references": { + "description": "Reference(s) to other creative works.", + "items": { + "$ref": "#/definitions/reference" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "repository": { + "$ref": "#/definitions/url", + "description": "The URL of the software or dataset in a repository (when the repository is neither a source code repository nor a build artifact repository).", + "examples": [ + "https://edoc.hu-berlin.de/handle/18452/23016", + "https://ascl.net/2105.013" + ] + }, + "repository-artifact": { + "$ref": "#/definitions/url", + "description": "The URL of the software in a build artifact/binary repository." + }, + "repository-code": { + "$ref": "#/definitions/url", + "description": "The URL of the software or dataset in a source code repository." + }, + "title": { + "description": "The name of the software or dataset.", + "minLength": 1, + "type": "string" + }, + "type": { + "default": "software", + "description": "The type of the work.", + "enum": [ + "dataset", + "software" + ], + "type": "string" + }, + "url": { + "$ref": "#/definitions/url", + "description": "The URL of a landing page/website for the software or dataset." + }, + "version": { + "$ref": "#/definitions/version", + "description": "The version of the software or dataset." + } + }, + "required": [ + "authors", + "cff-version", + "message", + "title" + ], + "title": "Citation File Format", + "type": "object" +} diff --git a/scripts/validate-citation.py b/scripts/validate-citation.py index f31d8c8..324d717 100644 --- a/scripts/validate-citation.py +++ b/scripts/validate-citation.py @@ -3,6 +3,8 @@ from __future__ import annotations +import hashlib +import json import re import sys from datetime import date @@ -11,10 +13,13 @@ from urllib.parse import urlsplit import yaml +from jsonschema import Draft7Validator, FormatChecker ROOT = Path(__file__).resolve().parents[1] CITATION_PATH = ROOT / "CITATION.cff" +CFF_SCHEMA_PATH = ROOT / "schemas" / "cff" / "cff-1.2.0.schema.json" +CFF_SCHEMA_SHA256 = "0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b" DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$") SPDX_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*$") @@ -49,7 +54,37 @@ def _nonempty_string(value: Any, field: str) -> str: return value -def validate_citation(path: Path = CITATION_PATH) -> None: +def load_verified_schema( + path: Path = CFF_SCHEMA_PATH, + expected_sha256: str = CFF_SCHEMA_SHA256, +) -> dict[str, Any]: + content = path.read_bytes() + actual = hashlib.sha256(content).hexdigest() + if actual != expected_sha256: + raise CitationError( + f"CFF schema SHA-256 mismatch: expected {expected_sha256}, found {actual}" + ) + schema = json.loads(content, object_pairs_hook=_reject_json_duplicate_keys) + if not isinstance(schema, dict): + raise CitationError("CFF schema must be a JSON object") + Draft7Validator.check_schema(schema) + return schema + + +def _reject_json_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CitationError(f"duplicate CFF schema key: {key}") + result[key] = value + return result + + +def validate_citation( + path: Path = CITATION_PATH, + schema_path: Path = CFF_SCHEMA_PATH, + expected_schema_sha256: str = CFF_SCHEMA_SHA256, +) -> None: document = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) if not isinstance(document, dict): raise CitationError("CITATION.cff must contain a YAML mapping") @@ -98,6 +133,16 @@ def validate_citation(path: Path = CITATION_PATH) -> None: except ValueError as exc: raise CitationError("date-released is not a valid calendar date") from exc + schema = load_verified_schema(schema_path, expected_schema_sha256) + schema_errors = sorted( + Draft7Validator(schema, format_checker=FormatChecker()).iter_errors(document), + key=lambda error: list(error.absolute_path), + ) + if schema_errors: + error = schema_errors[0] + location = ".".join(str(part) for part in error.absolute_path) or "" + raise CitationError(f"official CFF 1.2.0 schema rejected {location}: {error.message}") + def main() -> int: try: @@ -105,7 +150,10 @@ def main() -> int: except (OSError, UnicodeError, yaml.YAMLError, CitationError) as exc: print(f"CFF validation failed: {exc}", file=sys.stderr) return 1 - print("CITATION.cff validation PASSED for CFF 1.2.0.") + print( + "CITATION.cff validation PASSED against the checksum-verified " + "official CFF 1.2.0 schema." + ) return 0 diff --git a/scripts/validate-supply-chain.py b/scripts/validate-supply-chain.py index 3957dd0..e0f6dcb 100644 --- a/scripts/validate-supply-chain.py +++ b/scripts/validate-supply-chain.py @@ -3,6 +3,7 @@ from __future__ import annotations +import hashlib import re import sys from pathlib import Path @@ -15,12 +16,11 @@ PIN_RE = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s;\\]+)") HASH_RE = re.compile(r"--hash=sha256:[0-9a-f]{64}(?:\s|$)") ACTION_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$") +DOCKER_DIGEST_RE = re.compile(r"^docker://[^@\s]+@sha256:[0-9a-f]{64}$") VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$") -CFF_ACTION = ( - "citation-file-format/cffconvert-github-action@" - "4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084" -) +CFF_SCHEMA_PATH = Path("schemas/cff/cff-1.2.0.schema.json") +CFF_SCHEMA_SHA256 = "0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b" class SupplyChainError(ValueError): @@ -76,6 +76,8 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: if "pull_request_target" in raw_text: raise SupplyChainError("pull_request_target is not allowed") + if "citation-file-format/cffconvert-github-action@" in raw_text: + raise SupplyChainError("CFF Action wrapper hides a mutable tag-only container") if data.get("permissions") != {"contents": "read"}: raise SupplyChainError("workflow permissions must be exactly contents: read") jobs = data.get("jobs") @@ -84,7 +86,6 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: checkout_found = False python_found = False - cff_validator_found = False for job in jobs.values(): if job.get("runs-on") == "ubuntu-latest": raise SupplyChainError("runner image must not use ubuntu-latest") @@ -93,7 +94,9 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: action = step.get("uses") if action and not str(action).startswith("./"): action_ref = str(action) - if not ACTION_RE.fullmatch(action_ref): + if action_ref.startswith("docker://"): + validate_docker_reference(action_ref) + elif not ACTION_RE.fullmatch(action_ref): raise SupplyChainError( f"external action is not pinned to a full SHA: {action_ref}" ) @@ -111,8 +114,6 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: version = str(step.get("with", {}).get("python-version", "")) if not VERSION_RE.fullmatch(version): raise SupplyChainError("Python must be pinned to an exact patch version") - if action_ref == CFF_ACTION: - cff_validator_found = step.get("with", {}).get("args") == "--validate" run = str(step.get("run", "")) if "curl " in run and "sha256sum -c -" not in run: @@ -129,8 +130,6 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: raise SupplyChainError("pinned actions/checkout step not found") if not python_found: raise SupplyChainError("pinned actions/setup-python step not found") - if not cff_validator_found: - raise SupplyChainError("official CFF schema validator is missing or misconfigured") for required_command in ( "python scripts/validate-citation.py", "python scripts/validate-control-traceability.py", @@ -143,6 +142,22 @@ def validate_workflow(data: dict[str, Any], raw_text: str) -> None: raise SupplyChainError("workflow must validate version tags matching v*") +def validate_docker_reference(reference: str) -> None: + if not DOCKER_DIGEST_RE.fullmatch(reference): + raise SupplyChainError( + f"Docker action must use an exact SHA-256 image digest: {reference}" + ) + + +def validate_cff_schema_artifact(root: Path = ROOT) -> None: + path = root / CFF_SCHEMA_PATH + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != CFF_SCHEMA_SHA256: + raise SupplyChainError( + f"official CFF schema digest mismatch: expected {CFF_SCHEMA_SHA256}, found {actual}" + ) + + def validate_dependabot(data: dict[str, Any]) -> None: updates = data.get("updates", []) ecosystems = {entry.get("package-ecosystem") for entry in updates} @@ -156,6 +171,7 @@ def validate_dependabot(data: dict[str, Any]) -> None: def validate_repository(root: Path = ROOT) -> None: + validate_cff_schema_artifact(root) requirements = validate_pinned_requirements( (root / "requirements.txt").read_text(encoding="utf-8"), require_hashes=False, diff --git a/tests/test_citation.py b/tests/test_citation.py index b3496ce..efe926d 100644 --- a/tests/test_citation.py +++ b/tests/test_citation.py @@ -49,3 +49,22 @@ def test_citation_rejects_duplicate_keys(tmp_path): with pytest.raises(citation.CitationError, match="duplicate YAML mapping key"): citation.validate_citation(path) + + +def test_citation_rejects_modified_official_schema(tmp_path): + schema = tmp_path / "cff-schema.json" + schema.write_bytes(citation.CFF_SCHEMA_PATH.read_bytes() + b"\n") + + with pytest.raises(citation.CitationError, match="schema SHA-256 mismatch"): + citation.validate_citation(schema_path=schema) + + +def test_official_schema_rejects_unknown_cff_field(tmp_path): + path = tmp_path / "CITATION.cff" + path.write_text( + Path("CITATION.cff").read_text(encoding="utf-8") + "unknown-field: rejected\n", + encoding="utf-8", + ) + + with pytest.raises(citation.CitationError, match="official CFF 1.2.0 schema rejected"): + citation.validate_citation(path) diff --git a/tests/test_supply_chain.py b/tests/test_supply_chain.py index 4949d55..6f97152 100644 --- a/tests/test_supply_chain.py +++ b/tests/test_supply_chain.py @@ -39,18 +39,40 @@ def test_workflow_requires_release_readiness_validators() -> None: MODULE.validate_workflow(workflow, modified) -def test_workflow_requires_official_cff_schema_validator() -> None: - path = ROOT / ".github" / "workflows" / "f7las-ci.yml" - text = path.read_text(encoding="utf-8") - workflow = yaml.safe_load(text) - workflow["jobs"]["validate"]["steps"] = [ - step - for step in workflow["jobs"]["validate"]["steps"] - if step.get("uses") != MODULE.CFF_ACTION - ] +def test_tag_only_docker_action_is_rejected() -> None: + with pytest.raises(MODULE.SupplyChainError, match="exact SHA-256 image digest"): + MODULE.validate_docker_reference("docker://citationcff/cffconvert:2.0.0") - with pytest.raises(MODULE.SupplyChainError, match="official CFF schema validator"): - MODULE.validate_workflow(workflow, text) + +def test_malformed_docker_digest_is_rejected() -> None: + with pytest.raises(MODULE.SupplyChainError, match="exact SHA-256 image digest"): + MODULE.validate_docker_reference("docker://citationcff/cffconvert@sha256:1234") + + +def test_exact_docker_digest_is_accepted() -> None: + MODULE.validate_docker_reference( + "docker://citationcff/cffconvert@sha256:" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + + +def test_cff_action_wrapper_is_rejected() -> None: + workflow = {"permissions": {"contents": "read"}, "jobs": {"validate": {}}} + with pytest.raises(MODULE.SupplyChainError, match="mutable tag-only container"): + MODULE.validate_workflow( + workflow, + "uses: citation-file-format/cffconvert-github-action@" + "4cf11baa70a673bfdf9dad0acc7ee33b3f4b6084", + ) + + +def test_modified_cff_schema_artifact_is_rejected(tmp_path) -> None: + target = tmp_path / MODULE.CFF_SCHEMA_PATH + target.parent.mkdir(parents=True) + target.write_bytes((ROOT / MODULE.CFF_SCHEMA_PATH).read_bytes() + b"\n") + + with pytest.raises(MODULE.SupplyChainError, match="schema digest mismatch"): + MODULE.validate_cff_schema_artifact(tmp_path) def test_unpinned_requirement_is_rejected() -> None: