diff --git a/.github/scripts/summarize_cargo_metadata.py b/.github/scripts/summarize_cargo_metadata.py new file mode 100644 index 00000000..7947dd74 --- /dev/null +++ b/.github/scripts/summarize_cargo_metadata.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Summarize exact Cargo metadata for AF-01 T020 dependency-policy inspection.""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from typing import Any + +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + + +class InventoryError(ValueError): + """Raised when Cargo metadata cannot support a complete exact inventory.""" + + +def require_string(value: object, message: str) -> str: + if not isinstance(value, str) or not value: + raise InventoryError(message) + return value + + +def validate_manifest_dependencies(package: dict[str, Any], identity: str) -> None: + dependencies = package.get("dependencies") + if not isinstance(dependencies, list): + raise InventoryError(f"manifest dependencies for {identity} are not an array") + for index, dependency in enumerate(dependencies): + if not isinstance(dependency, dict): + raise InventoryError( + f"manifest dependency {index} for {identity} is not an object" + ) + require_string( + dependency.get("name"), + f"manifest dependency {index} for {identity} has invalid name", + ) + + +def summarize(metadata: object) -> dict[str, object]: + if not isinstance(metadata, dict): + raise InventoryError("cargo metadata root is not an object") + + packages = metadata.get("packages") + workspace_members = metadata.get("workspace_members") + resolve = metadata.get("resolve") + if not isinstance(packages, list) or not isinstance(workspace_members, list): + raise InventoryError("cargo metadata missing package/workspace arrays") + if not isinstance(resolve, dict) or not isinstance(resolve.get("nodes"), list): + raise InventoryError("cargo metadata missing resolved dependency graph") + + workspace_ids: set[str] = set() + for index, member in enumerate(workspace_members): + workspace_ids.add( + require_string(member, f"workspace member {index} is not a package id") + ) + + package_records: dict[str, dict[str, object]] = {} + versions_by_name: dict[str, set[str]] = defaultdict(set) + license_packages: dict[str, list[str]] = defaultdict(list) + source_classes: dict[str, int] = defaultdict(int) + unknown_license: list[str] = [] + non_crates_io: list[str] = [] + + for index, package in enumerate(packages): + if not isinstance(package, dict): + raise InventoryError(f"cargo metadata package {index} is not an object") + + package_id = require_string(package.get("id"), "package identity is incomplete") + name = require_string(package.get("name"), "package identity is incomplete") + version = require_string(package.get("version"), "package identity is incomplete") + if package_id in package_records: + raise InventoryError(f"duplicate package id in cargo metadata: {package_id}") + + source = package.get("source") + license_expr = package.get("license") + if source is not None and not isinstance(source, str): + raise InventoryError(f"invalid source for {name} {version}") + if license_expr is not None and not isinstance(license_expr, str): + raise InventoryError(f"invalid license for {name} {version}") + + identity = f"{name}@{version}" + validate_manifest_dependencies(package, identity) + + workspace = package_id in workspace_ids + source_class = ( + "workspace" + if workspace + else ("crates.io" if source == CRATES_IO_SOURCE else "other") + ) + source_classes[source_class] += 1 + versions_by_name[name].add(version) + if not workspace: + if license_expr: + license_packages[license_expr].append(identity) + else: + unknown_license.append(identity) + if source != CRATES_IO_SOURCE: + non_crates_io.append(f"{identity}:{source or ''}") + + package_records[package_id] = { + "dependencies": [], + "license": license_expr, + "name": name, + "package_id": package_id, + "source": source, + "source_class": source_class, + "version": version, + "workspace": workspace, + } + + unknown_workspace_ids = sorted(workspace_ids - set(package_records)) + if unknown_workspace_ids: + raise InventoryError( + "workspace members reference unknown package ids: " + + ", ".join(unknown_workspace_ids) + ) + + resolved_ids: set[str] = set() + for index, node in enumerate(resolve["nodes"]): + if not isinstance(node, dict): + raise InventoryError(f"resolved node {index} is not an object") + node_id = require_string(node.get("id"), f"resolved node {index} has invalid id") + if node_id not in package_records: + raise InventoryError(f"resolved node references unknown package id: {node_id}") + if node_id in resolved_ids: + raise InventoryError(f"duplicate resolved node for package id: {node_id}") + resolved_ids.add(node_id) + + dependency_ids = node.get("dependencies") + deps = node.get("deps") + if not isinstance(dependency_ids, list) or not isinstance(deps, list): + raise InventoryError(f"resolved node {node_id} has invalid dependency arrays") + + exact_dependency_ids: list[str] = [] + for dep_index, dependency_id in enumerate(dependency_ids): + exact_dependency_ids.append( + require_string( + dependency_id, + f"resolved dependency {dep_index} for {node_id} has invalid package id", + ) + ) + + resolved_edges: list[dict[str, object]] = [] + edge_package_ids: list[str] = [] + for dep_index, dep in enumerate(deps): + if not isinstance(dep, dict): + raise InventoryError( + f"resolved dependency edge {dep_index} for {node_id} is not an object" + ) + edge_name = require_string( + dep.get("name"), + f"resolved dependency edge {dep_index} for {node_id} has invalid name", + ) + target_id = require_string( + dep.get("pkg"), + f"resolved dependency edge {dep_index} for {node_id} has invalid package id", + ) + target = package_records.get(target_id) + if target is None: + raise InventoryError( + f"resolved dependency edge for {node_id} references unknown package id: {target_id}" + ) + edge_package_ids.append(target_id) + resolved_edges.append( + { + "name": edge_name, + "package_id": target_id, + "package_name": target["name"], + "source": target["source"], + "version": target["version"], + } + ) + + if sorted(set(exact_dependency_ids)) != sorted(set(edge_package_ids)): + raise InventoryError( + f"resolved dependency representations disagree for package id: {node_id}" + ) + + resolved_edges.sort( + key=lambda edge: ( + str(edge["name"]), + str(edge["package_name"]), + str(edge["version"]), + str(edge["source"]), + str(edge["package_id"]), + ) + ) + package_records[node_id]["dependencies"] = resolved_edges + + missing_resolved_ids = sorted(set(package_records) - resolved_ids) + if missing_resolved_ids: + raise InventoryError( + "packages missing from resolved dependency graph: " + + ", ".join(missing_resolved_ids) + ) + + inventory = sorted( + package_records.values(), + key=lambda item: ( + str(item["name"]), + str(item["version"]), + str(item["source"]), + str(item["package_id"]), + ), + ) + duplicates = { + name: sorted(versions) + for name, versions in sorted(versions_by_name.items()) + if len(versions) > 1 + } + licenses = { + expression: sorted(identities) + for expression, identities in sorted(license_packages.items()) + } + return { + "duplicates": duplicates, + "licenses": licenses, + "non_crates_io": sorted(non_crates_io), + "ok": not unknown_license, + "package_count": len(inventory), + "packages": inventory, + "schema": 2, + "source_classes": dict(sorted(source_classes.items())), + "unknown_license": sorted(unknown_license), + } + + +def main() -> int: + try: + metadata = json.load(sys.stdin) + result = summarize(metadata) + except (json.JSONDecodeError, OSError, InventoryError) as error: + print( + json.dumps( + {"error": f"invalid cargo metadata: {error}", "ok": False}, + sort_keys=True, + ) + ) + return 1 + + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_audit_workflow_trust_af01_coverage.py b/.github/scripts/test_audit_workflow_trust_af01_coverage.py new file mode 100644 index 00000000..fcf41fc4 --- /dev/null +++ b/.github/scripts/test_audit_workflow_trust_af01_coverage.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import sys +import tomllib +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).with_name("audit_workflow_trust.py") +SPEC = importlib.util.spec_from_file_location("audit_workflow_trust_af01_coverage_target", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +AUDIT = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = AUDIT +SPEC.loader.exec_module(AUDIT) + +SUMMARY_TEST_PATH = Path(__file__).with_name("test_summarize_cargo_metadata.py") +SUMMARY_TEST_SPEC = importlib.util.spec_from_file_location( + "summarize_cargo_metadata_regression_tests", SUMMARY_TEST_PATH +) +assert SUMMARY_TEST_SPEC is not None and SUMMARY_TEST_SPEC.loader is not None +SUMMARY_TESTS = importlib.util.module_from_spec(SUMMARY_TEST_SPEC) +sys.modules[SUMMARY_TEST_SPEC.name] = SUMMARY_TESTS +SUMMARY_TEST_SPEC.loader.exec_module(SUMMARY_TESTS) +# The existing universal AF-01 unittest discovery pattern loads this module. Re-export +# the inventory regression suite so exact-graph tests cannot be skipped by that gate. +CargoMetadataSummaryTests = SUMMARY_TESTS.CargoMetadataSummaryTests + +ROOT = Path(__file__).resolve().parents[2] +SECURITY_WORKFLOW = ROOT / ".github" / "workflows" / "af01-security.yml" +DENY_POLICY = ROOT / "deny.toml" +CLI_MANIFEST = ROOT / "crates" / "commandf-cli" / "Cargo.toml" + + +class Af01SecurityCoverageTests(unittest.TestCase): + @staticmethod + def pull_request_children(text: str) -> list[str]: + lines = text.splitlines() + try: + start = lines.index(" pull_request:") + except ValueError as error: + raise AssertionError("af01-security must have a pull_request trigger") from error + + children: list[str] = [] + for line in lines[start + 1 :]: + if line and not line.startswith(" "): + break + if line.startswith(" ") and not line.startswith(" ") and line.strip(): + break + if line.strip(): + children.append(line.strip()) + return children + + def test_stack_b_security_gate_is_universal_for_pull_requests(self) -> None: + text = SECURITY_WORKFLOW.read_text(encoding="utf-8") + children = self.pull_request_children(text) + self.assertFalse( + any(child.startswith(("paths:", "paths-ignore:")) for child in children), + "AF-01 Stack B must remain universal so policy/config/action metadata changes cannot bypass it", + ) + + covered_surfaces = ( + "deny.toml", + ".github/workflow-trust-policy.json", + ".github/workflows/af01-security.yml", + ".github/scripts/audit_workflow_trust.py", + ".github/scripts/summarize_cargo_metadata.py", + ".github/scripts/test_summarize_cargo_metadata.py", + "action.yml", + "nested/action.yaml", + ) + for surface in covered_surfaces: + with self.subTest(surface=surface): + self.assertEqual(children, [], f"universal pull_request coverage must include {surface}") + + def test_action_metadata_discovery_covers_both_supported_filenames(self) -> None: + workflows, actions = AUDIT.discover_security_files( + [ + ".github/workflows/af01-security.yml", + "action.yml", + "nested/action.yaml", + "nested/not-an-action.yml", + ] + ) + self.assertEqual(workflows, [".github/workflows/af01-security.yml"]) + self.assertEqual(actions, ["action.yml", "nested/action.yaml"]) + + def test_private_path_dependency_has_no_global_wildcard_bypass(self) -> None: + deny_policy = tomllib.loads(DENY_POLICY.read_text(encoding="utf-8")) + self.assertFalse( + deny_policy["bans"].get("allow-wildcard-paths", False), + "private path dependencies must remain subject to the wildcard requirement policy", + ) + + manifest = tomllib.loads(CLI_MANIFEST.read_text(encoding="utf-8")) + commandf_pkg = manifest["dependencies"]["commandf-pkg"] + self.assertEqual(commandf_pkg["path"], "../commandf-pkg") + self.assertEqual( + commandf_pkg["version"], + "=0.0.0", + "the intended workspace edge must carry an exact version requirement instead of a global bypass", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_summarize_cargo_metadata.py b/.github/scripts/test_summarize_cargo_metadata.py new file mode 100644 index 00000000..27461a4c --- /dev/null +++ b/.github/scripts/test_summarize_cargo_metadata.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).with_name("summarize_cargo_metadata.py") +SPEC = importlib.util.spec_from_file_location("summarize_cargo_metadata_target", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +SUMMARY = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = SUMMARY +SPEC.loader.exec_module(SUMMARY) + +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" + + +def package( + package_id: str, + name: str, + version: str, + *, + source: str | None = CRATES_IO_SOURCE, + license_expr: str | None = "MIT", + dependencies: object | None = None, +) -> dict[str, object]: + return { + "id": package_id, + "name": name, + "version": version, + "source": source, + "license": license_expr, + "dependencies": [] if dependencies is None else dependencies, + } + + +def valid_metadata() -> dict[str, object]: + root_id = "path+file:///workspace/commandf#0.1.0" + old_id = "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.2.17" + new_id = "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.3" + return { + "packages": [ + package( + root_id, + "commandf", + "0.1.0", + source=None, + license_expr=None, + dependencies=[ + {"name": "rand_old"}, + {"name": "rand_new"}, + ], + ), + package(old_id, "getrandom", "0.2.17"), + package(new_id, "getrandom", "0.4.3"), + ], + "workspace_members": [root_id], + "resolve": { + "nodes": [ + { + "id": root_id, + "dependencies": [old_id, new_id], + "deps": [ + {"name": "rand_old", "pkg": old_id, "dep_kinds": []}, + {"name": "rand_new", "pkg": new_id, "dep_kinds": []}, + ], + }, + {"id": old_id, "dependencies": [], "deps": []}, + {"id": new_id, "dependencies": [], "deps": []}, + ] + }, + } + + +class CargoMetadataSummaryTests(unittest.TestCase): + def test_resolved_edges_preserve_exact_selected_package_identity(self) -> None: + result = SUMMARY.summarize(valid_metadata()) + self.assertEqual(result["schema"], 2) + root = next(item for item in result["packages"] if item["name"] == "commandf") + self.assertEqual( + [ + (edge["name"], edge["package_name"], edge["version"], edge["package_id"]) + for edge in root["dependencies"] + ], + [ + ( + "rand_new", + "getrandom", + "0.4.3", + "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.3", + ), + ( + "rand_old", + "getrandom", + "0.2.17", + "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.2.17", + ), + ], + ) + self.assertEqual(result["duplicates"], {"getrandom": ["0.2.17", "0.4.3"]}) + + def test_manifest_dependencies_must_be_an_array(self) -> None: + metadata = valid_metadata() + metadata["packages"][0]["dependencies"] = {"name": "hidden"} + with self.assertRaisesRegex(SUMMARY.InventoryError, "not an array"): + SUMMARY.summarize(metadata) + + def test_manifest_dependency_record_must_be_an_object(self) -> None: + metadata = valid_metadata() + metadata["packages"][0]["dependencies"] = ["hidden"] + with self.assertRaisesRegex(SUMMARY.InventoryError, "is not an object"): + SUMMARY.summarize(metadata) + + def test_manifest_dependency_name_must_be_a_string(self) -> None: + metadata = valid_metadata() + metadata["packages"][0]["dependencies"] = [{"name": None}] + with self.assertRaisesRegex(SUMMARY.InventoryError, "invalid name"): + SUMMARY.summarize(metadata) + + def test_resolved_edge_requires_known_exact_package_id(self) -> None: + metadata = valid_metadata() + root = metadata["resolve"]["nodes"][0] + missing = "registry+https://github.com/rust-lang/crates.io-index#getrandom@9.9.9" + root["dependencies"] = [missing] + root["deps"] = [{"name": "missing", "pkg": missing, "dep_kinds": []}] + with self.assertRaisesRegex(SUMMARY.InventoryError, "unknown package id"): + SUMMARY.summarize(metadata) + + def test_resolved_dependency_representations_must_agree(self) -> None: + metadata = valid_metadata() + root = metadata["resolve"]["nodes"][0] + root["dependencies"] = root["dependencies"][:1] + with self.assertRaisesRegex(SUMMARY.InventoryError, "representations disagree"): + SUMMARY.summarize(metadata) + + def test_every_package_requires_a_resolved_node(self) -> None: + metadata = valid_metadata() + metadata["resolve"]["nodes"].pop() + with self.assertRaisesRegex(SUMMARY.InventoryError, "missing from resolved dependency graph"): + SUMMARY.summarize(metadata) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflow-trust-policy.json b/.github/workflow-trust-policy.json index be17b51f..407a3f21 100644 --- a/.github/workflow-trust-policy.json +++ b/.github/workflow-trust-policy.json @@ -22,6 +22,38 @@ "require_external_uses_full_sha": "External Actions and reusable workflows must be bound to an immutable 40-hex commit so mutable tags or branches cannot change executed code without a repository diff." }, "workflows": { + ".github/workflows/af01-security.yml": { + "jobs": { + "cargo-audit": { + "permissions": { + "contents": "read" + }, + "runner": "ubuntu-24.04", + "timeout_minutes": 20 + }, + "cargo-deny": { + "permissions": { + "contents": "read" + }, + "runner": "ubuntu-24.04", + "timeout_minutes": 15 + }, + "dependency-inventory": { + "permissions": { + "contents": "read" + }, + "runner": "ubuntu-24.04", + "timeout_minutes": 10 + }, + "zizmor": { + "permissions": { + "contents": "read" + }, + "runner": "ubuntu-24.04", + "timeout_minutes": 15 + } + } + }, ".github/workflows/cf06-oracle.yml": { "jobs": { "oracle-changed-profile": { diff --git a/.github/workflows/af01-security.yml b/.github/workflows/af01-security.yml new file mode 100644 index 00000000..0cb0859f --- /dev/null +++ b/.github/workflows/af01-security.yml @@ -0,0 +1,229 @@ +name: af01-security + +on: + pull_request: + push: + branches: + - feat/af01-stack-b-dependency-security + +permissions: + contents: read + +env: + AF01_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + dependency-inventory: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24 + with: + ref: ${{ env.AF01_SOURCE_SHA }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@032958afbdc797a9164d3bc0b56325c1308924a5 # 1.97.1 + - name: Generate exact locked dependency inventory + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$AF01_SOURCE_SHA" + cargo metadata --locked --format-version 1 > /tmp/commandf-dependency-metadata.json + python3 .github/scripts/summarize_cargo_metadata.py \ + < /tmp/commandf-dependency-metadata.json \ + | tee af01-t020-dependency-inventory.json + - name: Upload exact dependency inventory + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: af01-t020-dependency-inventory + path: af01-t020-dependency-inventory.json + if-no-files-found: error + retention-days: 7 + + cargo-deny: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24 + with: + ref: ${{ env.AF01_SOURCE_SHA }} + persist-credentials: false + - name: Record cargo-deny proof identity + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$AF01_SOURCE_SHA" + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + def sha256(path: str) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + proof = { + "action_commit": "3c6349835b2b7b196a839186cb8b78e02f7b5f25", + "action_release": "v2.1.1", + "cargo_deny_version": "0.20.2", + "cargo_lock_sha256": sha256("Cargo.lock"), + "checks": ["advisories", "bans", "licenses", "sources"], + "command": "cargo deny --all-features check advisories bans licenses sources", + "deny_toml_sha256": sha256("deny.toml"), + "head_sha": os.environ["AF01_SOURCE_SHA"], + "schema": 1, + } + Path("af01-cargo-deny-proof.json").write_text( + json.dumps(proof, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(proof, indent=2, sort_keys=True)) + PY + - name: Enforce cargo-deny dependency policy + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 / cargo-deny 0.20.2 + with: + command: check + arguments: --all-features + command-arguments: advisories bans licenses sources + log-level: warn + - name: Upload cargo-deny proof identity + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: af01-cargo-deny-proof + path: af01-cargo-deny-proof.json + if-no-files-found: error + retention-days: 7 + + cargo-audit: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24 + with: + ref: ${{ env.AF01_SOURCE_SHA }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@032958afbdc797a9164d3bc0b56325c1308924a5 # 1.97.1 + - name: Assert exact source checkout + run: test "$(git rev-parse HEAD)" = "$AF01_SOURCE_SHA" + - name: Install pinned cargo-audit + run: cargo install cargo-audit --version 0.22.2 --locked + - name: Audit exact Cargo.lock + run: | + set +e + cargo audit --file Cargo.lock --json > af01-rustsec-audit.json + audit_status=$? + set -e + printf '%s\n' "$audit_status" > af01-rustsec-audit.exit + - name: Record cargo-audit and advisory database identity + if: always() + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + import subprocess + from pathlib import Path + + db = Path.home() / ".cargo" / "advisory-db" + exit_path = Path("af01-rustsec-audit.exit") + result_path = Path("af01-rustsec-audit.json") + if not exit_path.is_file() or not result_path.is_file() or not (db / ".git").is_dir(): + raise SystemExit("cargo-audit evidence or advisory database identity is missing") + + proof = { + "advisory_db_commit": subprocess.check_output( + ["git", "-C", str(db), "rev-parse", "HEAD"], text=True + ).strip(), + "advisory_db_origin": subprocess.check_output( + ["git", "-C", str(db), "remote", "get-url", "origin"], text=True + ).strip(), + "cargo_audit_version": "0.22.2", + "cargo_lock_sha256": hashlib.sha256(Path("Cargo.lock").read_bytes()).hexdigest(), + "command": "cargo audit --file Cargo.lock --json", + "exit_code": int(exit_path.read_text(encoding="utf-8").strip()), + "head_sha": os.environ["AF01_SOURCE_SHA"], + "schema": 1, + } + Path("af01-rustsec-audit-proof.json").write_text( + json.dumps(proof, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(proof, indent=2, sort_keys=True)) + PY + - name: Upload cargo-audit evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: af01-rustsec-audit + path: | + af01-rustsec-audit.json + af01-rustsec-audit.exit + af01-rustsec-audit-proof.json + if-no-files-found: error + retention-days: 7 + - name: Enforce cargo-audit result + if: always() + run: | + python3 - <<'PY' + from pathlib import Path + import sys + + path = Path("af01-rustsec-audit.exit") + if not path.is_file(): + raise SystemExit("cargo-audit exit evidence is missing") + sys.exit(int(path.read_text(encoding="utf-8").strip())) + PY + + zizmor: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24 + with: + ref: ${{ env.AF01_SOURCE_SHA }} + persist-credentials: false + - name: Record zizmor policy identity + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$AF01_SOURCE_SHA" + python3 - <<'PY' + import json + import os + from pathlib import Path + + proof = { + "action_commit": "3dc1ecc9bcb9e94e9b2c709687979e1298497054", + "action_release": "v0.6.2", + "advanced_security": False, + "collect": "all", + "head_sha": os.environ["AF01_SOURCE_SHA"], + "min_severity": "medium", + "online_audits": False, + "schema": 1, + "zizmor_version": "1.29.0", + } + Path("af01-zizmor-proof.json").write_text( + json.dumps(proof, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(proof, indent=2, sort_keys=True)) + PY + - name: Audit workflows and Actions with zizmor + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 / zizmor 1.29.0 + with: + inputs: . + collect: all + online-audits: false + persona: regular + min-severity: medium + version: 1.29.0 + advanced-security: false + color: false + annotations: false + fail-on-no-inputs: true + - name: Upload zizmor policy identity + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: af01-zizmor-proof + path: af01-zizmor-proof.json + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ebfc227..41f33c1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - uses: dtolnay/rust-toolchain@032958afbdc797a9164d3bc0b56325c1308924a5 # 1.97.1 with: components: rustfmt, clippy + - name: AF-01 T020 exact dependency metadata inventory + run: cargo metadata --locked --format-version 1 | python3 .github/scripts/summarize_cargo_metadata.py - name: AF-01 workflow trust audit tests run: python3 -m unittest discover -s .github/scripts -p 'test_audit_workflow_trust*.py' - name: AF-01 repository workflow trust audit diff --git a/crates/commandf-cli/Cargo.toml b/crates/commandf-cli/Cargo.toml index 98f2f8d0..d6d806f7 100644 --- a/crates/commandf-cli/Cargo.toml +++ b/crates/commandf-cli/Cargo.toml @@ -7,4 +7,4 @@ publish.workspace = true [dependencies] clap.workspace = true -commandf-pkg = { path = "../commandf-pkg" } +commandf-pkg = { path = "../commandf-pkg", version = "=0.0.0" } diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..803de375 --- /dev/null +++ b/deny.toml @@ -0,0 +1,59 @@ +# AF-01 T021 dependency trust policy. +# +# Derived from specs/015-af-01-trusted-development-baseline/stack-b-dependency-inventory.md. +# This policy intentionally starts without advisory, source, duplicate, or license waivers. + +[advisories] +ignore = [] + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", + "CDLA-Permissive-2.0", + "ISC", + "LGPL-2.1-or-later", + "MIT", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.8 +exceptions = [] + +[licenses.private] +# commandF workspace crates are unpublished (`publish = false`) and are not +# third-party dependency license authority. Dependency licenses remain checked. +ignore = true +registries = [] + +[bans] +# The T020 graph contains three transitive duplicate families. Keep them visible +# without turning the current lockfile into an opaque skip-list contract. +multiple-versions = "warn" +wildcards = "deny" +# Keep the wildcard rule effective for every dependency, including private path +# dependencies. The repository-owned commandf -> commandf-pkg edge carries an +# exact =0.0.0 requirement in the manifest instead of relying on a global bypass. +allow-wildcard-paths = false +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" +allow = [] +allow-workspace = false +deny = [] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[sources.allow-org] +github = [] +gitlab = [] +bitbucket = [] diff --git a/specs/015-af-01-trusted-development-baseline/security-waiver-policy.md b/specs/015-af-01-trusted-development-baseline/security-waiver-policy.md new file mode 100644 index 00000000..e4a64172 --- /dev/null +++ b/specs/015-af-01-trusted-development-baseline/security-waiver-policy.md @@ -0,0 +1,43 @@ +# AF-01 Security Waiver Policy + +Status: `T024_POLICY` + +AF-01 security findings and dependency/advisory exceptions are fail-closed by default. A waiver is a temporary, reviewable repository artifact; it is never an anonymous scanner ignore, a lowered severity threshold, or a permanent substitute for remediation. + +## Required waiver fields + +Every waiver MUST record all of the following: + +1. **Identity** — exact scanner/finding/advisory identifier and, when applicable, exact package name/version or workflow/action path. +2. **Rationale** — why the finding cannot be remediated immediately and why accepting it is justified for commandF's actual exposure. +3. **Scope** — the smallest affected package/version, file/path, job, action, or rule identity. Family-wide or wildcard scope is prohibited unless a separate canonical plan amendment authorizes it. +4. **Compensating evidence** — exact tests, configuration, reachability evidence, containment, or other controls that reduce the accepted risk. Assertions without inspectable evidence are insufficient. +5. **Revisit/removal condition** — a concrete condition that ends the waiver, such as an upstream fixed release, dependency migration, workflow redesign, or a bounded review date plus an owner decision. + +## Evidence binding + +A waiver MUST also record: + +- the repository task/spec authority that permits the exception; +- the exact tool and policy surface that consumes the waiver; +- the first canonical commit that introduced the waiver; +- links or identifiers for upstream advisories/issues when available; +- whether the finding is security, maintenance, license, source, duplicate, or workflow-authority related. + +## Prohibited waiver forms + +The following are not valid AF-01 waivers: + +- an unannotated advisory ID in an ignore list; +- a broad package-family, source-family, license-family, or workflow-directory wildcard; +- lowering a global severity threshold to make an existing finding disappear; +- disabling a scanner or audit because it reports a valid finding; +- treating a network/tool/reviewer failure as PASS; +- accepting a finding solely because another scanner did not report it; +- a waiver without a removal/revisit condition. + +## Review and removal + +Waiver introduction, widening, renewal, or removal is a security-relevant repository change and MUST pass the same exact-head AF-01 workflow/security gates and review discipline as the policy it affects. When the removal condition is met, the waiver must be deleted rather than silently retained. + +Any future machine-readable waiver representation must preserve these fields and fail closed when one is missing. T024 does not authorize any current waiver; AF-01 Stack B starts with zero advisory/security waivers. diff --git a/specs/015-af-01-trusted-development-baseline/stack-b-dependency-inventory.md b/specs/015-af-01-trusted-development-baseline/stack-b-dependency-inventory.md new file mode 100644 index 00000000..228f5ee9 --- /dev/null +++ b/specs/015-af-01-trusted-development-baseline/stack-b-dependency-inventory.md @@ -0,0 +1,233 @@ +# AF-01 Stack B Dependency Inventory and Policy Intent + +Status: `T020_COMPLETE_POLICY_INPUT` + +This document records the exact dependency/license/source evidence inspected before creating `deny.toml`, plus the observed Stack B scanner calibration used to freeze the initial policies. It is not the final T028 exact-head qualification record. + +## Canonical input + +Stack A canonical base: + +```text +main: 48587578e2d9167ac1c96b51c9942edb2aa74d8c +tree: 4f2ccef845321a7abba8ce5388e78281a1514436 +Cargo.lock blob: 69ba1936596a1f3acfef2908fe659c6dc6fe474a +``` + +Inspection command: + +```text +cargo metadata --locked --format-version 1 | python3 .github/scripts/summarize_cargo_metadata.py +``` + +Exact evidence run: + +```text +head: 6f0fa6f2a344e7c6f9adbade2851b6aed3c4d5f9 +workflow: af01-security +run: 33044282731 +job: dependency-inventory +artifact: 9635015658 +artifact name: af01-t020-dependency-inventory +artifact digest: sha256:370637ff98963b3804780bec3439275ab7c1ec80c63f123722d493fe2cd54247 +``` + +The artifact was generated from the locked graph and uploaded by a full-SHA-pinned `actions/upload-artifact` action. That initial evidence established package/license/source counts; later reviewer hardening changed the inventory representation from manifest dependency names to exact Cargo resolved edges, so final T028 evidence must come from the hardened schema described below rather than treating this early artifact as final graph-edge proof. + +## Exact graph summary + +```text +packages total: 133 +workspace packages: 2 +crates.io packages: 131 +other registry/git packages: 0 +packages with unknown license metadata: 0 +``` + +Workspace crates: + +- `commandf` +- `commandf-pkg` + +The current workspace direct dependency surface is declared in the root/member manifests and includes `clap`, `flate2`, `semver`, `serde`, `serde_json`, `sha2`, `tar`, `thiserror`, `ureq`, and `tempfile`, plus the local path dependency from `commandf` to `commandf-pkg`. + +### Resolved-graph authority and reviewer remediation + +Qodo identified two material correctness defects in the original T020 summarizer: + +1. malformed `packages[].dependencies` records could be silently filtered from the output; and +2. dependency relationships were projected from manifest declarations and reduced to names, so two resolved versions of the same crate could not be distinguished on individual edges. + +Both findings were remediated before T028 qualification. The inventory schema is now `2` and fails closed unless: + +- every package and manifest dependency record has the required structure and string identity fields; +- `resolve.nodes` exists and contains one unique node for every package in the resolved graph; +- every resolved edge has a dependency name plus exact target package ID; +- every edge target resolves to a known package record; +- Cargo's `dependencies` and `deps[].pkg` resolved-node representations agree; and +- every package in the metadata package set is represented by a resolved node. + +Each emitted dependency edge now records the dependency edge name plus the exact selected `package_id`, resolved package name, version, and source. This preserves distinctions such as `getrandom@0.2.17` versus `getrandom@0.4.3` instead of collapsing them to `getrandom`. + +Regression coverage in `.github/scripts/test_summarize_cargo_metadata.py` includes exact multi-version edge preservation, malformed dependency-array/record/name rejection, unknown resolved target rejection, disagreement between Cargo's two resolved dependency representations, and missing resolved-node rejection. The suite is re-exported through the existing universal AF-01 workflow-trust unittest discovery surface so these regressions cannot be omitted while that gate remains authoritative. + +The original Qodo threads became resolved/outdated only after the implementation changed. T029 still requires a fresh exact-head Qodo review; this remediation record is not a substitute for that review. + +### Private path wildcard boundary and CodeRabbit remediation + +A fresh CodeRabbit review of previously qualified head `45629125e8e09f7d6cd6869fee82e4ae00f8483c` identified a high-severity policy-scope defect: `allow-wildcard-paths = true` was a global cargo-deny exception for private path dependencies, not an exception scoped only to the intended `commandf -> commandf-pkg` workspace edge. + +The finding was fixed rather than waived: + +- `deny.toml` now sets `allow-wildcard-paths = false`, so `wildcards = "deny"` applies to private path dependencies too; +- the intended repository-owned edge is declared as `commandf-pkg = { path = "../commandf-pkg", version = "=0.0.0" }`, giving it an exact version requirement rather than relying on a global bypass; +- `.github/scripts/test_audit_workflow_trust_af01_coverage.py` parses both `deny.toml` and the CLI manifest and fails if the global bypass is re-enabled or if the intended edge loses its exact version requirement; and +- no cargo-deny skip, waiver, alternate source authority, or wildcard exception was added. + +Because this remediation changed `Cargo.toml`, all prior T028 workflow evidence and review evidence for `45629125e8e09f7d6cd6869fee82e4ae00f8483c` is stale. The replacement head must be requalified from scratch, including every proof workflow that becomes path-applicable because `Cargo.toml` changed. + +## Observed license expressions + +The exact locked graph contains the following distinct dependency license expressions: + +- `(MIT OR Apache-2.0) AND Unicode-3.0` +- `0BSD OR MIT OR Apache-2.0` +- `Apache-2.0 AND ISC` +- `Apache-2.0 OR ISC OR MIT` +- `Apache-2.0 OR MIT` +- `Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT` +- `BSD-3-Clause` +- `CDLA-Permissive-2.0` +- `ISC` +- `MIT` +- `MIT OR Apache-2.0` +- `MIT OR Apache-2.0 OR LGPL-2.1-or-later` +- `MIT OR Zlib OR Apache-2.0` +- `MIT/Apache-2.0` +- `Unicode-3.0` +- `Unlicense OR MIT` + +The SPDX license atoms required by the observed graph are therefore narrowly bounded to: + +```text +0BSD +Apache-2.0 +Apache-2.0 WITH LLVM-exception +BSD-3-Clause +CDLA-Permissive-2.0 +ISC +LGPL-2.1-or-later +MIT +Unicode-3.0 +Unlicense +Zlib +``` + +No broader license family, wildcard, or blanket approval is authorized by this inventory. + +### Legacy metadata boundary + +Two exact packages report the legacy non-SPDX expression `MIT/Apache-2.0` in Cargo metadata: + +- `filetime@0.2.29` +- `version_check@0.9.5` + +`cargo-deny 0.20.2` accepted the locked graph under the narrow atom allowlist without a package-specific legacy-expression waiver. No global malformed-license exception or package exception was added. + +The two workspace crates are unpublished (`publish = false`). `deny.toml` therefore ignores private workspace-crate license declarations while continuing to enforce every third-party dependency license. This is a repository-owned-boundary decision, not permission to ignore a private third-party registry. + +## Duplicate/version inventory + +The locked graph contains exactly three package names at multiple versions: + +```text +getrandom: 0.2.17, 0.4.3 +syn: 2.0.119, 3.0.3 +windows-sys: 0.52.0, 0.61.2 +``` + +These are current transitive graph facts, not silently approved permanent exceptions. + +T021 policy intent and implemented boundary: + +- keep duplicate versions visible and machine-diagnosable; +- use `multiple-versions = "warn"` initially rather than creating opaque skip lists for the current graph; +- keep wildcard dependency requirements fail-closed for registry, git, and private path dependencies; +- express the repository-owned unpublished `commandf -> commandf-pkg` path edge with the exact requirement `=0.0.0` instead of disabling wildcard enforcement for a class of dependencies; +- keep `allow-wildcard-paths = false`; and +- do not add `skip` or `skip-tree` entries unless a later exact finding proves a narrow necessity and records a reason/revisit condition. + +This boundary means a future private workspace crate cannot acquire an unversioned path dependency merely because it is private. Source policy remains independently fail-closed. + +## Source policy intent + +Observed source authority is only: + +- local workspace/path packages; and +- crates.io via `https://github.com/rust-lang/crates.io-index`. + +T021 therefore enforces: + +```text +unknown registry: deny +unknown git source: deny +allowed registry: crates.io only +allowed git sources: none +``` + +A future git dependency, alternate registry, or additional source authority requires a repository diff and explicit policy review; it cannot be admitted by a wildcard source rule. + +## Advisory policy intent + +At the policy layer: + +- RustSec advisories are not blanket-ignored; +- `advisories.ignore` starts empty; +- current/future advisory waivers are governed by T024 rather than handwritten anonymous ignore entries; +- yanked or vulnerable dependency evidence remains visible to the scanner gates; +- scanner transport/tooling failures are CI failures, not PASS-equivalent outcomes. + +T023 supplies an independent `cargo-audit` view so cargo-deny is not the only advisory signal. + +## License policy intent + +T021 allows only the observed SPDX atoms listed above and uses cargo-deny 0.20.2's exact configuration semantics. The policy: + +- has no wildcard license approval; +- ignores only the unpublished workspace crates as first-party license subjects while retaining third-party dependency checks; +- preserves `confidence-threshold = 0.8`; +- has no package/version license exception or skip list; +- fails a new dependency whose license cannot be matched to the checked-in policy. + +## Stack B scanner calibration + +The following run is calibration evidence, not the final T028 head qualification: + +```text +PR head: 66ba48fa7aaa895c8b3cd3d7fefbb82ec55abac7 +PR head tree: 53196a991a7ee579da0d72f4f0b8c5373ba7698a +GitHub PR merge-ref tree: 53196a991a7ee579da0d72f4f0b8c5373ba7698a +workflow: af01-security +run: 33050044070 +``` + +The PR head and GitHub's temporary merge ref had the same tree, so the scanner inputs were byte-identical. The workflow was subsequently hardened to checkout and attest `AF01_SOURCE_SHA` explicitly because the original proof JSON labeled GitHub's temporary `GITHUB_SHA` as `head_sha`. + +Observed calibration results: + +- dependency inventory: `SUCCESS` under the earlier representation, superseded for final resolved-edge proof by schema 2; +- cargo-deny action `v2.1.1` at commit `3c6349835b2b7b196a839186cb8b78e02f7b5f25`, cargo-deny `0.20.2`: `SUCCESS` for advisories, bans, licenses, and sources; +- RustSec cargo-audit `0.22.2`: exit `0` against exact `Cargo.lock`; +- RustSec advisory database origin: `https://github.com/RustSec/advisory-db.git`; +- observed advisory database commit: `a7bfe16948bf6f3ee25bdee4822209f87da21b80`; +- observed `Cargo.lock` SHA-256: `0c58bb1b2a78ad5ed7e196ef20622fb5673536146ab6e0f787eb2d5f6517cf66`; +- zizmor action `v0.6.2` at commit `3dc1ecc9bcb9e94e9b2c709687979e1298497054`, zizmor `1.29.0`, `min-severity=medium`, online audits disabled: `SUCCESS` with no blocking medium/high finding; +- security waivers: zero. + +This observed baseline freezes the initial zizmor gate at `medium`; it does not authorize lowering the threshold around a future finding. T026 therefore has no current zizmor high/medium finding to disposition. Any later finding must be fixed or explicitly dispositioned under the AF-01 waiver policy without silently weakening the gate. + +## T020 decision + +`T020 = COMPLETE` + +The current graph, source authority, license surface, duplicate families, resolved-edge representation, fail-closed inventory validation, exact private-path wildcard boundary, and initial scanner calibration are documented. T021–T027 implement the checked-in dependency/workflow security gates and waiver/coverage policy. Final Stack B PASS remains governed by T028 exact-head workflow evidence and T029 exact-head independent reviews plus canonical merge truth. diff --git a/specs/015-af-01-trusted-development-baseline/tasks.md b/specs/015-af-01-trusted-development-baseline/tasks.md index d5999196..f88317db 100644 --- a/specs/015-af-01-trusted-development-baseline/tasks.md +++ b/specs/015-af-01-trusted-development-baseline/tasks.md @@ -29,22 +29,22 @@ Depends on T005. - [x] **T014** Harden `.github/workflows/ci.yml` to full-SHA external Actions, credentialless checkout, explicit machine-checkable least permissions, fixed supported runner label, bounded timeout, and preserved existing semantic/test steps. - [x] **T015** Reconcile every other existing workflow and repository Action metadata file to the AF-01 baseline, including permission declarations and proof-critical container digest identity, without changing its product/oracle/proof semantics or path-filter authority except where later universal required-check aggregation is explicitly introduced. - [x] **T016** Add a regression that discovers both `action.yml` and `action.yaml` anywhere in the tracked tree and fails if a future workflow, Action metadata file, permission grant, external Action ref, checkout credential setting, or proof-critical container identity escapes AF-01 trust auditing. -- [ ] **T017** Run mandatory workspace gates and every path-applicable existing proof/oracle workflow on the exact Stack A head. -- [ ] **T018** Request CodeRabbit and Qodo on exact Stack A head; disposition every substantive returned finding and require zero unresolved material review threads. -- [ ] **T019** Merge Stack A only from its exact qualified head and record canonical merge/main/tree. +- [x] **T017** Run mandatory workspace gates and every path-applicable existing proof/oracle workflow on the exact Stack A head. +- [x] **T018** Request CodeRabbit and Qodo on exact Stack A head; disposition every substantive returned finding and require zero unresolved material review threads. +- [x] **T019** Merge Stack A only from its exact qualified head and record canonical merge/main/tree. ## Phase 2 / Stack B — dependency and CI security gates Depends on canonical T019. -- [ ] **T020** Inspect the exact current Cargo dependency graph and license/source metadata; document intended direct/transitive source and license policy before generating `deny.toml`. -- [ ] **T021** Add checked-in `deny.toml` covering licenses, bans/duplicates, advisories, and sources with narrow reviewed exceptions only. -- [ ] **T022** Add pinned `cargo-deny` execution in an independently diagnosable CI job; retain machine-readable or complete textual evidence. -- [ ] **T023** Add pinned RustSec `cargo-audit` execution against exact `Cargo.lock`; retain advisory database/tool identity where available. -- [ ] **T024** Define waiver documentation requirements for any advisory/security exception: identity, rationale, scope, compensating evidence, and revisit/removal condition. -- [ ] **T025** Add pinned `zizmor` audit over all repository workflows/actions; freeze initial severity policy from observed baseline rather than guessing around findings. -- [ ] **T026** Fix valid high/medium workflow findings or amend the plan/tasks with explicit reviewed disposition; do not lower the gate silently. -- [ ] **T027** Add regressions proving dependency/workflow security configurations and both Action metadata filename forms are included in relevant workflow path/coverage logic so policy mutations cannot bypass gates. +- [x] **T020** Inspect the exact current Cargo dependency graph and license/source metadata; document intended direct/transitive source and license policy before generating `deny.toml`. +- [x] **T021** Add checked-in `deny.toml` covering licenses, bans/duplicates, advisories, and sources with narrow reviewed exceptions only. +- [x] **T022** Add pinned `cargo-deny` execution in an independently diagnosable CI job; retain machine-readable or complete textual evidence. +- [x] **T023** Add pinned RustSec `cargo-audit` execution against exact `Cargo.lock`; retain advisory database/tool identity where available. +- [x] **T024** Define waiver documentation requirements for any advisory/security exception: identity, rationale, scope, compensating evidence, and revisit/removal condition. +- [x] **T025** Add pinned `zizmor` audit over all repository workflows/actions; freeze initial severity policy from observed baseline rather than guessing around findings. +- [x] **T026** Fix valid high/medium workflow findings or amend the plan/tasks with explicit reviewed disposition; do not lower the gate silently. +- [x] **T027** Add regressions proving dependency/workflow security configurations and both Action metadata filename forms are included in relevant workflow path/coverage logic so policy mutations cannot bypass gates. - [ ] **T028** Run mandatory workspace gates plus all path-applicable existing proof/oracle workflows on exact Stack B head. - [ ] **T029** Obtain and disposition CodeRabbit/Qodo review on exact Stack B head, merge only from exact qualified head, and record canonical merge/main/tree.