472 lines
16 KiB
Python
472 lines
16 KiB
Python
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()
|