Skip to content

Commit

Permalink
add fix-bundle plumbing command (#1089)
Browse files Browse the repository at this point in the history
  • Loading branch information
woodruffw authored Aug 19, 2024
1 parent 93e3c5b commit a966b3e
Show file tree
Hide file tree
Showing 92 changed files with 378 additions and 74 deletions.
4 changes: 2 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# These directories contain TUF and other assets that are either digested
# or sized-checked so CRLF normalization breaks them.
sigstore/_store/** binary diff=text
test/unit/assets/** binary diff=text
test/unit/assets/x509/** -binary
test/assets/** binary diff=text
test/assets/x509/** -binary
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ jobs:
# This in turn effectively exercises the correctness of our
# "online-only" test markers, since any test that's online
# but not marked as such will fail.
unshare --map-root-user --net make test TEST_ARGS="--skip-online -vv --showlocals"
# We also explicitly exclude the intergration tests, since these are
# always online.
unshare --map-root-user --net make test T="test/unit" TEST_ARGS="--skip-online -vv --showlocals"
- name: test
run: make test TEST_ARGS="-vv --showlocals"
Expand Down
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,5 @@ build
!sigstore/_store/*.crt
!sigstore/_store/*.pem
!sigstore/_store/*.pub
!test/unit/assets/*
!test/unit/assets/x509/*
!test/unit/assets/staging-tuf/*
!test/assets/**
!test/assets/staging-tuf/**
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ All versions prior to 0.9.0 are untracked.

## [Unreleased]

### Added

* API: `models.Bundle.BundleType` is now a public API
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))

* CLI: The `sigstore plumbing` subcommand hierarchy has been added. This
hierarchy is for *developer-only* interactions, such as fixing malformed
Sigstore bundles. These subcommands are **not considered stable until
explicitly documented as such**.
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))

### Changed

* CLI: The default console logger now emits to `stderr`, rather than `stdout`
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))

## [3.1.0]

### Added
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ endif
ifneq ($(T),)
T := $(T)
else
T := test/unit
T := test/unit test/integration
endif

.PHONY: all
Expand Down Expand Up @@ -91,7 +91,7 @@ test-interactive: test
gen-x509-testcases: $(VENV)/pyvenv.cfg
. $(VENV_BIN)/activate && \
export TESTCASE_OVERWRITE=1 && \
python test/unit/assets/x509/build-testcases.py && \
python test/assets/x509/build-testcases.py && \
git diff --exit-code

.PHONY: doc
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ positional arguments:
get-identity-token
retrieve and return a Sigstore-compatible OpenID
Connect token
plumbing developer-only plumbing operations

optional arguments:
-h, --help show this help message and exit
Expand Down
161 changes: 137 additions & 24 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,21 @@

from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509 import load_pem_x509_certificate
from rich.console import Console
from rich.logging import RichHandler
from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import (
Bundle as RawBundle,
)

from sigstore import __version__, dsse
from sigstore._internal.fulcio.client import ExpiredCertificate
from sigstore._internal.rekor import _hashedrekord_from_parts
from sigstore._internal.rekor.client import RekorClient
from sigstore._internal.trust import ClientTrustConfig
from sigstore._utils import sha256_digest
from sigstore.errors import Error, VerificationError
from sigstore.hashes import Hashed
from sigstore.models import Bundle
from sigstore.models import Bundle, InvalidBundle
from sigstore.oidc import (
DEFAULT_OAUTH_ISSUER_URL,
ExpiredIdentity,
Expand All @@ -47,7 +52,10 @@
policy,
)

logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
_console = Console(file=sys.stderr)
logging.basicConfig(
format="%(message)s", datefmt="[%X]", handlers=[RichHandler(console=_console)]
)
_logger = logging.getLogger(__name__)

# NOTE: We configure the top package logger, rather than the root logger,
Expand All @@ -56,7 +64,15 @@
_package_logger.setLevel(os.environ.get("SIGSTORE_LOGLEVEL", "INFO").upper())


def _die(args: argparse.Namespace, message: str) -> NoReturn:
def _fatal(message: str) -> NoReturn:
"""
Logs a fatal condition and exits.
"""
_logger.fatal(message)
sys.exit(1)


def _invalid_arguments(args: argparse.Namespace, message: str) -> NoReturn:
"""
An `argparse` helper that fixes up the type hints on our use of
`ArgumentParser.error`.
Expand Down Expand Up @@ -405,12 +421,54 @@ def _parser() -> argparse.ArgumentParser:
)
_add_shared_oidc_options(get_identity_token)

# `sigstore plumbing`
plumbing = subcommands.add_parser(
"plumbing",
help="developer-only plumbing operations",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
parents=[parent_parser],
)
plumbing_subcommands = plumbing.add_subparsers(
required=True,
dest="plumbing_subcommand",
metavar="COMMAND",
help="the operation to perform",
)

# `sigstore plumbing fix-bundle`
fix_bundle = plumbing_subcommands.add_parser(
"fix-bundle",
help="fix (and optionally upgrade) older bundle formats",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
parents=[parent_parser],
)
fix_bundle.add_argument(
"--bundle",
metavar="FILE",
type=Path,
required=True,
help=("The bundle to fix and/or upgrade"),
)
fix_bundle.add_argument(
"--upgrade-version",
action="store_true",
help="Upgrade the bundle to the latest bundle spec version",
)
fix_bundle.add_argument(
"--in-place",
action="store_true",
help="Overwrite the input bundle with its fix instead of emitting to stdout",
)

return parser


def main() -> None:
def main(args: list[str] | None = None) -> None:
if not args:
args = sys.argv[1:]

parser = _parser()
args = parser.parse_args()
args = parser.parse_args(args)

# Configure logging upfront, so that we don't miss anything.
if args.verbose >= 1:
Expand All @@ -437,10 +495,12 @@ def main() -> None:
if identity:
print(identity)
else:
_die(args, "No identity token supplied or detected!")

_invalid_arguments(args, "No identity token supplied or detected!")
elif args.subcommand == "plumbing":
if args.plumbing_subcommand == "fix-bundle":
_fix_bundle(args)
else:
_die(args, f"Unknown subcommand: {args.subcommand}")
_invalid_arguments(args, f"Unknown subcommand: {args.subcommand}")
except Error as e:
e.log_and_exit(_logger, args.verbose >= 1)

Expand All @@ -453,34 +513,38 @@ def _sign(args: argparse.Namespace) -> None:
# `--no-default-files` has no effect on `--bundle`, but we forbid it because
# it indicates user confusion.
if args.no_default_files and has_bundle:
_die(args, "--no-default-files may not be combined with --bundle.")
_invalid_arguments(
args, "--no-default-files may not be combined with --bundle."
)

# Fail if `--signature` or `--certificate` is specified *and* we have more
# than one input.
if (has_sig or has_crt or has_bundle) and len(args.files) > 1:
_die(
_invalid_arguments(
args,
"Error: --signature, --certificate, and --bundle can't be used with "
"explicit outputs for multiple inputs.",
)

if args.output_directory and (has_sig or has_crt or has_bundle):
_die(
_invalid_arguments(
args,
"Error: --signature, --certificate, and --bundle can't be used with "
"an explicit output directory.",
)

# Fail if either `--signature` or `--certificate` is specified, but not both.
if has_sig ^ has_crt:
_die(args, "Error: --signature and --certificate must be used together.")
_invalid_arguments(
args, "Error: --signature and --certificate must be used together."
)

# Build up the map of inputs -> outputs ahead of any signing operations,
# so that we can fail early if overwriting without `--overwrite`.
output_map: dict[Path, dict[str, Path | None]] = {}
for file in args.files:
if not file.is_file():
_die(args, f"Input must be a file: {file}")
_invalid_arguments(args, f"Input must be a file: {file}")

sig, cert, bundle = (
args.signature,
Expand All @@ -490,7 +554,9 @@ def _sign(args: argparse.Namespace) -> None:

output_dir = args.output_directory if args.output_directory else file.parent
if output_dir.exists() and not output_dir.is_dir():
_die(args, f"Output directory exists and is not a directory: {output_dir}")
_invalid_arguments(
args, f"Output directory exists and is not a directory: {output_dir}"
)
output_dir.mkdir(parents=True, exist_ok=True)

if not bundle and not args.no_default_files:
Expand All @@ -506,7 +572,7 @@ def _sign(args: argparse.Namespace) -> None:
extants.append(str(bundle))

if extants:
_die(
_invalid_arguments(
args,
"Refusing to overwrite outputs without --overwrite: "
f"{', '.join(extants)}",
Expand Down Expand Up @@ -543,7 +609,7 @@ def _sign(args: argparse.Namespace) -> None:
identity = _get_identity(args)

if not identity:
_die(args, "No identity token supplied or detected!")
_invalid_arguments(args, "No identity token supplied or detected!")

with signing_ctx.signer(identity) as signer:
for file, outputs in output_map.items():
Expand Down Expand Up @@ -609,26 +675,30 @@ def _collect_verification_state(
# Fail if --certificate, --signature, or --bundle is specified and we
# have more than one input.
if (args.certificate or args.signature or args.bundle) and len(args.files) > 1:
_die(
_invalid_arguments(
args,
"--certificate, --signature, or --bundle can only be used "
"with a single input file",
)

# Fail if `--certificate` or `--signature` is used with `--bundle`.
if args.bundle and (args.certificate or args.signature):
_die(args, "--bundle cannot be used with --certificate or --signature")
_invalid_arguments(
args, "--bundle cannot be used with --certificate or --signature"
)

# Fail if `--certificate` or `--signature` is used with `--offline`.
if args.offline and (args.certificate or args.signature):
_die(args, "--offline cannot be used with --certificate or --signature")
_invalid_arguments(
args, "--offline cannot be used with --certificate or --signature"
)

# The converse of `sign`: we build up an expected input map and check
# that we have everything so that we can fail early.
input_map = {}
for file in args.files:
if not file.is_file():
_die(args, f"Input must be a file: {file}")
_invalid_arguments(args, f"Input must be a file: {file}")

sig, cert, bundle = (
args.signature,
Expand Down Expand Up @@ -656,7 +726,7 @@ def _collect_verification_state(
elif bundle.is_file() and legacy_default_bundle.is_file():
# Don't allow the user to implicitly verify `{input}.sigstore.json` if
# `{input}.sigstore` is also present, since this implies user confusion.
_die(
_invalid_arguments(
args,
f"Conflicting inputs: {bundle} and {legacy_default_bundle}",
)
Expand All @@ -678,7 +748,7 @@ def _collect_verification_state(
input_map[file] = {"bundle": bundle}

if missing:
_die(
_invalid_arguments(
args,
f"Missing verification materials for {(file)}: {', '.join(missing)}",
)
Expand Down Expand Up @@ -719,7 +789,9 @@ def _collect_verification_state(
_hashedrekord_from_parts(cert, signature, hashed)
)
if log_entry is None:
_die(args, f"No matching log entry for {file}'s verification materials")
_invalid_arguments(
args, f"No matching log entry for {file}'s verification materials"
)
bundle = Bundle.from_parts(cert, signature, log_entry)

_logger.debug(f"Verifying contents from: {file}")
Expand Down Expand Up @@ -752,7 +824,7 @@ def _verify_github(args: argparse.Namespace) -> None:
# We require at least one of `--cert-identity` or `--repository`,
# to minimize the risk of user confusion about what's being verified.
if not (args.cert_identity or args.workflow_repository):
_die(args, "--cert-identity or --repository is required")
_invalid_arguments(args, "--cert-identity or --repository is required")

# No matter what the user configures above, we require the OIDC issuer to
# be GitHub Actions.
Expand Down Expand Up @@ -852,3 +924,44 @@ def _get_identity(args: argparse.Namespace) -> Optional[IdentityToken]:
)

return token


def _fix_bundle(args: argparse.Namespace) -> None:
# NOTE: We could support `--trusted-root` here in the future,
# for custom Rekor instances.
if args.staging:
rekor = RekorClient.staging()
else:
rekor = RekorClient.production()

raw_bundle = RawBundle().from_json(args.bundle.read_text())

if len(raw_bundle.verification_material.tlog_entries) != 1:
_fatal("unfixable bundle: must have exactly one log entry")

# Some old versions of sigstore-python (1.x) produce malformed
# bundles where the inclusion proof is present but without
# its checkpoint. We fix these by retrieving the complete entry
# from Rekor and replacing the incomplete entry.
tlog_entry = raw_bundle.verification_material.tlog_entries[0]
inclusion_proof = tlog_entry.inclusion_proof
if not inclusion_proof.checkpoint:
_logger.info("fixable: bundle's log entry is missing a checkpoint")
new_entry = rekor.log.entries.get(log_index=tlog_entry.log_index)._to_rekor()
raw_bundle.verification_material.tlog_entries = [new_entry]

# Try to create our invariant-preserving Bundle from the any changes above.
try:
bundle = Bundle(raw_bundle)
except InvalidBundle as e:
e.log_and_exit(_logger)

# Round-trip through the bundle's parts to induce a version upgrade,
# if requested.
if args.upgrade_version:
bundle = Bundle._from_parts(*bundle._to_parts())

if args.in_place:
args.bundle.write_text(bundle.to_json())
else:
print(bundle.to_json())
Loading

0 comments on commit a966b3e

Please sign in to comment.