feat(mcp): add immutable external artifact mirror lane
This commit is contained in:
1330
scripts/security/external_mcp_artifact_controller.py
Normal file
1330
scripts/security/external_mcp_artifact_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
393
scripts/security/tests/test_external_mcp_artifact_controller.py
Normal file
393
scripts/security/tests/test_external_mcp_artifact_controller.py
Normal file
@@ -0,0 +1,393 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_artifact_controller.py"
|
||||
POLICY_PATH = ROOT / "config/mcp/playwright-mcp-artifact-policy.json"
|
||||
WORKFLOW_PATH = ROOT / ".gitea/workflows/mcp-external-artifact-mirror.yaml"
|
||||
RUN_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
TRACE_ID = f"mcp-artifact-{RUN_ID}"
|
||||
|
||||
|
||||
def _load_controller():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"external_mcp_artifact_controller", CONTROLLER_PATH
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
controller = _load_controller()
|
||||
|
||||
|
||||
def _verified_packages(policy):
|
||||
return tuple(
|
||||
controller.VerifiedPackage(
|
||||
policy=package,
|
||||
tarball=b"tarball",
|
||||
attestation=b"{}",
|
||||
metadata_sha256="a" * 64,
|
||||
tarball_sha512_hex=package.sha512_hex,
|
||||
tarball_sha1=package.shasum_sha1,
|
||||
attestation_sha256="b" * 64,
|
||||
unpacked_bytes=1024,
|
||||
archive_file_count=3,
|
||||
license_file_count=1,
|
||||
osv_response_sha256="c" * 64,
|
||||
vulnerability_ids=(),
|
||||
)
|
||||
for package in policy.packages
|
||||
)
|
||||
|
||||
|
||||
def _tarball(
|
||||
package,
|
||||
*,
|
||||
unsafe_symlink: bool = False,
|
||||
duplicate_manifest: bool = False,
|
||||
) -> bytes:
|
||||
package_json = json.dumps(
|
||||
{
|
||||
"name": package.name,
|
||||
"version": package.version,
|
||||
"license": package.license,
|
||||
"dependencies": package.dependencies,
|
||||
"optionalDependencies": package.optional_dependencies,
|
||||
}
|
||||
).encode()
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode="w:gz") as archive:
|
||||
for name, data in (
|
||||
("package/package.json", package_json),
|
||||
("package/LICENSE", b"Apache License 2.0"),
|
||||
):
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(data)
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
if duplicate_manifest:
|
||||
member = tarfile.TarInfo("package/package.json")
|
||||
member.size = len(package_json)
|
||||
archive.addfile(member, io.BytesIO(package_json))
|
||||
if unsafe_symlink:
|
||||
member = tarfile.TarInfo("package/escape")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = "../../outside"
|
||||
archive.addfile(member)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class ExternalMcpArtifactControllerTest(unittest.TestCase):
|
||||
def test_policy_is_exact_discovery_only_and_fail_closed(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
raw = POLICY_PATH.read_text(encoding="utf-8").lower()
|
||||
|
||||
self.assertEqual(policy.candidate_id, "external.playwright-verifier")
|
||||
self.assertEqual(policy.risk_level, "high")
|
||||
self.assertEqual(len(policy.packages), 3)
|
||||
self.assertEqual(policy.mirror_tag, "0.0.78-bundle-v1")
|
||||
self.assertNotIn("@latest", raw)
|
||||
self.assertNotIn(":latest", raw)
|
||||
self.assertNotIn("npx -y", raw)
|
||||
self.assertNotIn("github.com", raw)
|
||||
self.assertNotIn("ghcr.io", raw)
|
||||
self.assertTrue(all(package.version for package in policy.packages))
|
||||
self.assertTrue(all(package.sha512_hex for package in policy.packages))
|
||||
|
||||
def test_gitea_workflow_has_no_remote_action_or_floating_supply_chain(self) -> None:
|
||||
raw = WORKFLOW_PATH.read_text(encoding="utf-8")
|
||||
lowered = raw.lower()
|
||||
|
||||
self.assertNotIn("uses:", lowered)
|
||||
self.assertNotIn("actions/checkout", lowered)
|
||||
self.assertNotIn("github.com", lowered)
|
||||
self.assertNotIn("api.github.com", lowered)
|
||||
self.assertNotIn("raw.githubusercontent.com", lowered)
|
||||
self.assertNotIn("codeload.github.com", lowered)
|
||||
self.assertNotIn("ghcr.io", lowered)
|
||||
self.assertNotIn("npx -y", lowered)
|
||||
self.assertNotIn("@latest", lowered)
|
||||
self.assertNotIn(":latest", lowered)
|
||||
self.assertNotIn("git push --force", lowered)
|
||||
self.assertIn("runs-on: awoooi-non110-host", raw)
|
||||
self.assertIn("docker login", raw)
|
||||
self.assertIn("--password-stdin", raw)
|
||||
self.assertIn("docker logout", raw)
|
||||
self.assertIn("verify_external_mcp_artifact_receipt.py", raw)
|
||||
self.assertIn("git merge --no-edit gitea/main", raw)
|
||||
self.assertIn("git push gitea HEAD:main", raw)
|
||||
self.assertIn('cron: "13 2 * * 3"', raw)
|
||||
|
||||
def test_dependency_closure_rejects_drift(self) -> None:
|
||||
payload = json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
payload["packages"][0]["dependencies"]["playwright"] = "1.2.3"
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "policy.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError, "dependency_closure_invalid"
|
||||
):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_run_and_trace_identity_must_match(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError, "run_trace_identity_mismatch"
|
||||
):
|
||||
controller.main(
|
||||
[
|
||||
"--policy",
|
||||
str(POLICY_PATH),
|
||||
"--run-id",
|
||||
RUN_ID,
|
||||
"--trace-id",
|
||||
"mcp-artifact-00000000-0000-4000-8000-000000000000",
|
||||
"check",
|
||||
]
|
||||
)
|
||||
|
||||
def test_noncanonical_run_id_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(controller.ControllerError, "run_id_not_canonical"):
|
||||
controller.main(
|
||||
[
|
||||
"--policy",
|
||||
str(POLICY_PATH),
|
||||
"--run-id",
|
||||
RUN_ID.upper(),
|
||||
"--trace-id",
|
||||
TRACE_ID,
|
||||
"check",
|
||||
]
|
||||
)
|
||||
|
||||
def test_check_mode_emits_bound_no_write_receipt(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
verified = _verified_packages(policy)
|
||||
with (
|
||||
patch.object(controller, "verify_upstream_bundle", return_value=verified),
|
||||
patch.object(controller, "build_cyclonedx_sbom", return_value={}),
|
||||
patch.object(
|
||||
controller,
|
||||
"_write_bundle",
|
||||
return_value=("d" * 64, "e" * 64),
|
||||
),
|
||||
io.StringIO() as output,
|
||||
redirect_stdout(output),
|
||||
):
|
||||
result = controller.main(
|
||||
[
|
||||
"--policy",
|
||||
str(POLICY_PATH),
|
||||
"--run-id",
|
||||
RUN_ID,
|
||||
"--trace-id",
|
||||
TRACE_ID,
|
||||
"check",
|
||||
]
|
||||
)
|
||||
receipt = json.loads(output.getvalue())
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(receipt["run_id"], RUN_ID)
|
||||
self.assertEqual(receipt["trace_id"], TRACE_ID)
|
||||
self.assertEqual(
|
||||
receipt["status"], "completed_check_mode_pending_independent_verifier"
|
||||
)
|
||||
self.assertFalse(receipt["promotion_allowed"])
|
||||
self.assertFalse(receipt["canary_allowed"])
|
||||
self.assertFalse(
|
||||
receipt["stage_receipts"]["sensor_source_receipt"]
|
||||
["runtime_request_or_response_body_stored"]
|
||||
)
|
||||
self.assertTrue(
|
||||
receipt["stage_receipts"]["sensor_source_receipt"]
|
||||
["upstream_supply_chain_attestation_bundled"]
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt["stage_receipts"]["rollback_or_no_write_terminal"]["status"],
|
||||
"no_write_pending_external_verifier",
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt["stage_receipts"]["independent_post_verifier"]["status"],
|
||||
"pending_external_verifier",
|
||||
)
|
||||
|
||||
def test_mirror_apply_requires_receipt_path(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError, "apply_receipt_path_required"
|
||||
):
|
||||
controller.main(
|
||||
[
|
||||
"--policy",
|
||||
str(POLICY_PATH),
|
||||
"--run-id",
|
||||
RUN_ID,
|
||||
"--trace-id",
|
||||
TRACE_ID,
|
||||
"mirror",
|
||||
"--apply",
|
||||
]
|
||||
)
|
||||
|
||||
def test_tarball_inspection_accepts_exact_manifest(self) -> None:
|
||||
package = controller.load_policy(POLICY_PATH).packages[0]
|
||||
|
||||
unpacked, files, licenses = controller.inspect_tarball(
|
||||
package,
|
||||
_tarball(package),
|
||||
maximum_unpacked_bytes=1024 * 1024,
|
||||
)
|
||||
|
||||
self.assertGreater(unpacked, 0)
|
||||
self.assertEqual(files, 2)
|
||||
self.assertEqual(licenses, 1)
|
||||
|
||||
def test_tarball_inspection_rejects_symlink(self) -> None:
|
||||
package = controller.load_policy(POLICY_PATH).packages[0]
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError, "package_archive_unsafe_entry"
|
||||
):
|
||||
controller.inspect_tarball(
|
||||
package,
|
||||
_tarball(package, unsafe_symlink=True),
|
||||
maximum_unpacked_bytes=1024 * 1024,
|
||||
)
|
||||
|
||||
def test_tarball_inspection_rejects_duplicate_manifest(self) -> None:
|
||||
package = controller.load_policy(POLICY_PATH).packages[0]
|
||||
with self.assertRaisesRegex(
|
||||
controller.ControllerError,
|
||||
"package_archive_duplicate_or_invalid_file",
|
||||
):
|
||||
controller.inspect_tarball(
|
||||
package,
|
||||
_tarball(package, duplicate_manifest=True),
|
||||
maximum_unpacked_bytes=1024 * 1024,
|
||||
)
|
||||
|
||||
def test_slsa_subject_must_match_exact_tarball_digest(self) -> None:
|
||||
package = controller.load_policy(POLICY_PATH).packages[0]
|
||||
statement = {
|
||||
"_type": controller.IN_TOTO_STATEMENT_TYPE,
|
||||
"predicateType": controller.SLSA_PREDICATE_TYPE,
|
||||
"subject": [
|
||||
{
|
||||
"name": package.slsa_subject_name,
|
||||
"digest": {"sha512": package.slsa_subject_sha512_hex},
|
||||
}
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"attestations": [
|
||||
{
|
||||
"predicateType": controller.SLSA_PREDICATE_TYPE,
|
||||
"bundle": {
|
||||
"dsseEnvelope": {
|
||||
"payload": base64.b64encode(
|
||||
json.dumps(statement).encode()
|
||||
).decode()
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
controller.validate_slsa_attestation(package, payload)
|
||||
bad_statement = dict(statement)
|
||||
bad_statement["subject"] = [
|
||||
{
|
||||
"name": package.slsa_subject_name,
|
||||
"digest": {"sha512": "0" * 128},
|
||||
}
|
||||
]
|
||||
payload["attestations"][0]["bundle"]["dsseEnvelope"]["payload"] = (
|
||||
base64.b64encode(json.dumps(bad_statement).encode()).decode()
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ControllerError, "slsa_subject_mismatch"):
|
||||
controller.validate_slsa_attestation(package, payload)
|
||||
|
||||
def test_sbom_has_exact_npm_purls_and_dependency_edges(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
|
||||
sbom = controller.build_cyclonedx_sbom(
|
||||
policy,
|
||||
_verified_packages(policy),
|
||||
)
|
||||
|
||||
refs = {row["bom-ref"] for row in sbom["components"]}
|
||||
self.assertIn("pkg:npm/%40playwright/mcp@0.0.78", refs)
|
||||
self.assertNotIn("pkg:npm/%40playwright%2Fmcp@0.0.78", refs)
|
||||
root = next(
|
||||
row
|
||||
for row in sbom["dependencies"]
|
||||
if row["ref"] == "pkg:npm/%40playwright/mcp@0.0.78"
|
||||
)
|
||||
self.assertEqual(len(root["dependsOn"]), 2)
|
||||
|
||||
def test_osv_records_fail_closed(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
package = policy.packages[0]
|
||||
|
||||
class OsvClient:
|
||||
def post_json(self, *_args, **_kwargs):
|
||||
return b'{"vulns":[{"id":"OSV-TEST-1"}]}'
|
||||
|
||||
response_hash, vulnerabilities = controller._query_osv(
|
||||
package,
|
||||
OsvClient(),
|
||||
policy.maximum_metadata_bytes,
|
||||
)
|
||||
self.assertEqual(len(response_hash), 64)
|
||||
self.assertEqual(vulnerabilities, ("OSV-TEST-1",))
|
||||
|
||||
def test_mirror_receipt_never_promotes_runtime(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
verified = _verified_packages(policy)
|
||||
|
||||
receipt = controller.build_receipt(
|
||||
policy=policy,
|
||||
verified=verified,
|
||||
sbom_sha256="a" * 64,
|
||||
bundle_manifest_sha256="b" * 64,
|
||||
run_id=RUN_ID,
|
||||
trace_id=TRACE_ID,
|
||||
mode="apply",
|
||||
internal_mirror_ref=(
|
||||
"registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp@sha256:"
|
||||
+ "c" * 64
|
||||
),
|
||||
runtime_mirror_ref=(
|
||||
"192.168.0.110:5000/awoooi/mcp-artifacts/playwright-mcp@sha256:"
|
||||
+ "c" * 64
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
receipt["status"],
|
||||
"completed_internal_mirror_pending_independent_verifier",
|
||||
)
|
||||
self.assertFalse(receipt["promotion_allowed"])
|
||||
self.assertFalse(receipt["replay_allowed"])
|
||||
self.assertFalse(receipt["shadow_allowed"])
|
||||
self.assertFalse(receipt["canary_allowed"])
|
||||
self.assertFalse(
|
||||
receipt["stage_receipts"]["bounded_execution"]
|
||||
["production_route_changed"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,471 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
VERIFIER_PATH = ROOT / "scripts/security/verify_external_mcp_artifact_receipt.py"
|
||||
RUN_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
TRACE_ID = f"mcp-artifact-{RUN_ID}"
|
||||
|
||||
|
||||
def _load_verifier():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"verify_external_mcp_artifact_receipt", VERIFIER_PATH
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verifier = _load_verifier()
|
||||
|
||||
|
||||
def _canonical(payload) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
|
||||
|
||||
def _fixture():
|
||||
package_data = {
|
||||
"@playwright/mcp": b"root-package",
|
||||
"playwright": b"playwright-package",
|
||||
"playwright-core": b"playwright-core-package",
|
||||
}
|
||||
versions = {
|
||||
"@playwright/mcp": "0.0.78",
|
||||
"playwright": "1.62.0-alpha-1783623505000",
|
||||
"playwright-core": "1.62.0-alpha-1783623505000",
|
||||
}
|
||||
packages = []
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
private_key = root / "private.pem"
|
||||
public_key = root / "public.der"
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"ecparam",
|
||||
"-name",
|
||||
"prime256v1",
|
||||
"-genkey",
|
||||
"-noout",
|
||||
"-out",
|
||||
str(private_key),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"pkey",
|
||||
"-in",
|
||||
str(private_key),
|
||||
"-pubout",
|
||||
"-outform",
|
||||
"DER",
|
||||
"-out",
|
||||
str(public_key),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
public_key_base64 = base64.b64encode(public_key.read_bytes()).decode()
|
||||
for index, (name, data) in enumerate(package_data.items()):
|
||||
integrity = "sha512-" + base64.b64encode(
|
||||
hashlib.sha512(data).digest()
|
||||
).decode()
|
||||
message = f"{name}@{versions[name]}:{integrity}".encode()
|
||||
message_path = root / f"message-{index}.bin"
|
||||
signature_path = root / f"signature-{index}.der"
|
||||
message_path.write_bytes(message)
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"dgst",
|
||||
"-sha256",
|
||||
"-sign",
|
||||
str(private_key),
|
||||
"-out",
|
||||
str(signature_path),
|
||||
str(message_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": versions[name],
|
||||
"integrity": integrity,
|
||||
"signature": base64.b64encode(
|
||||
signature_path.read_bytes()
|
||||
).decode(),
|
||||
"slsa_subject_name": (
|
||||
f"pkg:npm/{quote(name, safe='/')}@{versions[name]}"
|
||||
),
|
||||
"slsa_subject_sha512_hex": hashlib.sha512(data).hexdigest(),
|
||||
"shasum_sha1": hashlib.sha1(
|
||||
data, usedforsecurity=False
|
||||
).hexdigest(),
|
||||
}
|
||||
)
|
||||
policy = {
|
||||
"schema_version": verifier.POLICY_SCHEMA_VERSION,
|
||||
"work_item_id": "MCP-ARTIFACT-PLAYWRIGHT-TEST",
|
||||
"candidate_id": "external.playwright-verifier",
|
||||
"risk_level": "high",
|
||||
"registry_contract": {
|
||||
"signature_key": {
|
||||
"public_key_der_base64": public_key_base64,
|
||||
}
|
||||
},
|
||||
"packages": packages,
|
||||
"internal_mirror": {
|
||||
"push_repository": (
|
||||
"registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp"
|
||||
),
|
||||
"runtime_repository": (
|
||||
"192.168.0.110:5000/awoooi/mcp-artifacts/playwright-mcp"
|
||||
),
|
||||
},
|
||||
}
|
||||
policy_checksum = "sha256:" + hashlib.sha256(_canonical(policy)).hexdigest()
|
||||
sbom = {
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": f"pkg:npm/{quote(row['name'], safe='/')}@{row['version']}"
|
||||
}
|
||||
for row in packages
|
||||
],
|
||||
}
|
||||
sbom_bytes = _canonical(sbom) + b"\n"
|
||||
files = {"artifact/sbom.cdx.json": sbom_bytes}
|
||||
manifest_rows = []
|
||||
receipt_rows = []
|
||||
for row in packages:
|
||||
safe_name = row["name"].replace("@", "").replace("/", "-")
|
||||
filename = f"{safe_name}-{row['version']}.tgz"
|
||||
statement = {
|
||||
"_type": verifier.IN_TOTO_STATEMENT_TYPE,
|
||||
"predicateType": verifier.SLSA_PREDICATE_TYPE,
|
||||
"subject": [
|
||||
{
|
||||
"name": row["slsa_subject_name"],
|
||||
"digest": {"sha512": row["slsa_subject_sha512_hex"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
attestation = _canonical(
|
||||
{
|
||||
"attestations": [
|
||||
{
|
||||
"predicateType": verifier.SLSA_PREDICATE_TYPE,
|
||||
"bundle": {
|
||||
"dsseEnvelope": {
|
||||
"payload": base64.b64encode(
|
||||
_canonical(statement)
|
||||
).decode()
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
files[f"artifact/tarballs/{filename}"] = package_data[row["name"]]
|
||||
files[f"artifact/attestations/{filename}.attestations.json"] = attestation
|
||||
manifest_rows.append(
|
||||
{
|
||||
"name": row["name"],
|
||||
"version": row["version"],
|
||||
"tarball_path": f"tarballs/{filename}",
|
||||
"tarball_sha512_hex": row["slsa_subject_sha512_hex"],
|
||||
"tarball_sha1": row["shasum_sha1"],
|
||||
"attestation_path": (
|
||||
f"attestations/{filename}.attestations.json"
|
||||
),
|
||||
"attestation_sha256": hashlib.sha256(attestation).hexdigest(),
|
||||
"npm_registry_signature_verified": True,
|
||||
"slsa_subject_verified": True,
|
||||
"archive_safety_verified": True,
|
||||
"vulnerability_ids": [],
|
||||
}
|
||||
)
|
||||
receipt_rows.append(
|
||||
{
|
||||
"name": row["name"],
|
||||
"version": row["version"],
|
||||
"tarball_sha512_hex": row["slsa_subject_sha512_hex"],
|
||||
"tarball_sha1": row["shasum_sha1"],
|
||||
"npm_registry_signature_verified": True,
|
||||
"slsa_subject_verified": True,
|
||||
"archive_safety_verified": True,
|
||||
"license_decision": "passed",
|
||||
"vulnerability_decision": "passed_no_known_osv_records",
|
||||
"vulnerability_ids": [],
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"candidate_id": policy["candidate_id"],
|
||||
"work_item_id": policy["work_item_id"],
|
||||
"policy_checksum": policy_checksum,
|
||||
"package_count": 3,
|
||||
"packages": manifest_rows,
|
||||
"sbom": {"sha256": hashlib.sha256(sbom_bytes).hexdigest()},
|
||||
"operation_boundaries": {
|
||||
"mcp_server_started": False,
|
||||
"browser_started": False,
|
||||
"runtime_request_or_response_body_stored": False,
|
||||
},
|
||||
}
|
||||
manifest_bytes = _canonical(manifest) + b"\n"
|
||||
files["artifact/bundle-manifest.json"] = manifest_bytes
|
||||
digest = "sha256:" + "c" * 64
|
||||
receipt = {
|
||||
"schema_version": verifier.CONTROLLER_RECEIPT_SCHEMA_VERSION,
|
||||
"run_id": RUN_ID,
|
||||
"trace_id": TRACE_ID,
|
||||
"work_item_id": policy["work_item_id"],
|
||||
"candidate_id": policy["candidate_id"],
|
||||
"risk_level": "high",
|
||||
"mode": "apply",
|
||||
"status": "completed_internal_mirror_pending_independent_verifier",
|
||||
"policy_checksum": policy_checksum,
|
||||
"packages": receipt_rows,
|
||||
"internal_mirror_ref": (
|
||||
policy["internal_mirror"]["push_repository"] + "@" + digest
|
||||
),
|
||||
"runtime_mirror_ref": (
|
||||
policy["internal_mirror"]["runtime_repository"] + "@" + digest
|
||||
),
|
||||
"bundle_manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
||||
"cyclonedx_sbom_sha256": hashlib.sha256(sbom_bytes).hexdigest(),
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"stage_receipts": {
|
||||
"sensor_source_receipt": {
|
||||
"redirects_followed": 0,
|
||||
"runtime_request_or_response_body_stored": False,
|
||||
"upstream_supply_chain_attestation_bundled": True,
|
||||
},
|
||||
"check_mode": {
|
||||
"status": "passed",
|
||||
"mcp_process_started": False,
|
||||
"browser_started": False,
|
||||
"production_write_performed": False,
|
||||
},
|
||||
"bounded_execution": {
|
||||
"status": "internal_oci_bundle_pushed",
|
||||
"internal_registry_write_performed": True,
|
||||
"external_runtime_mutation_performed": False,
|
||||
"production_route_changed": False,
|
||||
},
|
||||
"independent_post_verifier": {
|
||||
"status": "pending_external_verifier",
|
||||
"internal_digest_ref_verified": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
return policy, receipt, files
|
||||
|
||||
|
||||
def _write_image_archive(path: Path, files: dict[str, bytes], *, symlink=False):
|
||||
layer_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=layer_buffer, mode="w") as layer:
|
||||
for name, data in files.items():
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(data)
|
||||
layer.addfile(member, io.BytesIO(data))
|
||||
if symlink:
|
||||
member = tarfile.TarInfo("artifact/unsafe-link")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = "../../outside"
|
||||
layer.addfile(member)
|
||||
layer_bytes = layer_buffer.getvalue()
|
||||
manifest_bytes = json.dumps([{"Layers": ["layer.tar"]}]).encode()
|
||||
with tarfile.open(path, mode="w") as outer:
|
||||
for name, data in (
|
||||
("manifest.json", manifest_bytes),
|
||||
("layer.tar", layer_bytes),
|
||||
):
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(data)
|
||||
outer.addfile(member, io.BytesIO(data))
|
||||
|
||||
|
||||
class ExternalMcpArtifactIndependentVerifierTest(unittest.TestCase):
|
||||
def test_controller_receipt_identity_and_boundaries_are_validated(self) -> None:
|
||||
policy, receipt, _ = _fixture()
|
||||
|
||||
identity = verifier.validate_controller_receipt(policy, receipt)
|
||||
|
||||
self.assertEqual(identity["run_id"], RUN_ID)
|
||||
self.assertEqual(identity["trace_id"], TRACE_ID)
|
||||
self.assertEqual(identity["digest"], "sha256:" + "c" * 64)
|
||||
|
||||
def test_controller_receipt_rejects_promotion_claim(self) -> None:
|
||||
policy, receipt, _ = _fixture()
|
||||
receipt["canary_allowed"] = True
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"controller_receipt_promotion_boundary_invalid",
|
||||
):
|
||||
verifier.validate_controller_receipt(policy, receipt)
|
||||
|
||||
def test_bundle_files_are_rehashed_independently(self) -> None:
|
||||
policy, receipt, files = _fixture()
|
||||
|
||||
evidence = verifier.verify_bundle_files(
|
||||
files,
|
||||
policy=policy,
|
||||
controller_receipt=receipt,
|
||||
)
|
||||
|
||||
self.assertEqual(evidence["verified_package_count"], 3)
|
||||
self.assertEqual(evidence["artifact_file_count"], 8)
|
||||
|
||||
def test_bundle_tarball_tamper_is_rejected(self) -> None:
|
||||
policy, receipt, files = _fixture()
|
||||
tampered = deepcopy(files)
|
||||
root_tarball = next(
|
||||
key
|
||||
for key in tampered
|
||||
if key.startswith("artifact/tarballs/playwright-mcp-")
|
||||
)
|
||||
tampered[root_tarball] += b"tamper"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"bundle_package_evidence_mismatch",
|
||||
):
|
||||
verifier.verify_bundle_files(
|
||||
tampered,
|
||||
policy=policy,
|
||||
controller_receipt=receipt,
|
||||
)
|
||||
|
||||
def test_bundle_extra_file_is_rejected(self) -> None:
|
||||
policy, receipt, files = _fixture()
|
||||
files["artifact/unexpected.bin"] = b"unexpected"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"bundle_file_set_invalid",
|
||||
):
|
||||
verifier.verify_bundle_files(
|
||||
files,
|
||||
policy=policy,
|
||||
controller_receipt=receipt,
|
||||
)
|
||||
|
||||
def test_independent_npm_signature_rejects_tamper(self) -> None:
|
||||
policy, _, files = _fixture()
|
||||
package = deepcopy(policy["packages"][0])
|
||||
package["signature"] = base64.b64encode(b"invalid-signature").decode()
|
||||
tarball_path = next(
|
||||
key
|
||||
for key in files
|
||||
if key.startswith("artifact/tarballs/playwright-mcp-")
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"bundled_npm_signature_verification_failed",
|
||||
):
|
||||
verifier.verify_bundled_npm_signature(
|
||||
policy,
|
||||
package,
|
||||
files[tarball_path],
|
||||
)
|
||||
|
||||
def test_independent_slsa_subject_rejects_tamper(self) -> None:
|
||||
policy, _, files = _fixture()
|
||||
package = deepcopy(policy["packages"][0])
|
||||
package["slsa_subject_sha512_hex"] = "0" * 128
|
||||
attestation_path = next(
|
||||
key
|
||||
for key in files
|
||||
if key.startswith("artifact/attestations/playwright-mcp-")
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"bundled_slsa_subject_mismatch",
|
||||
):
|
||||
verifier.verify_bundled_slsa_subject(
|
||||
package,
|
||||
files[attestation_path],
|
||||
)
|
||||
|
||||
def test_image_archive_is_inspected_as_data(self) -> None:
|
||||
_, _, files = _fixture()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
archive = Path(temp) / "image.tar"
|
||||
_write_image_archive(archive, files)
|
||||
|
||||
extracted = verifier.extract_image_files(archive)
|
||||
|
||||
self.assertEqual(extracted, files)
|
||||
|
||||
def test_image_archive_rejects_symlink(self) -> None:
|
||||
_, _, files = _fixture()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
archive = Path(temp) / "image.tar"
|
||||
_write_image_archive(archive, files, symlink=True)
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError,
|
||||
"image_layer_unsafe_entry",
|
||||
):
|
||||
verifier.extract_image_files(archive)
|
||||
|
||||
def test_verifier_receipt_keeps_runtime_promotion_disabled(self) -> None:
|
||||
policy, receipt, files = _fixture()
|
||||
identity = verifier.validate_controller_receipt(policy, receipt)
|
||||
evidence = verifier.verify_bundle_files(
|
||||
files,
|
||||
policy=policy,
|
||||
controller_receipt=receipt,
|
||||
)
|
||||
|
||||
result = verifier.build_verifier_receipt(
|
||||
identity=identity,
|
||||
controller_receipt_bytes=_canonical(receipt) + b"\n",
|
||||
bundle_evidence=evidence,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "completed_internal_mirror_verified")
|
||||
self.assertFalse(result["promotion_allowed"])
|
||||
self.assertFalse(result["replay_allowed"])
|
||||
self.assertFalse(result["shadow_allowed"])
|
||||
self.assertFalse(result["canary_allowed"])
|
||||
self.assertFalse(
|
||||
result["independent_post_verifier"]["container_or_mcp_started"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
688
scripts/security/verify_external_mcp_artifact_receipt.py
Normal file
688
scripts/security/verify_external_mcp_artifact_receipt.py
Normal file
@@ -0,0 +1,688 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently verify an internal external-MCP artifact mirror receipt.
|
||||
|
||||
This verifier does not import the artifact controller. It validates the policy
|
||||
and controller receipt through a separate code path, reads the Harbor image back
|
||||
by immutable digest, and inspects its layers as data. It never starts a container,
|
||||
MCP server, browser, replay, or production canary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
POLICY_SCHEMA_VERSION = "awoooi_external_mcp_artifact_policy_v1"
|
||||
CONTROLLER_RECEIPT_SCHEMA_VERSION = "awoooi_external_mcp_artifact_receipt_v1"
|
||||
VERIFIER_RECEIPT_SCHEMA_VERSION = "awoooi_external_mcp_artifact_verifier_receipt_v1"
|
||||
SHA256_HEX_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
SHA1_HEX_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA512_HEX_PATTERN = re.compile(r"^[0-9a-f]{128}$")
|
||||
OCI_DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
MAX_IMAGE_ARCHIVE_BYTES = 96 * 1024 * 1024
|
||||
MAX_LAYER_UNPACKED_BYTES = 64 * 1024 * 1024
|
||||
MAX_LAYER_FILES = 2_048
|
||||
SLSA_PREDICATE_TYPE = "https://slsa.dev/provenance/v1"
|
||||
IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
|
||||
|
||||
|
||||
class VerifierError(RuntimeError):
|
||||
"""Fail-closed public-safe verifier error class."""
|
||||
|
||||
|
||||
def _canonical_json_bytes(payload: Any) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, error_class: str) -> dict[str, Any]:
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise VerifierError(error_class) from exc
|
||||
if len(data) > 4 * 1024 * 1024:
|
||||
raise VerifierError(error_class)
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError(error_class) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise VerifierError(error_class)
|
||||
return payload
|
||||
|
||||
|
||||
def _require_dict(value: Any, error_class: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise VerifierError(error_class)
|
||||
return value
|
||||
|
||||
|
||||
def _expected_packages(policy: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
rows = policy.get("packages")
|
||||
if not isinstance(rows, list) or len(rows) != 3:
|
||||
raise VerifierError("policy_package_set_invalid")
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise VerifierError("policy_package_set_invalid")
|
||||
name = row.get("name")
|
||||
version = row.get("version")
|
||||
sha512_hex = row.get("slsa_subject_sha512_hex")
|
||||
sha1_hex = row.get("shasum_sha1")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not isinstance(version, str)
|
||||
or not isinstance(sha512_hex, str)
|
||||
or not SHA512_HEX_PATTERN.fullmatch(sha512_hex)
|
||||
or not isinstance(sha1_hex, str)
|
||||
or not SHA1_HEX_PATTERN.fullmatch(sha1_hex)
|
||||
or name in result
|
||||
):
|
||||
raise VerifierError("policy_package_set_invalid")
|
||||
result[name] = row
|
||||
if set(result) != {"@playwright/mcp", "playwright", "playwright-core"}:
|
||||
raise VerifierError("policy_package_set_invalid")
|
||||
return result
|
||||
|
||||
|
||||
def validate_controller_receipt(
|
||||
policy: dict[str, Any],
|
||||
receipt: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the controller receipt without using controller code."""
|
||||
if policy.get("schema_version") != POLICY_SCHEMA_VERSION:
|
||||
raise VerifierError("policy_schema_invalid")
|
||||
if receipt.get("schema_version") != CONTROLLER_RECEIPT_SCHEMA_VERSION:
|
||||
raise VerifierError("controller_receipt_schema_invalid")
|
||||
policy_checksum = "sha256:" + _sha256_hex(_canonical_json_bytes(policy))
|
||||
if receipt.get("policy_checksum") != policy_checksum:
|
||||
raise VerifierError("controller_receipt_policy_checksum_mismatch")
|
||||
if (
|
||||
receipt.get("work_item_id") != policy.get("work_item_id")
|
||||
or receipt.get("candidate_id") != policy.get("candidate_id")
|
||||
or receipt.get("risk_level") != "high"
|
||||
or receipt.get("mode") != "apply"
|
||||
or receipt.get("status")
|
||||
!= "completed_internal_mirror_pending_independent_verifier"
|
||||
):
|
||||
raise VerifierError("controller_receipt_identity_or_state_invalid")
|
||||
|
||||
run_id = receipt.get("run_id")
|
||||
trace_id = receipt.get("trace_id")
|
||||
try:
|
||||
normalized_run_id = str(uuid.UUID(run_id))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise VerifierError("controller_receipt_run_id_invalid") from exc
|
||||
if run_id != normalized_run_id or trace_id != f"mcp-artifact-{run_id}":
|
||||
raise VerifierError("controller_receipt_run_trace_mismatch")
|
||||
if any(
|
||||
receipt.get(field) is not False
|
||||
for field in (
|
||||
"promotion_allowed",
|
||||
"replay_allowed",
|
||||
"shadow_allowed",
|
||||
"canary_allowed",
|
||||
)
|
||||
):
|
||||
raise VerifierError("controller_receipt_promotion_boundary_invalid")
|
||||
|
||||
expected = _expected_packages(policy)
|
||||
rows = receipt.get("packages")
|
||||
if not isinstance(rows, list) or len(rows) != len(expected):
|
||||
raise VerifierError("controller_receipt_package_set_invalid")
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or row.get("name") not in expected:
|
||||
raise VerifierError("controller_receipt_package_set_invalid")
|
||||
package = expected[row["name"]]
|
||||
if row["name"] in seen:
|
||||
raise VerifierError("controller_receipt_package_set_invalid")
|
||||
seen.add(row["name"])
|
||||
if (
|
||||
row.get("version") != package.get("version")
|
||||
or row.get("tarball_sha512_hex")
|
||||
!= package.get("slsa_subject_sha512_hex")
|
||||
or row.get("tarball_sha1") != package.get("shasum_sha1")
|
||||
or row.get("npm_registry_signature_verified") is not True
|
||||
or row.get("slsa_subject_verified") is not True
|
||||
or row.get("archive_safety_verified") is not True
|
||||
or row.get("license_decision") != "passed"
|
||||
or row.get("vulnerability_decision")
|
||||
!= "passed_no_known_osv_records"
|
||||
or row.get("vulnerability_ids") != []
|
||||
):
|
||||
raise VerifierError("controller_receipt_package_evidence_invalid")
|
||||
if seen != set(expected):
|
||||
raise VerifierError("controller_receipt_package_set_invalid")
|
||||
|
||||
stages = _require_dict(receipt.get("stage_receipts"), "stage_receipts_invalid")
|
||||
sensor = _require_dict(
|
||||
stages.get("sensor_source_receipt"), "sensor_source_receipt_invalid"
|
||||
)
|
||||
check_mode = _require_dict(stages.get("check_mode"), "check_mode_invalid")
|
||||
execution = _require_dict(
|
||||
stages.get("bounded_execution"), "bounded_execution_invalid"
|
||||
)
|
||||
pending_verifier = _require_dict(
|
||||
stages.get("independent_post_verifier"),
|
||||
"independent_post_verifier_state_invalid",
|
||||
)
|
||||
if (
|
||||
sensor.get("redirects_followed") != 0
|
||||
or sensor.get("runtime_request_or_response_body_stored") is not False
|
||||
or sensor.get("upstream_supply_chain_attestation_bundled") is not True
|
||||
or check_mode.get("status") != "passed"
|
||||
or check_mode.get("mcp_process_started") is not False
|
||||
or check_mode.get("browser_started") is not False
|
||||
or check_mode.get("production_write_performed") is not False
|
||||
or execution.get("status") != "internal_oci_bundle_pushed"
|
||||
or execution.get("internal_registry_write_performed") is not True
|
||||
or execution.get("external_runtime_mutation_performed") is not False
|
||||
or execution.get("production_route_changed") is not False
|
||||
or pending_verifier.get("status") != "pending_external_verifier"
|
||||
or pending_verifier.get("internal_digest_ref_verified") is not False
|
||||
):
|
||||
raise VerifierError("controller_receipt_stage_boundary_invalid")
|
||||
|
||||
mirror = _require_dict(policy.get("internal_mirror"), "internal_mirror_invalid")
|
||||
internal_ref = receipt.get("internal_mirror_ref")
|
||||
runtime_ref = receipt.get("runtime_mirror_ref")
|
||||
push_prefix = f"{mirror.get('push_repository')}@"
|
||||
runtime_prefix = f"{mirror.get('runtime_repository')}@"
|
||||
if (
|
||||
not isinstance(internal_ref, str)
|
||||
or not internal_ref.startswith(push_prefix)
|
||||
or not isinstance(runtime_ref, str)
|
||||
or not runtime_ref.startswith(runtime_prefix)
|
||||
):
|
||||
raise VerifierError("controller_receipt_internal_ref_invalid")
|
||||
internal_digest = internal_ref.removeprefix(push_prefix)
|
||||
runtime_digest = runtime_ref.removeprefix(runtime_prefix)
|
||||
if (
|
||||
internal_digest != runtime_digest
|
||||
or not OCI_DIGEST_PATTERN.fullmatch(internal_digest)
|
||||
):
|
||||
raise VerifierError("controller_receipt_internal_digest_invalid")
|
||||
for field in ("bundle_manifest_sha256", "cyclonedx_sbom_sha256"):
|
||||
if not isinstance(receipt.get(field), str) or not SHA256_HEX_PATTERN.fullmatch(
|
||||
receipt[field]
|
||||
):
|
||||
raise VerifierError("controller_receipt_bundle_hash_invalid")
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"trace_id": trace_id,
|
||||
"work_item_id": receipt["work_item_id"],
|
||||
"candidate_id": receipt["candidate_id"],
|
||||
"policy_checksum": policy_checksum,
|
||||
"internal_ref": internal_ref,
|
||||
"runtime_ref": runtime_ref,
|
||||
"digest": internal_digest,
|
||||
"expected_packages": expected,
|
||||
}
|
||||
|
||||
|
||||
def _run_command(command: list[str], *, cwd: Path, timeout: int) -> None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise VerifierError("registry_readback_command_failed") from exc
|
||||
if completed.returncode != 0:
|
||||
raise VerifierError("registry_readback_command_failed")
|
||||
|
||||
|
||||
def verify_bundled_npm_signature(
|
||||
policy: dict[str, Any],
|
||||
package: dict[str, Any],
|
||||
tarball: bytes,
|
||||
) -> None:
|
||||
"""Verify the npm ECDSA signature with OpenSSL through a separate path."""
|
||||
integrity = package.get("integrity")
|
||||
signature_base64 = package.get("signature")
|
||||
registry = _require_dict(policy.get("registry_contract"), "registry_contract_invalid")
|
||||
signature_key = _require_dict(
|
||||
registry.get("signature_key"), "registry_signature_key_invalid"
|
||||
)
|
||||
public_key_base64 = signature_key.get("public_key_der_base64")
|
||||
if (
|
||||
not isinstance(integrity, str)
|
||||
or not integrity.startswith("sha512-")
|
||||
or not isinstance(signature_base64, str)
|
||||
or not isinstance(public_key_base64, str)
|
||||
):
|
||||
raise VerifierError("bundled_npm_signature_policy_invalid")
|
||||
try:
|
||||
integrity_digest = base64.b64decode(
|
||||
integrity.removeprefix("sha512-"), validate=True
|
||||
)
|
||||
signature = base64.b64decode(signature_base64, validate=True)
|
||||
public_key = base64.b64decode(public_key_base64, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise VerifierError("bundled_npm_signature_encoding_invalid") from exc
|
||||
if integrity_digest != hashlib.sha512(tarball).digest():
|
||||
raise VerifierError("bundled_npm_integrity_mismatch")
|
||||
openssl = shutil.which("openssl")
|
||||
if not openssl:
|
||||
raise VerifierError("openssl_signature_verifier_unavailable")
|
||||
message = f"{package.get('name')}@{package.get('version')}:{integrity}".encode()
|
||||
with tempfile.TemporaryDirectory(prefix="awoooi-mcp-npm-verifier-") as temp:
|
||||
root = Path(temp)
|
||||
der_path = root / "public.der"
|
||||
pem_path = root / "public.pem"
|
||||
signature_path = root / "signature.der"
|
||||
message_path = root / "message.bin"
|
||||
der_path.write_bytes(public_key)
|
||||
signature_path.write_bytes(signature)
|
||||
message_path.write_bytes(message)
|
||||
convert = subprocess.run(
|
||||
[
|
||||
openssl,
|
||||
"pkey",
|
||||
"-pubin",
|
||||
"-inform",
|
||||
"DER",
|
||||
"-in",
|
||||
str(der_path),
|
||||
"-out",
|
||||
str(pem_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if convert.returncode != 0:
|
||||
raise VerifierError("bundled_npm_public_key_invalid")
|
||||
verified = subprocess.run(
|
||||
[
|
||||
openssl,
|
||||
"dgst",
|
||||
"-sha256",
|
||||
"-verify",
|
||||
str(pem_path),
|
||||
"-signature",
|
||||
str(signature_path),
|
||||
str(message_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if verified.returncode != 0:
|
||||
raise VerifierError("bundled_npm_signature_verification_failed")
|
||||
|
||||
|
||||
def verify_bundled_slsa_subject(
|
||||
package: dict[str, Any],
|
||||
attestation: bytes,
|
||||
) -> None:
|
||||
"""Decode the bundled DSSE statement and independently bind its subject."""
|
||||
try:
|
||||
payload = json.loads(attestation)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError("bundled_slsa_attestation_invalid") from exc
|
||||
rows = payload.get("attestations") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise VerifierError("bundled_slsa_attestation_invalid")
|
||||
matches = [
|
||||
row
|
||||
for row in rows
|
||||
if isinstance(row, dict) and row.get("predicateType") == SLSA_PREDICATE_TYPE
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise VerifierError("bundled_slsa_attestation_invalid")
|
||||
bundle = matches[0].get("bundle")
|
||||
envelope = bundle.get("dsseEnvelope") if isinstance(bundle, dict) else None
|
||||
encoded = envelope.get("payload") if isinstance(envelope, dict) else None
|
||||
if not isinstance(encoded, str):
|
||||
raise VerifierError("bundled_slsa_dsse_payload_missing")
|
||||
try:
|
||||
statement = json.loads(base64.b64decode(encoded, validate=True))
|
||||
except (ValueError, binascii.Error, json.JSONDecodeError) as exc:
|
||||
raise VerifierError("bundled_slsa_dsse_payload_invalid") from exc
|
||||
expected_subject = {
|
||||
"name": package.get("slsa_subject_name"),
|
||||
"digest": {"sha512": package.get("slsa_subject_sha512_hex")},
|
||||
}
|
||||
if (
|
||||
not isinstance(statement, dict)
|
||||
or statement.get("_type") != IN_TOTO_STATEMENT_TYPE
|
||||
or statement.get("predicateType") != SLSA_PREDICATE_TYPE
|
||||
or statement.get("subject") != [expected_subject]
|
||||
):
|
||||
raise VerifierError("bundled_slsa_subject_mismatch")
|
||||
|
||||
|
||||
def read_image_files(internal_ref: str) -> dict[str, bytes]:
|
||||
"""Pull and inspect an OCI image as tar data without creating a container."""
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
raise VerifierError("docker_cli_unavailable")
|
||||
with tempfile.TemporaryDirectory(prefix="awoooi-mcp-artifact-verifier-") as temp:
|
||||
root = Path(temp)
|
||||
archive_path = root / "image.tar"
|
||||
_run_command(
|
||||
[docker, "buildx", "imagetools", "inspect", internal_ref],
|
||||
cwd=root,
|
||||
timeout=60,
|
||||
)
|
||||
_run_command([docker, "pull", internal_ref], cwd=root, timeout=180)
|
||||
_run_command(
|
||||
[docker, "image", "save", "--output", str(archive_path), internal_ref],
|
||||
cwd=root,
|
||||
timeout=120,
|
||||
)
|
||||
return extract_image_files(archive_path)
|
||||
|
||||
|
||||
def extract_image_files(archive_path: Path) -> dict[str, bytes]:
|
||||
"""Extract bounded artifact files from a Docker image archive."""
|
||||
try:
|
||||
if archive_path.stat().st_size > MAX_IMAGE_ARCHIVE_BYTES:
|
||||
raise VerifierError("image_archive_size_exceeded")
|
||||
with tarfile.open(archive_path, mode="r:") as outer:
|
||||
manifest_member = outer.getmember("manifest.json")
|
||||
manifest_file = outer.extractfile(manifest_member)
|
||||
if manifest_file is None:
|
||||
raise VerifierError("image_manifest_missing")
|
||||
manifests = json.loads(manifest_file.read(1_048_577))
|
||||
if not isinstance(manifests, list) or len(manifests) != 1:
|
||||
raise VerifierError("image_manifest_invalid")
|
||||
layers = manifests[0].get("Layers")
|
||||
if not isinstance(layers, list) or not layers:
|
||||
raise VerifierError("image_layers_missing")
|
||||
files: dict[str, bytes] = {}
|
||||
total_bytes = 0
|
||||
file_count = 0
|
||||
for layer_name in layers:
|
||||
if not isinstance(layer_name, str):
|
||||
raise VerifierError("image_layer_name_invalid")
|
||||
layer_path = PurePosixPath(layer_name)
|
||||
if layer_path.is_absolute() or ".." in layer_path.parts:
|
||||
raise VerifierError("image_layer_name_invalid")
|
||||
layer_member = outer.getmember(layer_name)
|
||||
layer_file = outer.extractfile(layer_member)
|
||||
if layer_file is None:
|
||||
raise VerifierError("image_layer_missing")
|
||||
layer_bytes = layer_file.read(MAX_IMAGE_ARCHIVE_BYTES + 1)
|
||||
if len(layer_bytes) > MAX_IMAGE_ARCHIVE_BYTES:
|
||||
raise VerifierError("image_layer_size_exceeded")
|
||||
with tarfile.open(fileobj=io.BytesIO(layer_bytes)) as layer:
|
||||
for member in layer.getmembers():
|
||||
path = PurePosixPath(member.name)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or member.issym()
|
||||
or member.islnk()
|
||||
or member.isdev()
|
||||
):
|
||||
raise VerifierError("image_layer_unsafe_entry")
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile():
|
||||
raise VerifierError("image_layer_unsupported_entry")
|
||||
if any(part.startswith(".wh.") for part in path.parts):
|
||||
raise VerifierError("image_layer_whiteout_not_allowed")
|
||||
normalized = str(path).lstrip("./")
|
||||
if not normalized.startswith("artifact/"):
|
||||
raise VerifierError("image_layer_file_outside_artifact")
|
||||
extracted = layer.extractfile(member)
|
||||
if extracted is None:
|
||||
raise VerifierError("image_layer_file_unreadable")
|
||||
data = extracted.read(member.size + 1)
|
||||
if len(data) != member.size:
|
||||
raise VerifierError("image_layer_file_size_mismatch")
|
||||
total_bytes += len(data)
|
||||
file_count += 1
|
||||
if (
|
||||
total_bytes > MAX_LAYER_UNPACKED_BYTES
|
||||
or file_count > MAX_LAYER_FILES
|
||||
):
|
||||
raise VerifierError("image_layer_bounds_exceeded")
|
||||
files[normalized] = data
|
||||
except (OSError, KeyError, tarfile.TarError, json.JSONDecodeError) as exc:
|
||||
raise VerifierError("image_archive_invalid") from exc
|
||||
return files
|
||||
|
||||
|
||||
def verify_bundle_files(
|
||||
files: dict[str, bytes],
|
||||
*,
|
||||
policy: dict[str, Any],
|
||||
controller_receipt: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Recompute immutable bundle evidence from image layer files."""
|
||||
manifest_bytes = files.get("artifact/bundle-manifest.json")
|
||||
sbom_bytes = files.get("artifact/sbom.cdx.json")
|
||||
if manifest_bytes is None or sbom_bytes is None:
|
||||
raise VerifierError("bundle_core_files_missing")
|
||||
if _sha256_hex(manifest_bytes) != controller_receipt.get(
|
||||
"bundle_manifest_sha256"
|
||||
) or _sha256_hex(sbom_bytes) != controller_receipt.get("cyclonedx_sbom_sha256"):
|
||||
raise VerifierError("bundle_core_hash_mismatch")
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes)
|
||||
sbom = json.loads(sbom_bytes)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError("bundle_core_json_invalid") from exc
|
||||
if not isinstance(manifest, dict) or not isinstance(sbom, dict):
|
||||
raise VerifierError("bundle_core_json_invalid")
|
||||
policy_checksum = "sha256:" + _sha256_hex(_canonical_json_bytes(policy))
|
||||
if (
|
||||
manifest.get("candidate_id") != policy.get("candidate_id")
|
||||
or manifest.get("work_item_id") != policy.get("work_item_id")
|
||||
or manifest.get("policy_checksum") != policy_checksum
|
||||
or manifest.get("package_count") != 3
|
||||
or manifest.get("operation_boundaries", {}).get("mcp_server_started")
|
||||
is not False
|
||||
or manifest.get("operation_boundaries", {}).get("browser_started")
|
||||
is not False
|
||||
or manifest.get("operation_boundaries", {}).get(
|
||||
"runtime_request_or_response_body_stored"
|
||||
)
|
||||
is not False
|
||||
):
|
||||
raise VerifierError("bundle_manifest_boundary_invalid")
|
||||
expected = _expected_packages(policy)
|
||||
package_rows = manifest.get("packages")
|
||||
if not isinstance(package_rows, list) or len(package_rows) != len(expected):
|
||||
raise VerifierError("bundle_package_set_invalid")
|
||||
verified_package_names: list[str] = []
|
||||
expected_file_paths = {
|
||||
"artifact/bundle-manifest.json",
|
||||
"artifact/sbom.cdx.json",
|
||||
}
|
||||
for row in package_rows:
|
||||
if not isinstance(row, dict) or row.get("name") not in expected:
|
||||
raise VerifierError("bundle_package_set_invalid")
|
||||
package = expected[row["name"]]
|
||||
tarball_path = row.get("tarball_path")
|
||||
attestation_path = row.get("attestation_path")
|
||||
if not isinstance(tarball_path, str) or not isinstance(attestation_path, str):
|
||||
raise VerifierError("bundle_package_path_invalid")
|
||||
tarball = files.get(f"artifact/{tarball_path}")
|
||||
attestation = files.get(f"artifact/{attestation_path}")
|
||||
if tarball is None or attestation is None:
|
||||
raise VerifierError("bundle_package_file_missing")
|
||||
expected_file_paths.update(
|
||||
{f"artifact/{tarball_path}", f"artifact/{attestation_path}"}
|
||||
)
|
||||
if (
|
||||
hashlib.sha512(tarball).hexdigest()
|
||||
!= package.get("slsa_subject_sha512_hex")
|
||||
or hashlib.sha1(tarball, usedforsecurity=False).hexdigest()
|
||||
!= package.get("shasum_sha1")
|
||||
or _sha256_hex(attestation) != row.get("attestation_sha256")
|
||||
or row.get("npm_registry_signature_verified") is not True
|
||||
or row.get("slsa_subject_verified") is not True
|
||||
or row.get("archive_safety_verified") is not True
|
||||
or row.get("vulnerability_ids") != []
|
||||
):
|
||||
raise VerifierError("bundle_package_evidence_mismatch")
|
||||
verify_bundled_npm_signature(policy, package, tarball)
|
||||
verify_bundled_slsa_subject(package, attestation)
|
||||
verified_package_names.append(row["name"])
|
||||
if len(set(verified_package_names)) != len(expected):
|
||||
raise VerifierError("bundle_package_set_invalid")
|
||||
if set(files) != expected_file_paths:
|
||||
raise VerifierError("bundle_file_set_invalid")
|
||||
|
||||
expected_refs = {
|
||||
f"pkg:npm/{quote(name, safe='/')}@{row['version']}"
|
||||
for name, row in expected.items()
|
||||
}
|
||||
components = sbom.get("components")
|
||||
if (
|
||||
sbom.get("bomFormat") != "CycloneDX"
|
||||
or sbom.get("specVersion") != "1.5"
|
||||
or not isinstance(components, list)
|
||||
or {row.get("bom-ref") for row in components if isinstance(row, dict)}
|
||||
!= expected_refs
|
||||
):
|
||||
raise VerifierError("bundle_sbom_invalid")
|
||||
manifest_sbom = _require_dict(manifest.get("sbom"), "bundle_sbom_invalid")
|
||||
if manifest_sbom.get("sha256") != _sha256_hex(sbom_bytes):
|
||||
raise VerifierError("bundle_sbom_hash_mismatch")
|
||||
return {
|
||||
"artifact_file_count": len(files),
|
||||
"verified_package_count": len(verified_package_names),
|
||||
"bundle_manifest_sha256": _sha256_hex(manifest_bytes),
|
||||
"cyclonedx_sbom_sha256": _sha256_hex(sbom_bytes),
|
||||
}
|
||||
|
||||
|
||||
def build_verifier_receipt(
|
||||
*,
|
||||
identity: dict[str, Any],
|
||||
controller_receipt_bytes: bytes,
|
||||
bundle_evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": VERIFIER_RECEIPT_SCHEMA_VERSION,
|
||||
"run_id": identity["run_id"],
|
||||
"trace_id": identity["trace_id"],
|
||||
"work_item_id": identity["work_item_id"],
|
||||
"candidate_id": identity["candidate_id"],
|
||||
"status": "completed_internal_mirror_verified",
|
||||
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"policy_checksum": identity["policy_checksum"],
|
||||
"controller_receipt_sha256": _sha256_hex(controller_receipt_bytes),
|
||||
"internal_mirror_ref": identity["internal_ref"],
|
||||
"runtime_mirror_ref": identity["runtime_ref"],
|
||||
"internal_digest": identity["digest"],
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"independent_post_verifier": {
|
||||
"status": "passed",
|
||||
"implementation": "standalone_no_controller_import",
|
||||
"registry_digest_readback_verified": True,
|
||||
"npm_registry_signatures_reverified": True,
|
||||
"slsa_subjects_reverified": True,
|
||||
"container_or_mcp_started": False,
|
||||
**bundle_evidence,
|
||||
},
|
||||
"rollback_terminal": {
|
||||
"status": "rollback_ready_not_promoted",
|
||||
"action": "disable_candidate_and_retain_immutable_bundle",
|
||||
"production_route_changed": False,
|
||||
"external_artifact_delete_allowed": False,
|
||||
},
|
||||
"learning_writeback": {
|
||||
"status": "verifier_receipt_ready_for_committed_writeback",
|
||||
"external_rag_write_performed": False,
|
||||
"raw_runtime_request_or_response_body_stored": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_receipt(path: Path, receipt: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(_canonical_json_bytes(receipt) + b"\n")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--policy", required=True, type=Path)
|
||||
parser.add_argument("--controller-receipt", required=True, type=Path)
|
||||
parser.add_argument("--verifier-receipt", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
policy = _read_json(args.policy, "policy_unreadable")
|
||||
controller_receipt_bytes = args.controller_receipt.read_bytes()
|
||||
if len(controller_receipt_bytes) > 4 * 1024 * 1024:
|
||||
raise VerifierError("controller_receipt_too_large")
|
||||
controller_receipt = _read_json(
|
||||
args.controller_receipt,
|
||||
"controller_receipt_unreadable",
|
||||
)
|
||||
identity = validate_controller_receipt(policy, controller_receipt)
|
||||
files = read_image_files(identity["internal_ref"])
|
||||
bundle_evidence = verify_bundle_files(
|
||||
files,
|
||||
policy=policy,
|
||||
controller_receipt=controller_receipt,
|
||||
)
|
||||
receipt = build_verifier_receipt(
|
||||
identity=identity,
|
||||
controller_receipt_bytes=controller_receipt_bytes,
|
||||
bundle_evidence=bundle_evidence,
|
||||
)
|
||||
_write_receipt(args.verifier_receipt, receipt)
|
||||
print(json.dumps(receipt, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, VerifierError) as exc:
|
||||
error_class = str(exc) if isinstance(exc, VerifierError) else "verifier_io_error"
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": VERIFIER_RECEIPT_SCHEMA_VERSION,
|
||||
"status": "blocked_with_safe_next_action",
|
||||
"error_class": error_class,
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
Reference in New Issue
Block a user