Skip to content

Commit a966b3e

Browse files
authored
add fix-bundle plumbing command (#1089)
1 parent 93e3c5b commit a966b3e

File tree

92 files changed

+378
-74
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

92 files changed

+378
-74
lines changed

.gitattributes

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# These directories contain TUF and other assets that are either digested
22
# or sized-checked so CRLF normalization breaks them.
33
sigstore/_store/** binary diff=text
4-
test/unit/assets/** binary diff=text
5-
test/unit/assets/x509/** -binary
4+
test/assets/** binary diff=text
5+
test/assets/x509/** -binary

.github/workflows/ci.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ jobs:
4848
# This in turn effectively exercises the correctness of our
4949
# "online-only" test markers, since any test that's online
5050
# but not marked as such will fail.
51-
unshare --map-root-user --net make test TEST_ARGS="--skip-online -vv --showlocals"
51+
# We also explicitly exclude the intergration tests, since these are
52+
# always online.
53+
unshare --map-root-user --net make test T="test/unit" TEST_ARGS="--skip-online -vv --showlocals"
5254
5355
- name: test
5456
run: make test TEST_ARGS="-vv --showlocals"

.gitignore

+2-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,5 @@ build
2323
!sigstore/_store/*.crt
2424
!sigstore/_store/*.pem
2525
!sigstore/_store/*.pub
26-
!test/unit/assets/*
27-
!test/unit/assets/x509/*
28-
!test/unit/assets/staging-tuf/*
26+
!test/assets/**
27+
!test/assets/staging-tuf/**

CHANGELOG.md

+16
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ All versions prior to 0.9.0 are untracked.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
* API: `models.Bundle.BundleType` is now a public API
14+
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))
15+
16+
* CLI: The `sigstore plumbing` subcommand hierarchy has been added. This
17+
hierarchy is for *developer-only* interactions, such as fixing malformed
18+
Sigstore bundles. These subcommands are **not considered stable until
19+
explicitly documented as such**.
20+
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))
21+
22+
### Changed
23+
24+
* CLI: The default console logger now emits to `stderr`, rather than `stdout`
25+
([#1089](https://github.com/sigstore/sigstore-python/pull/1089))
26+
1127
## [3.1.0]
1228

1329
### Added

Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ endif
4040
ifneq ($(T),)
4141
T := $(T)
4242
else
43-
T := test/unit
43+
T := test/unit test/integration
4444
endif
4545

4646
.PHONY: all
@@ -91,7 +91,7 @@ test-interactive: test
9191
gen-x509-testcases: $(VENV)/pyvenv.cfg
9292
. $(VENV_BIN)/activate && \
9393
export TESTCASE_OVERWRITE=1 && \
94-
python test/unit/assets/x509/build-testcases.py && \
94+
python test/assets/x509/build-testcases.py && \
9595
git diff --exit-code
9696

9797
.PHONY: doc

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ positional arguments:
108108
get-identity-token
109109
retrieve and return a Sigstore-compatible OpenID
110110
Connect token
111+
plumbing developer-only plumbing operations
111112

112113
optional arguments:
113114
-h, --help show this help message and exit

sigstore/_cli.py

+137-24
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,21 @@
2424

2525
from cryptography.hazmat.primitives.serialization import Encoding
2626
from cryptography.x509 import load_pem_x509_certificate
27+
from rich.console import Console
2728
from rich.logging import RichHandler
29+
from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import (
30+
Bundle as RawBundle,
31+
)
2832

2933
from sigstore import __version__, dsse
3034
from sigstore._internal.fulcio.client import ExpiredCertificate
3135
from sigstore._internal.rekor import _hashedrekord_from_parts
36+
from sigstore._internal.rekor.client import RekorClient
3237
from sigstore._internal.trust import ClientTrustConfig
3338
from sigstore._utils import sha256_digest
3439
from sigstore.errors import Error, VerificationError
3540
from sigstore.hashes import Hashed
36-
from sigstore.models import Bundle
41+
from sigstore.models import Bundle, InvalidBundle
3742
from sigstore.oidc import (
3843
DEFAULT_OAUTH_ISSUER_URL,
3944
ExpiredIdentity,
@@ -47,7 +52,10 @@
4752
policy,
4853
)
4954

50-
logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
55+
_console = Console(file=sys.stderr)
56+
logging.basicConfig(
57+
format="%(message)s", datefmt="[%X]", handlers=[RichHandler(console=_console)]
58+
)
5159
_logger = logging.getLogger(__name__)
5260

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

5866

59-
def _die(args: argparse.Namespace, message: str) -> NoReturn:
67+
def _fatal(message: str) -> NoReturn:
68+
"""
69+
Logs a fatal condition and exits.
70+
"""
71+
_logger.fatal(message)
72+
sys.exit(1)
73+
74+
75+
def _invalid_arguments(args: argparse.Namespace, message: str) -> NoReturn:
6076
"""
6177
An `argparse` helper that fixes up the type hints on our use of
6278
`ArgumentParser.error`.
@@ -405,12 +421,54 @@ def _parser() -> argparse.ArgumentParser:
405421
)
406422
_add_shared_oidc_options(get_identity_token)
407423

424+
# `sigstore plumbing`
425+
plumbing = subcommands.add_parser(
426+
"plumbing",
427+
help="developer-only plumbing operations",
428+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
429+
parents=[parent_parser],
430+
)
431+
plumbing_subcommands = plumbing.add_subparsers(
432+
required=True,
433+
dest="plumbing_subcommand",
434+
metavar="COMMAND",
435+
help="the operation to perform",
436+
)
437+
438+
# `sigstore plumbing fix-bundle`
439+
fix_bundle = plumbing_subcommands.add_parser(
440+
"fix-bundle",
441+
help="fix (and optionally upgrade) older bundle formats",
442+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
443+
parents=[parent_parser],
444+
)
445+
fix_bundle.add_argument(
446+
"--bundle",
447+
metavar="FILE",
448+
type=Path,
449+
required=True,
450+
help=("The bundle to fix and/or upgrade"),
451+
)
452+
fix_bundle.add_argument(
453+
"--upgrade-version",
454+
action="store_true",
455+
help="Upgrade the bundle to the latest bundle spec version",
456+
)
457+
fix_bundle.add_argument(
458+
"--in-place",
459+
action="store_true",
460+
help="Overwrite the input bundle with its fix instead of emitting to stdout",
461+
)
462+
408463
return parser
409464

410465

411-
def main() -> None:
466+
def main(args: list[str] | None = None) -> None:
467+
if not args:
468+
args = sys.argv[1:]
469+
412470
parser = _parser()
413-
args = parser.parse_args()
471+
args = parser.parse_args(args)
414472

415473
# Configure logging upfront, so that we don't miss anything.
416474
if args.verbose >= 1:
@@ -437,10 +495,12 @@ def main() -> None:
437495
if identity:
438496
print(identity)
439497
else:
440-
_die(args, "No identity token supplied or detected!")
441-
498+
_invalid_arguments(args, "No identity token supplied or detected!")
499+
elif args.subcommand == "plumbing":
500+
if args.plumbing_subcommand == "fix-bundle":
501+
_fix_bundle(args)
442502
else:
443-
_die(args, f"Unknown subcommand: {args.subcommand}")
503+
_invalid_arguments(args, f"Unknown subcommand: {args.subcommand}")
444504
except Error as e:
445505
e.log_and_exit(_logger, args.verbose >= 1)
446506

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

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

467529
if args.output_directory and (has_sig or has_crt or has_bundle):
468-
_die(
530+
_invalid_arguments(
469531
args,
470532
"Error: --signature, --certificate, and --bundle can't be used with "
471533
"an explicit output directory.",
472534
)
473535

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

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

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

491555
output_dir = args.output_directory if args.output_directory else file.parent
492556
if output_dir.exists() and not output_dir.is_dir():
493-
_die(args, f"Output directory exists and is not a directory: {output_dir}")
557+
_invalid_arguments(
558+
args, f"Output directory exists and is not a directory: {output_dir}"
559+
)
494560
output_dir.mkdir(parents=True, exist_ok=True)
495561

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

508574
if extants:
509-
_die(
575+
_invalid_arguments(
510576
args,
511577
"Refusing to overwrite outputs without --overwrite: "
512578
f"{', '.join(extants)}",
@@ -543,7 +609,7 @@ def _sign(args: argparse.Namespace) -> None:
543609
identity = _get_identity(args)
544610

545611
if not identity:
546-
_die(args, "No identity token supplied or detected!")
612+
_invalid_arguments(args, "No identity token supplied or detected!")
547613

548614
with signing_ctx.signer(identity) as signer:
549615
for file, outputs in output_map.items():
@@ -609,26 +675,30 @@ def _collect_verification_state(
609675
# Fail if --certificate, --signature, or --bundle is specified and we
610676
# have more than one input.
611677
if (args.certificate or args.signature or args.bundle) and len(args.files) > 1:
612-
_die(
678+
_invalid_arguments(
613679
args,
614680
"--certificate, --signature, or --bundle can only be used "
615681
"with a single input file",
616682
)
617683

618684
# Fail if `--certificate` or `--signature` is used with `--bundle`.
619685
if args.bundle and (args.certificate or args.signature):
620-
_die(args, "--bundle cannot be used with --certificate or --signature")
686+
_invalid_arguments(
687+
args, "--bundle cannot be used with --certificate or --signature"
688+
)
621689

622690
# Fail if `--certificate` or `--signature` is used with `--offline`.
623691
if args.offline and (args.certificate or args.signature):
624-
_die(args, "--offline cannot be used with --certificate or --signature")
692+
_invalid_arguments(
693+
args, "--offline cannot be used with --certificate or --signature"
694+
)
625695

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

633703
sig, cert, bundle = (
634704
args.signature,
@@ -656,7 +726,7 @@ def _collect_verification_state(
656726
elif bundle.is_file() and legacy_default_bundle.is_file():
657727
# Don't allow the user to implicitly verify `{input}.sigstore.json` if
658728
# `{input}.sigstore` is also present, since this implies user confusion.
659-
_die(
729+
_invalid_arguments(
660730
args,
661731
f"Conflicting inputs: {bundle} and {legacy_default_bundle}",
662732
)
@@ -678,7 +748,7 @@ def _collect_verification_state(
678748
input_map[file] = {"bundle": bundle}
679749

680750
if missing:
681-
_die(
751+
_invalid_arguments(
682752
args,
683753
f"Missing verification materials for {(file)}: {', '.join(missing)}",
684754
)
@@ -719,7 +789,9 @@ def _collect_verification_state(
719789
_hashedrekord_from_parts(cert, signature, hashed)
720790
)
721791
if log_entry is None:
722-
_die(args, f"No matching log entry for {file}'s verification materials")
792+
_invalid_arguments(
793+
args, f"No matching log entry for {file}'s verification materials"
794+
)
723795
bundle = Bundle.from_parts(cert, signature, log_entry)
724796

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

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

854926
return token
927+
928+
929+
def _fix_bundle(args: argparse.Namespace) -> None:
930+
# NOTE: We could support `--trusted-root` here in the future,
931+
# for custom Rekor instances.
932+
if args.staging:
933+
rekor = RekorClient.staging()
934+
else:
935+
rekor = RekorClient.production()
936+
937+
raw_bundle = RawBundle().from_json(args.bundle.read_text())
938+
939+
if len(raw_bundle.verification_material.tlog_entries) != 1:
940+
_fatal("unfixable bundle: must have exactly one log entry")
941+
942+
# Some old versions of sigstore-python (1.x) produce malformed
943+
# bundles where the inclusion proof is present but without
944+
# its checkpoint. We fix these by retrieving the complete entry
945+
# from Rekor and replacing the incomplete entry.
946+
tlog_entry = raw_bundle.verification_material.tlog_entries[0]
947+
inclusion_proof = tlog_entry.inclusion_proof
948+
if not inclusion_proof.checkpoint:
949+
_logger.info("fixable: bundle's log entry is missing a checkpoint")
950+
new_entry = rekor.log.entries.get(log_index=tlog_entry.log_index)._to_rekor()
951+
raw_bundle.verification_material.tlog_entries = [new_entry]
952+
953+
# Try to create our invariant-preserving Bundle from the any changes above.
954+
try:
955+
bundle = Bundle(raw_bundle)
956+
except InvalidBundle as e:
957+
e.log_and_exit(_logger)
958+
959+
# Round-trip through the bundle's parts to induce a version upgrade,
960+
# if requested.
961+
if args.upgrade_version:
962+
bundle = Bundle._from_parts(*bundle._to_parts())
963+
964+
if args.in_place:
965+
args.bundle.write_text(bundle.to_json())
966+
else:
967+
print(bundle.to_json())

0 commit comments

Comments
 (0)