mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-10 21:24:56 +03:00
474 lines
18 KiB
Python
474 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Sigstore Software Signing Agent - Automates cosign keyless signing, Rekor
|
|
transparency log verification, and Fulcio certificate inspection for container
|
|
images and software artifacts."""
|
|
|
|
import json
|
|
import logging
|
|
import argparse
|
|
import subprocess
|
|
import hashlib
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
REKOR_PUBLIC_URL = "https://rekor.sigstore.dev"
|
|
FULCIO_PUBLIC_URL = "https://fulcio.sigstore.dev"
|
|
|
|
|
|
def compute_sha256(filepath):
|
|
"""Compute SHA-256 hash of a file."""
|
|
sha256 = hashlib.sha256()
|
|
with open(filepath, "rb") as f:
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
sha256.update(chunk)
|
|
return sha256.hexdigest()
|
|
|
|
|
|
def run_cosign(args, capture=True):
|
|
"""Execute a cosign CLI command and return the result."""
|
|
cmd = ["cosign"] + args
|
|
logger.info("Running: %s", " ".join(cmd))
|
|
result = subprocess.run(cmd, capture_output=capture, text=True, timeout=120)
|
|
if result.returncode != 0:
|
|
logger.error("cosign failed (exit %d): %s", result.returncode, result.stderr)
|
|
return result
|
|
|
|
|
|
def check_cosign_installed():
|
|
"""Verify cosign CLI is available and return version info."""
|
|
result = run_cosign(["version"])
|
|
if result.returncode != 0:
|
|
logger.error("cosign is not installed or not in PATH")
|
|
return None
|
|
version_line = ""
|
|
for line in result.stdout.splitlines():
|
|
if "cosign" in line.lower() or "GitVersion" in line:
|
|
version_line = line.strip()
|
|
break
|
|
return version_line or result.stdout.strip()
|
|
|
|
|
|
def sign_blob_keyless(filepath, bundle_path=None):
|
|
"""Sign a file blob using cosign keyless signing with Fulcio and Rekor.
|
|
|
|
This triggers an OIDC authentication flow. In CI, set SIGSTORE_ID_TOKEN
|
|
environment variable to provide the identity token non-interactively.
|
|
"""
|
|
filepath = Path(filepath)
|
|
if not filepath.exists():
|
|
return {"error": f"File not found: {filepath}", "signed": False}
|
|
|
|
if bundle_path is None:
|
|
bundle_path = str(filepath) + ".sigstore.json"
|
|
|
|
args = ["sign-blob", str(filepath), "--bundle", bundle_path, "--yes"]
|
|
result = run_cosign(args)
|
|
|
|
if result.returncode == 0:
|
|
logger.info("Blob signed successfully: %s", filepath)
|
|
bundle_data = {}
|
|
try:
|
|
with open(bundle_path, "r") as f:
|
|
bundle_data = json.load(f)
|
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
pass
|
|
return {
|
|
"signed": True,
|
|
"file": str(filepath),
|
|
"bundle": bundle_path,
|
|
"sha256": compute_sha256(filepath),
|
|
"has_rekor_entry": "rekorBundle" in bundle_data
|
|
or "verificationMaterial" in bundle_data,
|
|
}
|
|
return {"signed": False, "file": str(filepath), "error": result.stderr.strip()}
|
|
|
|
|
|
def verify_blob_keyless(filepath, bundle_path, cert_identity, cert_oidc_issuer):
|
|
"""Verify a signed blob against expected identity and OIDC issuer."""
|
|
filepath = Path(filepath)
|
|
if not filepath.exists():
|
|
return {"error": f"File not found: {filepath}", "verified": False}
|
|
|
|
args = [
|
|
"verify-blob",
|
|
str(filepath),
|
|
"--bundle",
|
|
bundle_path,
|
|
"--certificate-identity",
|
|
cert_identity,
|
|
"--certificate-oidc-issuer",
|
|
cert_oidc_issuer,
|
|
]
|
|
result = run_cosign(args)
|
|
|
|
return {
|
|
"verified": result.returncode == 0,
|
|
"file": str(filepath),
|
|
"certificate_identity": cert_identity,
|
|
"certificate_oidc_issuer": cert_oidc_issuer,
|
|
"output": result.stdout.strip() if result.returncode == 0 else result.stderr.strip(),
|
|
}
|
|
|
|
|
|
def sign_container_keyless(image_uri):
|
|
"""Sign a container image using cosign keyless signing.
|
|
|
|
The image_uri should include the digest (e.g., registry/image@sha256:abc...).
|
|
Signing by tag instead of digest is unreliable because tags are mutable.
|
|
"""
|
|
args = ["sign", image_uri, "--yes"]
|
|
result = run_cosign(args)
|
|
|
|
return {
|
|
"signed": result.returncode == 0,
|
|
"image": image_uri,
|
|
"output": result.stdout.strip() if result.returncode == 0 else result.stderr.strip(),
|
|
}
|
|
|
|
|
|
def verify_container_keyless(image_uri, cert_identity, cert_oidc_issuer):
|
|
"""Verify a container image signature against expected identity and issuer."""
|
|
args = [
|
|
"verify",
|
|
image_uri,
|
|
"--certificate-identity",
|
|
cert_identity,
|
|
"--certificate-oidc-issuer",
|
|
cert_oidc_issuer,
|
|
]
|
|
result = run_cosign(args)
|
|
|
|
verification_details = []
|
|
if result.returncode == 0:
|
|
try:
|
|
verification_details = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
verification_details = [{"raw_output": result.stdout.strip()}]
|
|
|
|
return {
|
|
"verified": result.returncode == 0,
|
|
"image": image_uri,
|
|
"certificate_identity": cert_identity,
|
|
"certificate_oidc_issuer": cert_oidc_issuer,
|
|
"signatures": verification_details,
|
|
}
|
|
|
|
|
|
def search_rekor_by_hash(artifact_hash, rekor_url=None):
|
|
"""Search the Rekor transparency log for entries matching an artifact hash.
|
|
|
|
Queries the Rekor REST API /api/v1/index/retrieve endpoint.
|
|
"""
|
|
base = rekor_url or REKOR_PUBLIC_URL
|
|
url = f"{base}/api/v1/index/retrieve"
|
|
payload = {"hash": f"sha256:{artifact_hash}"}
|
|
|
|
try:
|
|
resp = requests.post(url, json=payload, timeout=30)
|
|
resp.raise_for_status()
|
|
uuids = resp.json()
|
|
logger.info("Found %d Rekor entries for hash %s", len(uuids), artifact_hash[:16])
|
|
return {"hash": artifact_hash, "entry_uuids": uuids, "count": len(uuids)}
|
|
except requests.RequestException as e:
|
|
logger.error("Rekor search failed: %s", e)
|
|
return {"hash": artifact_hash, "entry_uuids": [], "error": str(e)}
|
|
|
|
|
|
def search_rekor_by_email(email, rekor_url=None):
|
|
"""Search the Rekor transparency log for entries matching an email identity."""
|
|
base = rekor_url or REKOR_PUBLIC_URL
|
|
url = f"{base}/api/v1/index/retrieve"
|
|
payload = {"email": email}
|
|
|
|
try:
|
|
resp = requests.post(url, json=payload, timeout=30)
|
|
resp.raise_for_status()
|
|
uuids = resp.json()
|
|
logger.info("Found %d Rekor entries for email %s", len(uuids), email)
|
|
return {"email": email, "entry_uuids": uuids, "count": len(uuids)}
|
|
except requests.RequestException as e:
|
|
logger.error("Rekor search failed: %s", e)
|
|
return {"email": email, "entry_uuids": [], "error": str(e)}
|
|
|
|
|
|
def get_rekor_entry(uuid, rekor_url=None):
|
|
"""Retrieve a specific entry from the Rekor transparency log by UUID."""
|
|
base = rekor_url or REKOR_PUBLIC_URL
|
|
url = f"{base}/api/v1/log/entries/{uuid}"
|
|
|
|
try:
|
|
resp = requests.get(url, timeout=30)
|
|
resp.raise_for_status()
|
|
entry_data = resp.json()
|
|
|
|
parsed = {"uuid": uuid, "raw": entry_data}
|
|
for entry_uuid, entry_body in entry_data.items():
|
|
parsed["log_index"] = entry_body.get("logIndex")
|
|
parsed["integrated_time"] = entry_body.get("integratedTime")
|
|
if parsed["integrated_time"]:
|
|
parsed["integrated_time_iso"] = datetime.fromtimestamp(
|
|
parsed["integrated_time"], tz=timezone.utc
|
|
).isoformat()
|
|
verification = entry_body.get("verification", {})
|
|
parsed["has_inclusion_proof"] = "inclusionProof" in verification
|
|
parsed["has_signed_entry_timestamp"] = "signedEntryTimestamp" in verification
|
|
break
|
|
|
|
return parsed
|
|
except requests.RequestException as e:
|
|
logger.error("Failed to retrieve Rekor entry %s: %s", uuid, e)
|
|
return {"uuid": uuid, "error": str(e)}
|
|
|
|
|
|
def verify_rekor_entry(uuid, rekor_url=None):
|
|
"""Verify a Rekor entry's inclusion proof using the rekor-cli."""
|
|
result = run_cosign(["env"]) # Check if rekor-cli is better
|
|
rekor_result = subprocess.run(
|
|
["rekor-cli", "verify", "--rekor_server", rekor_url or REKOR_PUBLIC_URL,
|
|
"--entry-uuid", uuid],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
return {
|
|
"uuid": uuid,
|
|
"inclusion_verified": rekor_result.returncode == 0,
|
|
"output": rekor_result.stdout.strip() if rekor_result.returncode == 0
|
|
else rekor_result.stderr.strip(),
|
|
}
|
|
|
|
|
|
def get_rekor_log_info(rekor_url=None):
|
|
"""Retrieve the current Rekor transparency log state (tree size, root hash)."""
|
|
base = rekor_url or REKOR_PUBLIC_URL
|
|
url = f"{base}/api/v1/log"
|
|
|
|
try:
|
|
resp = requests.get(url, timeout=30)
|
|
resp.raise_for_status()
|
|
log_info = resp.json()
|
|
return {
|
|
"tree_size": log_info.get("treeSize"),
|
|
"root_hash": log_info.get("rootHash"),
|
|
"signed_tree_head": log_info.get("signedTreeHead"),
|
|
"tree_id": log_info.get("treeID"),
|
|
}
|
|
except requests.RequestException as e:
|
|
logger.error("Failed to get Rekor log info: %s", e)
|
|
return {"error": str(e)}
|
|
|
|
|
|
def audit_signing_event(filepath=None, image_uri=None, cert_identity=None,
|
|
cert_oidc_issuer=None, rekor_url=None):
|
|
"""Perform a complete audit of a signing event: verify the artifact and
|
|
cross-reference against the Rekor transparency log."""
|
|
report = {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"artifact": filepath or image_uri,
|
|
"checks": [],
|
|
}
|
|
|
|
# Get Rekor log state
|
|
log_info = get_rekor_log_info(rekor_url)
|
|
report["rekor_log_state"] = log_info
|
|
|
|
if filepath:
|
|
artifact_hash = compute_sha256(filepath)
|
|
report["artifact_sha256"] = artifact_hash
|
|
|
|
# Search Rekor for this artifact
|
|
rekor_search = search_rekor_by_hash(artifact_hash, rekor_url)
|
|
report["rekor_entries"] = rekor_search
|
|
report["checks"].append({
|
|
"check": "rekor_entry_exists",
|
|
"passed": rekor_search.get("count", 0) > 0,
|
|
"detail": f"Found {rekor_search.get('count', 0)} Rekor entries",
|
|
})
|
|
|
|
# Retrieve entry details if found
|
|
if rekor_search.get("entry_uuids"):
|
|
first_uuid = rekor_search["entry_uuids"][0]
|
|
entry_detail = get_rekor_entry(first_uuid, rekor_url)
|
|
report["rekor_entry_detail"] = entry_detail
|
|
report["checks"].append({
|
|
"check": "inclusion_proof_present",
|
|
"passed": entry_detail.get("has_inclusion_proof", False),
|
|
"detail": "Inclusion proof found in Rekor entry"
|
|
if entry_detail.get("has_inclusion_proof")
|
|
else "No inclusion proof in Rekor entry",
|
|
})
|
|
|
|
# Verify blob if bundle and identity provided
|
|
bundle_path = str(filepath) + ".sigstore.json"
|
|
if Path(bundle_path).exists() and cert_identity and cert_oidc_issuer:
|
|
verify_result = verify_blob_keyless(
|
|
filepath, bundle_path, cert_identity, cert_oidc_issuer
|
|
)
|
|
report["verification"] = verify_result
|
|
report["checks"].append({
|
|
"check": "signature_verification",
|
|
"passed": verify_result.get("verified", False),
|
|
"detail": "Signature verified against identity and issuer"
|
|
if verify_result.get("verified")
|
|
else verify_result.get("output", "Verification failed"),
|
|
})
|
|
|
|
elif image_uri and cert_identity and cert_oidc_issuer:
|
|
verify_result = verify_container_keyless(
|
|
image_uri, cert_identity, cert_oidc_issuer
|
|
)
|
|
report["verification"] = verify_result
|
|
report["checks"].append({
|
|
"check": "container_signature_verification",
|
|
"passed": verify_result.get("verified", False),
|
|
"detail": f"Found {len(verify_result.get('signatures', []))} valid signatures"
|
|
if verify_result.get("verified")
|
|
else "Container signature verification failed",
|
|
})
|
|
|
|
# Summary
|
|
passed = sum(1 for c in report["checks"] if c["passed"])
|
|
total = len(report["checks"])
|
|
report["summary"] = {
|
|
"checks_passed": passed,
|
|
"checks_total": total,
|
|
"overall_status": "PASSED" if passed == total and total > 0 else "FAILED",
|
|
}
|
|
|
|
return report
|
|
|
|
|
|
def generate_report(results, output_path):
|
|
"""Write audit results to a JSON report file."""
|
|
with open(output_path, "w") as f:
|
|
json.dump(results, f, indent=2, default=str)
|
|
logger.info("Report written to %s", output_path)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Sigstore Software Signing Agent - Keyless signing, "
|
|
"Rekor verification, and Fulcio certificate inspection"
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# sign-blob
|
|
sign_blob_p = sub.add_parser("sign-blob", help="Sign a file blob with keyless signing")
|
|
sign_blob_p.add_argument("file", help="Path to file to sign")
|
|
sign_blob_p.add_argument("--bundle", help="Output bundle path (default: <file>.sigstore.json)")
|
|
|
|
# verify-blob
|
|
verify_blob_p = sub.add_parser("verify-blob", help="Verify a signed blob")
|
|
verify_blob_p.add_argument("file", help="Path to signed file")
|
|
verify_blob_p.add_argument("--bundle", required=True, help="Path to sigstore bundle")
|
|
verify_blob_p.add_argument("--cert-identity", required=True, help="Expected certificate identity")
|
|
verify_blob_p.add_argument("--cert-oidc-issuer", required=True, help="Expected OIDC issuer URL")
|
|
|
|
# sign-container
|
|
sign_cont_p = sub.add_parser("sign-container", help="Sign a container image")
|
|
sign_cont_p.add_argument("image", help="Container image URI (use digest, not tag)")
|
|
|
|
# verify-container
|
|
verify_cont_p = sub.add_parser("verify-container", help="Verify a container image signature")
|
|
verify_cont_p.add_argument("image", help="Container image URI")
|
|
verify_cont_p.add_argument("--cert-identity", required=True, help="Expected certificate identity")
|
|
verify_cont_p.add_argument("--cert-oidc-issuer", required=True, help="Expected OIDC issuer URL")
|
|
|
|
# search-rekor
|
|
search_p = sub.add_parser("search-rekor", help="Search Rekor transparency log")
|
|
search_group = search_p.add_mutually_exclusive_group(required=True)
|
|
search_group.add_argument("--hash", help="SHA-256 hash of artifact to search")
|
|
search_group.add_argument("--email", help="Email identity to search")
|
|
search_group.add_argument("--file", help="File to compute hash and search")
|
|
search_p.add_argument("--rekor-url", help="Custom Rekor server URL")
|
|
|
|
# get-rekor-entry
|
|
entry_p = sub.add_parser("get-rekor-entry", help="Retrieve a Rekor log entry")
|
|
entry_p.add_argument("uuid", help="Rekor entry UUID")
|
|
entry_p.add_argument("--rekor-url", help="Custom Rekor server URL")
|
|
|
|
# log-info
|
|
log_p = sub.add_parser("log-info", help="Get Rekor transparency log state")
|
|
log_p.add_argument("--rekor-url", help="Custom Rekor server URL")
|
|
|
|
# audit
|
|
audit_p = sub.add_parser("audit", help="Full audit of a signing event")
|
|
audit_group = audit_p.add_mutually_exclusive_group(required=True)
|
|
audit_group.add_argument("--file", help="Path to signed file")
|
|
audit_group.add_argument("--image", help="Container image URI")
|
|
audit_p.add_argument("--cert-identity", help="Expected certificate identity")
|
|
audit_p.add_argument("--cert-oidc-issuer", help="Expected OIDC issuer URL")
|
|
audit_p.add_argument("--rekor-url", help="Custom Rekor server URL")
|
|
|
|
# check
|
|
sub.add_parser("check", help="Verify cosign is installed and reachable")
|
|
|
|
parser.add_argument("--output", default="sigstore_report.json", help="Output report path")
|
|
args = parser.parse_args()
|
|
|
|
result = {}
|
|
|
|
if args.command == "check":
|
|
version = check_cosign_installed()
|
|
log_info = get_rekor_log_info()
|
|
result = {
|
|
"cosign_installed": version is not None,
|
|
"cosign_version": version,
|
|
"rekor_reachable": "error" not in log_info,
|
|
"rekor_tree_size": log_info.get("tree_size"),
|
|
}
|
|
|
|
elif args.command == "sign-blob":
|
|
result = sign_blob_keyless(args.file, args.bundle)
|
|
|
|
elif args.command == "verify-blob":
|
|
result = verify_blob_keyless(
|
|
args.file, args.bundle, args.cert_identity, args.cert_oidc_issuer
|
|
)
|
|
|
|
elif args.command == "sign-container":
|
|
result = sign_container_keyless(args.image)
|
|
|
|
elif args.command == "verify-container":
|
|
result = verify_container_keyless(
|
|
args.image, args.cert_identity, args.cert_oidc_issuer
|
|
)
|
|
|
|
elif args.command == "search-rekor":
|
|
rekor_url = getattr(args, "rekor_url", None)
|
|
if args.hash:
|
|
result = search_rekor_by_hash(args.hash, rekor_url)
|
|
elif args.email:
|
|
result = search_rekor_by_email(args.email, rekor_url)
|
|
elif args.file:
|
|
file_hash = compute_sha256(args.file)
|
|
result = search_rekor_by_hash(file_hash, rekor_url)
|
|
result["file"] = args.file
|
|
result["computed_hash"] = file_hash
|
|
|
|
elif args.command == "get-rekor-entry":
|
|
result = get_rekor_entry(args.uuid, getattr(args, "rekor_url", None))
|
|
|
|
elif args.command == "log-info":
|
|
result = get_rekor_log_info(getattr(args, "rekor_url", None))
|
|
|
|
elif args.command == "audit":
|
|
result = audit_signing_event(
|
|
filepath=getattr(args, "file", None),
|
|
image_uri=getattr(args, "image", None),
|
|
cert_identity=getattr(args, "cert_identity", None),
|
|
cert_oidc_issuer=getattr(args, "cert_oidc_issuer", None),
|
|
rekor_url=getattr(args, "rekor_url", None),
|
|
)
|
|
|
|
print(json.dumps(result, indent=2, default=str))
|
|
generate_report(result, args.output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|