From 73c84552c33c3379c3f7c60918e9d1624af517ed Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:43:22 +0000 Subject: [PATCH 01/22] Pin native SBOM tool metadata and SPDX schema source --- scripts/ci/native-sbom/README.md | 26 + scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE | 92 +++ scripts/ci/native-sbom/spdx-schema-2.3.json | 766 ++++++++++++++++++++ scripts/ci/native-sbom/tool-pins.v1.json | 53 ++ 4 files changed, 937 insertions(+) create mode 100644 scripts/ci/native-sbom/README.md create mode 100644 scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE create mode 100644 scripts/ci/native-sbom/spdx-schema-2.3.json create mode 100644 scripts/ci/native-sbom/tool-pins.v1.json diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md new file mode 100644 index 000000000..5d694e3c7 --- /dev/null +++ b/scripts/ci/native-sbom/README.md @@ -0,0 +1,26 @@ +# Native SBOM source inputs + +`tool-pins.v1.json` records the immutable Syft 1.55.0 two-platform tool map and +`actions/attest` v4.2.2 commit reviewed in Server #1012, comments 6644320041, +6648408237 and Security 6649211450. The upstream tool index includes additional +platforms; only the two explicit child/config pairs are selected. Neither tool +identity is a Server SBOM subject. The upstream Syft tag is observed unsigned. +The generator binaries have not been run or qualified by this metadata record. + +The new schema input is an unmodified copy of **SPDX Project, SPDX Specification +2.3.1**, `schemas/spdx-schema.json`, from the SPDX Project's +[immutable source](https://github.com/spdx/spdx-spec/blob/6f2cb47d13db19b12c23885573b22ef32ec1ada5/schemas/spdx-schema.json). +The upstream [CC-BY-3.0 license](https://github.com/spdx/spdx-spec/blob/6f2cb47d13db19b12c23885573b22ef32ec1ada5/LICENSE) +is included verbatim as `SPDX-SCHEMA-LICENSE`; attribution is retained here. +The pin map records exact source/blob, size and SHA-256. This schema validates +SPDX-2.3 documents using JSON Schema 2019-09. Its references are all local; +future validation must disable remote retrieval, including instance-supplied +schema URLs. Vendoring this schema does not qualify a schema-validator package. + +The still-null validator dependency closure and generation mode are explicit +unfinished source work, not permissive defaults. No workflow currently consumes +these inputs. The complete generation/confinement, independent schema/semantic +validation, exact Server child predicates, retained raw bytes/checksum map, +Release asset equality/idempotency and fail-before controls remain required +before a normal source/CI and Security review. No pull, build, tool/scan, +attestation, registry, Release or frozen-subject operation is admitted here. diff --git a/scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE b/scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE new file mode 100644 index 000000000..44a22d370 --- /dev/null +++ b/scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE @@ -0,0 +1,92 @@ +Creative Commons Attribution 3.0 Unported + +CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE. +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS. + +1. Definitions + +a. "Adaptation" means a work based upon the Work, or upon the Work and other pre-existing works, such as a translation, adaptation, derivative work, arrangement of music or other alterations of a literary or artistic work, or phonogram or performance and includes cinematographic adaptations or any other form in which the Work may be recast, transformed, or adapted including in any form recognizably derived from the original, except that a work that constitutes a Collection will not be considered an Adaptation for the purpose of this License. For the avoidance of doubt, where the Work is a musical work, performance or phonogram, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered an Adaptation for the purpose of this License. + +b. "Collection" means a collection of literary or artistic works, such as encyclopedias and anthologies, or performances, phonograms or broadcasts, or other works or subject matter other than works listed in Section 1(f) below, which, by reason of the selection and arrangement of their contents, constitute intellectual creations, in which the Work is included in its entirety in unmodified form along with one or more other contributions, each constituting separate and independent works in themselves, which together are assembled into a collective whole. A work that constitutes a Collection will not be considered an Adaptation (as defined above) for the purposes of this License. + +c. "Distribute" means to make available to the public the original and copies of the Work or Adaptation, as appropriate, through sale or other transfer of ownership. + +d. "Licensor" means the individual, individuals, entity or entities that offer(s) the Work under the terms of this License. + +e. "Original Author" means, in the case of a literary or artistic work, the individual, individuals, entity or entities who created the Work or if no individual or entity can be identified, the publisher; and in addition (i) in the case of a performance the actors, singers, musicians, dancers, and other persons who act, sing, deliver, declaim, play in, interpret or otherwise perform literary or artistic works or expressions of folklore; (ii) in the case of a phonogram the producer being the person or legal entity who first fixes the sounds of a performance or other sounds; and, (iii) in the case of broadcasts, the organization that transmits the broadcast. + +f. "Work" means the literary and/or artistic work offered under the terms of this License including without limitation any production in the literary, scientific and artistic domain, whatever may be the mode or form of its expression including digital form, such as a book, pamphlet and other writing; a lecture, address, sermon or other work of the same nature; a dramatic or dramatico-musical work; a choreographic work or entertainment in dumb show; a musical composition with or without words; a cinematographic work to which are assimilated works expressed by a process analogous to cinematography; a work of drawing, painting, architecture, sculpture, engraving or lithography; a photographic work to which are assimilated works expressed by a process analogous to photography; a work of applied art; an illustration, map, plan, sketch or three-dimensional work relative to geography, topography, architecture or science; a performance; a broadcast; a phonogram; a compilation of data to the extent it is protected as a copyrightable work; or a work performed by a variety or circus performer to the extent it is not otherwise considered a literary or artistic work. + +g. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation. + +h. "Publicly Perform" means to perform public recitations of the Work and to communicate to the public those public recitations, by any means or process, including by wire or wireless means or public digital performances; to make available to the public Works in such a way that members of the public may access these Works from a place and at a place individually chosen by them; to perform the Work to the public by any means or process and the communication to the public of the performances of the Work, including by public digital performance; to broadcast and rebroadcast the Work by any means including signs, sounds or images. + +i. "Reproduce" means to make copies of the Work by any means including without limitation by sound or visual recordings and the right of fixation and reproducing fixations of the Work, including storage of a protected performance or phonogram in digital form or other electronic medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in connection with the copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below: + +a. to Reproduce the Work, to incorporate the Work into one or more Collections, and to Reproduce the Work as incorporated in the Collections; + +b. to create and Reproduce Adaptations provided that any such Adaptation, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified."; + +c. to Distribute and Publicly Perform the Work including as incorporated in Collections; and, + +d. to Distribute and Publicly Perform Adaptations. + +e. For the avoidance of doubt: + +i. Non-waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme cannot be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; + +ii. Waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme can be waived, the Licensor waives the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; and, + +iii. Voluntary License Schemes. The Licensor waives the right to collect royalties, whether individually or, in the event that the Licensor is a member of a collecting society that administers voluntary licensing schemes, via that society, from any exercise by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. Subject to Section 8(f), all rights not expressly granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions: + +a. You may Distribute or Publicly Perform the Work only under the terms of this License. You must include a copy of, or the Uniform Resource Identifier (URI) for, this License with every copy of the Work You Distribute or Publicly Perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of the recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties with every copy of the Work You Distribute or Publicly Perform. When You Distribute or Publicly Perform the Work, You may not impose any effective technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collection, but this does not require the Collection apart from the Work itself to be made subject to the terms of this License. If You create a Collection, upon notice from any Licensor You must, to the extent practicable, remove from the Collection any credit as required by Section 4(b), as requested. If You create an Adaptation, upon notice from any Licensor You must, to the extent practicable, remove from the Adaptation any credit as required by Section 4(b), as requested. + +b. If You Distribute, or Publicly Perform the Work or any Adaptations or Collections, You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or if the Original Author and/or Licensor designate another party or parties (e.g., a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; (ii) the title of the Work if supplied; (iii) to the extent reasonably practicable, the URI, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and (iv) , consistent with Section 3(b), in the case of an Adaptation, a credit identifying the use of the Work in the Adaptation (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4 (b) may be implemented in any reasonable manner; provided, however, that in the case of a Adaptation or Collection, at a minimum such credit will appear, if a credit for all contributing authors of the Adaptation or Collection appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties. + +c. Except as otherwise agreed in writing by the Licensor or as may be otherwise permitted by applicable law, if You Reproduce, Distribute or Publicly Perform the Work either by itself or as part of any Adaptations or Collections, You must not distort, mutilate, modify or take other derogatory action in relation to the Work which would be prejudicial to the Original Author's honor or reputation. Licensor agrees that in those jurisdictions (e.g. Japan), in which any exercise of the right granted in Section 3(b) of this License (the right to make Adaptations) would be deemed to be a distortion, mutilation, modification or other derogatory action prejudicial to the Original Author's honor and reputation, the Licensor will waive or not assert, as appropriate, this Section, to the fullest extent permitted by the applicable national law, to enable You to reasonably exercise Your right under Section 3(b) of this License (right to make Adaptations) but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + +a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Adaptations or Collections from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License. + +b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above. + +8. Miscellaneous + +a. Each time You Distribute or Publicly Perform the Work or a Collection, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License. + +b. Each time You Distribute or Publicly Perform an Adaptation, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License. + +c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + +d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. + +e. This License may not be modified without the mutual written agreement of the Licensor and You. + +f. The rights granted under, and the subject matter referenced, in this License were drafted utilizing the terminology of the Berne Convention for the Protection of Literary and Artistic Works (as amended on September 28, 1979), the Rome Convention of 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and the Universal Copyright Convention (as revised on July 24, 1971). These rights and subject matter take effect in the relevant jurisdiction in which the License terms are sought to be enforced according to the corresponding provisions of the implementation of those treaty provisions in the applicable national law. If the standard suite of rights granted under applicable copyright law includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law. + +Creative Commons Notice + +Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor. + +Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, Creative Commons does not authorize the use by either party of the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time. For the avoidance of doubt, this trademark restriction does not form part of this License. + +Creative Commons may be contacted at http://creativecommons.org/. \ No newline at end of file diff --git a/scripts/ci/native-sbom/spdx-schema-2.3.json b/scripts/ci/native-sbom/spdx-schema-2.3.json new file mode 100644 index 000000000..2dc11d1a3 --- /dev/null +++ b/scripts/ci/native-sbom/spdx-schema-2.3.json @@ -0,0 +1,766 @@ +{ + "$schema" : "https://json-schema.org/draft/2019-09/schema", + "$id" : "http://spdx.org/rdf/terms/2.3", + "title" : "SPDX 2.3.1", + "type" : "object", + "properties" : { + "$schema": { + "type": "string", + "description": "Reference the SPDX 2.3.1 JSON schema." + }, + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "comment" : { + "type" : "string" + }, + "creationInfo" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "created" : { + "description" : "Identify when the SPDX document was originally created. The date is to be specified according to combined date and time in UTC format as specified in ISO 8601 standard.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "creators" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "minItems" : 1, + "type" : "array", + "items" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "type" : "string" + } + }, + "licenseListVersion" : { + "description" : "An optional field for creators of the SPDX file to provide the version of the SPDX License List used when the SPDX file was created.", + "type" : "string" + } + }, + "required" : [ "created", "creators" ], + "additionalProperties" : false, + "description" : "One instance is required for each SPDX file produced. It provides the necessary information for forward and backward compatibility for processing tools." + }, + "dataLicense" : { + "description" : "License expression for dataLicense. See SPDX Annex D for the license expression syntax. Compliance with the SPDX specification includes populating the SPDX fields therein with data related to such fields (\"SPDX-Metadata\"). The SPDX specification contains numerous fields where an SPDX document creator may provide relevant explanatory text in SPDX-Metadata. Without opining on the lawfulness of \"database rights\" (in jurisdictions where applicable), such explanatory text is copyrightable subject matter in most Berne Convention countries. By using the SPDX specification, or any portion hereof, you hereby agree that any copyright rights (as determined by your jurisdiction) in any SPDX-Metadata, including without limitation explanatory text, shall be subject to the terms of the Creative Commons CC0 1.0 Universal license. For SPDX-Metadata not containing any copyright rights, you hereby agree and acknowledge that the SPDX-Metadata is provided to you \"as-is\" and without any representations or warranties of any kind concerning the SPDX-Metadata, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non-infringement, or the absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.", + "type" : "string" + }, + "externalDocumentRefs" : { + "description" : "Identify any external SPDX documents referenced within this SPDX document.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "checksum" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexadecimal encoded digest value produced using a specific algorithm.", + "pattern" : "^[0-9a-fA-F]+$", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + }, + "externalDocumentId" : { + "description" : "externalDocumentId is a string containing letters, numbers, ., - and/or + which uniquely identifies an external document within this document.", + "type" : "string" + }, + "spdxDocument" : { + "description" : "SPDX ID for SpdxDocument. A property containing an SPDX document.", + "type" : "string" + } + }, + "required" : [ "checksum", "externalDocumentId", "spdxDocument" ], + "additionalProperties" : false, + "description" : "Information about an external SPDX document reference including the checksum. This allows for verification of the external references." + } + }, + "hasExtractedLicensingInfos" : { + "description" : "Indicates that a particular ExtractedLicensingInfo was defined in the subject SpdxDocument.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "crossRefs" : { + "description" : "Cross Reference Detail for a license SeeAlso URL", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "isLive" : { + "description" : "Indicate a URL is still a live accessible location on the public internet", + "type" : "boolean" + }, + "isValid" : { + "description" : "True if the URL is a valid well formed URL", + "type" : "boolean" + }, + "isWayBackLink" : { + "description" : "True if the License SeeAlso URL points to a Wayback archive", + "type" : "boolean" + }, + "match" : { + "description" : "Status of a License List SeeAlso URL reference if it refers to a website that matches the license text.", + "type" : "string" + }, + "order" : { + "description" : "The ordinal order of this element within a list", + "type" : "integer" + }, + "timestamp" : { + "description" : "Timestamp", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "url" : { + "description" : "URL Reference", + "type" : "string" + } + }, + "required" : [ "url" ], + "additionalProperties" : false, + "description" : "Cross reference details for the a URL reference" + } + }, + "extractedText" : { + "description" : "Provide a copy of the actual text of the license reference extracted from the package, file or snippet that is associated with the License Identifier to aid in future analysis.", + "type" : "string" + }, + "licenseId" : { + "description" : "A human readable short form license identifier for a license. The license ID is either on the standard license list or the form \"LicenseRef-[idString]\" where [idString] is a unique string containing letters, numbers, \".\" or \"-\". When used within a license expression, the license ID can optionally include a reference to an external document in the form \"DocumentRef-[docrefIdString]:LicenseRef-[idString]\" where docRefIdString is an ID for an external document reference.", + "type" : "string" + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "seeAlsos" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "extractedText", "licenseId" ], + "additionalProperties" : false, + "description" : "An ExtractedLicensingInfo represents a license or licensing notice that was found in a package, file or snippet. Any license text that is recognized as a license may be represented as a License rather than an ExtractedLicensingInfo." + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "revieweds" : { + "description" : "Reviewed", + "deprecated": true, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "reviewDate" : { + "description" : "The date and time at which the SpdxDocument was reviewed. This value must be in UTC and have 'Z' as its timezone indicator.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "reviewer" : { + "description" : "The name and, optionally, contact information of the person who performed the review. Values of this property must conform to the agent and tool syntax. The reviewer property is deprecated in favor of Annotation with an annotationType review.", + "type" : "string" + } + }, + "required" : [ "reviewDate" ], + "additionalProperties" : false, + "description" : "This class has been deprecated in favor of an Annotation with an Annotation type of review." + } + }, + "spdxVersion" : { + "description" : "Provide a reference number that can be used to understand how to parse and interpret the rest of the file. It will enable both future changes to the specification and to support backward compatibility. The version number consists of a major and minor version indicator. The major field will be incremented when incompatible changes between versions are made (one or more sections are created, modified or deleted). The minor field will be incremented when backwards compatible changes are made.", + "type" : "string" + }, + "documentNamespace" : { + "type" : "string", + "description" : "The URI provides an unambiguous mechanism for other SPDX documents to reference SPDX elements within this SPDX document." + }, + "documentDescribes" : { + "description" : "DEPRECATED: use relationships instead of this field. Packages, files and/or Snippets described by this SPDX document", + "deprecated": true, + "$comment": "This field has been deprecated as it is a duplicate of using the SPDXRef-DOCUMENT DESCRIBES relationship", + "type" : "array", + "items" : { + "type" : "string", + "description" : "SPDX ID for each Package, File, or Snippet." + } + }, + "packages" : { + "description" : "Packages referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "pattern" : "^SPDXRef-[a-zA-Z0-9.-]+$", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "builtDate" : { + "description" : "This field provides a place for recording the actual date the package was built.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexadecimal encoded digest value produced using a specific algorithm.", + "pattern" : "^[0-9a-fA-F]+$", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "description" : { + "description" : "Provides a detailed description of the package.", + "type" : "string" + }, + "downloadLocation" : { + "description" : "The URI at which this package is available for download. Private (i.e., not publicly reachable) URIs are acceptable as values of this property. The values http://spdx.org/rdf/terms#none and http://spdx.org/rdf/terms#noassertion may be used to specify that the package is not downloadable or that no attempt was made to determine its download location, respectively.", + "type" : "string" + }, + "externalRefs" : { + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "referenceCategory" : { + "description" : "Category for the external reference", + "type" : "string", + "enum" : [ "OTHER", "PERSISTENT-ID", "PERSISTENT_ID", "SECURITY", "PACKAGE-MANAGER", "PACKAGE_MANAGER" ] + }, + "referenceLocator" : { + "description" : "The unique string with no spaces necessary to access the package-specific information, metadata, or content within the target location. The format of the locator is subject to constraints defined by the .", + "type" : "string" + }, + "referenceType" : { + "description" : "Type of the external reference. These are defined in an appendix in the SPDX specification.", + "type" : "string" + } + }, + "required" : [ "referenceCategory", "referenceLocator", "referenceType" ], + "additionalProperties" : false, + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package." + } + }, + "filesAnalyzed" : { + "description" : "Indicates whether the file content of this package has been available for or subjected to analysis when creating the SPDX document. If false indicates packages that represent metadata or URI references to a project, product, artifact, distribution or a component. If set to false, the package must not contain any files.", + "type" : "boolean" + }, + "hasFiles" : { + "description" : "DEPRECATED: use relationships instead of this field. Indicates that a particular file belongs to a package.", + "deprecated": true, + "$comment": "This field has been deprecated as it is a duplicate of using CONTAINS relationships from a package to files", + "type" : "array", + "items" : { + "description" : "SPDX ID for File. Indicates that a particular file belongs to a package.", + "type" : "string" + } + }, + "homepage" : { + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseDeclared" : { + "description" : "License expression for licenseDeclared. See SPDX Annex D for the license expression syntax. The licensing that the creators of the software in the package, or the packager, have declared. Declarations by the original software creator should be preferred, if they exist.", + "type" : "string" + }, + "licenseInfoFromFiles" : { + "description" : "The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of all licenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same package is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoFromFiles. See SPDX Annex D for the license expression syntax. The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of all licenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same package is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "originator" : { + "description" : "The name and, optionally, contact information of the person or organization that originally created the package. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "packageFileName" : { + "description" : "The base name of the package file name. For example, zlib-1.2.5.tar.gz.", + "type" : "string" + }, + "packageVerificationCode" : { + "type" : "object", + "properties" : { + "packageVerificationCodeExcludedFiles" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "array", + "items" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "string" + } + }, + "packageVerificationCodeValue" : { + "description" : "The actual package verification code as a hex encoded value.", + "type" : "string" + } + }, + "required" : [ "packageVerificationCodeValue" ], + "additionalProperties" : false, + "description" : "A manifest based verification code (the algorithm is defined in section 4.7 of the full specification) of the SPDX Item. This allows consumers of this data and/or database to determine if an SPDX item they have in hand is identical to the SPDX item from which the data was produced. This algorithm works even if the SPDX document is included in the SPDX item." + }, + "primaryPackagePurpose" : { + "description" : "This field provides information about the primary purpose of the identified package. Package Purpose is intrinsic to how the package is being used rather than the content of the package.", + "type" : "string", + "enum" : [ "OTHER", "INSTALL", "ARCHIVE", "FIRMWARE", "APPLICATION", "FRAMEWORK", "LIBRARY", "CONTAINER", "SOURCE", "DEVICE", "OPERATING-SYSTEM", "FILE" ] + }, + "releaseDate" : { + "description" : "This field provides a place for recording the date the package was released.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "sourceInfo" : { + "description" : "Allows the producer(s) of the SPDX document to describe how the package was acquired and/or changed from the original source.", + "type" : "string" + }, + "summary" : { + "description" : "Provides a short description of the package.", + "type" : "string" + }, + "supplier" : { + "description" : "The name and, optionally, contact information of the person or organization who was the immediate supplier of this package to the recipient. The supplier may be different than originator when the software has been repackaged. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "validUntilDate" : { + "description" : "This field provides a place for recording the end of the support period for a package from the supplier.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "versionInfo" : { + "description" : "Provides an indication of the version of the package that is described by this SpdxDocument.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "downloadLocation", "name" ], + "additionalProperties" : false + } + }, + "files" : { + "description" : "Files referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "pattern" : "^SPDXRef-[a-zA-Z0-9.-]+$", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "artifactOfs" : { + "description" : "Indicates the project in which the SpdxElement originated. Tools must preserve doap:homepage and doap:name properties and the URI (if one is known) of doap:Project resources that are values of this property. All other properties of doap:Projects are not directly supported by SPDX and may be dropped when translating to or from some SPDX formats.", + "type" : "array", + "items" : { + "type" : "object" + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexadecimal encoded digest value produced using a specific algorithm.", + "pattern" : "^[0-9a-fA-F]+$", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "fileContributors" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "string" + } + }, + "fileDependencies" : { + "description" : "This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "deprecated": true, + "type" : "array", + "items" : { + "description" : "SPDX ID for File. This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "type" : "string" + } + }, + "fileName" : { + "description" : "The name of the file relative to the root of the package.", + "type" : "string" + }, + "fileTypes" : { + "description" : "The type of the file.", + "type" : "array", + "items" : { + "description" : "The type of the file.", + "type" : "string", + "enum" : [ "OTHER", "DOCUMENTATION", "IMAGE", "VIDEO", "ARCHIVE", "SPDX", "APPLICATION", "SOURCE", "BINARY", "TEXT", "AUDIO" ] + } + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInFiles" : { + "description" : "Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInFile. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "noticeText" : { + "description" : "This field provides a place for the SPDX file creator to record potential legal notices found in the file. This may or may not include copyright statements.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "checksums", "fileName" ], + "additionalProperties" : false + } + }, + "snippets" : { + "description" : "Snippets referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "pattern" : "^SPDXRef-[a-zA-Z0-9.-]+$", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string", + "pattern" : "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]|23:59:60)Z$" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConcluded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInSnippets" : { + "description" : "Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInSnippet. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "ranges" : { + "description" : "This field defines the byte range in the original host file (in X.2) that the snippet information applies to", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "endPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + }, + "startPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + } + }, + "required" : [ "endPointer", "startPointer" ], + "additionalProperties" : false + } + }, + "snippetFromFile" : { + "description" : "SPDX ID for File. File containing the SPDX element (e.g. the file containing a snippet).", + "type" : "string" + } + }, + "required" : [ "SPDXID", "ranges", "snippetFromFile" ], + "additionalProperties" : false + } + }, + "relationships" : { + "description" : "Relationships referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "spdxElementId" : { + "type" : "string", + "description" : "Id to which the SPDX element is related" + }, + "comment" : { + "type" : "string" + }, + "relatedSpdxElement" : { + "description" : "SPDX ID for SpdxElement. A related SpdxElement.", + "type" : "string" + }, + "relationshipType" : { + "description" : "Describes the type of relationship between two SPDX elements.", + "type" : "string", + "enum" : [ "VARIANT_OF", "COPY_OF", "PATCH_FOR", "TEST_DEPENDENCY_OF", "CONTAINED_BY", "DATA_FILE_OF", "OPTIONAL_COMPONENT_OF", "ANCESTOR_OF", "GENERATES", "CONTAINS", "OPTIONAL_DEPENDENCY_OF", "FILE_ADDED", "REQUIREMENT_DESCRIPTION_FOR", "DEV_DEPENDENCY_OF", "DEPENDENCY_OF", "BUILD_DEPENDENCY_OF", "DESCRIBES", "PREREQUISITE_FOR", "HAS_PREREQUISITE", "PROVIDED_DEPENDENCY_OF", "DYNAMIC_LINK", "DESCRIBED_BY", "METAFILE_OF", "DEPENDENCY_MANIFEST_OF", "PATCH_APPLIED", "RUNTIME_DEPENDENCY_OF", "TEST_OF", "TEST_TOOL_OF", "DEPENDS_ON", "SPECIFICATION_FOR", "FILE_MODIFIED", "DISTRIBUTION_ARTIFACT", "AMENDS", "DOCUMENTATION_OF", "GENERATED_FROM", "STATIC_LINK", "OTHER", "BUILD_TOOL_OF", "TEST_CASE_OF", "PACKAGE_OF", "DESCENDANT_OF", "FILE_DELETED", "EXPANDED_FROM_ARCHIVE", "DEV_TOOL_OF", "EXAMPLE_OF" ] + } + }, + "required" : [ "spdxElementId", "relatedSpdxElement", "relationshipType" ], + "additionalProperties" : false + } + } + }, + "required" : [ "SPDXID", "creationInfo", "dataLicense", "name", "spdxVersion", "documentNamespace" ], + "additionalProperties" : false +} diff --git a/scripts/ci/native-sbom/tool-pins.v1.json b/scripts/ci/native-sbom/tool-pins.v1.json new file mode 100644 index 000000000..435ed396f --- /dev/null +++ b/scripts/ci/native-sbom/tool-pins.v1.json @@ -0,0 +1,53 @@ +{ + "admission": { + "generation_mode": null, + "generator_binary_qualified": false, + "metadata_source_only": true, + "scan_or_publication_admitted": false, + "schema_validator_dependency_closure": null + }, + "attestation_action": { + "commit": "1e69f48acb82d1966a394da916b4c1698aa569d6", + "predicate_type": "https://spdx.dev/Document/v2.3", + "repository": "actions/attest", + "upstream_tag": "v4.2.2" + }, + "document_schema": { + "bytes": 47602, + "document_version": "SPDX-2.3", + "git_blob": "2dc11d1a34268227b63113d788b89efd75b5d2a8", + "json_schema_dialect": "https://json-schema.org/draft/2019-09/schema", + "license": "CC-BY-3.0", + "license_path": "scripts/ci/native-sbom/SPDX-SCHEMA-LICENSE", + "license_sha256": "017e38491cccbd2bdb6da0a32a33db9ec245b5dab30fdcd09f2c742c975e5b35", + "local_path": "scripts/ci/native-sbom/spdx-schema-2.3.json", + "repository": "spdx/spdx-spec", + "sha256": "4126dc29f15e92feec92e9622f4939131ad58b125d6f578aa93e980a6e6f3212", + "source_commit": "6f2cb47d13db19b12c23885573b22ef32ec1ada5", + "source_path": "schemas/spdx-schema.json", + "upstream_tag": "v2.3.1", + "upstream_tag_object": "cb1dada44bcc0abc4a0285f6448285c5c8fd4cb3" + }, + "generator": { + "image": "docker.io/anchore/syft", + "index_digest": "sha256:4cc92b64ca74ffca4c94d4c8fcc01f217731375300be14d0b6eeb2e82eaa110a", + "name": "syft", + "platforms": { + "linux/amd64": { + "child_manifest_digest": "sha256:b83763ae05df444f7f768655ce2ea6d7c2f8280d58f9bee221a69d2971d98883", + "image_config_digest": "sha256:ff2c886c25877c11dc103dd782fd0bf8835c86fbdad7434df53a976491f410d0" + }, + "linux/arm64": { + "child_manifest_digest": "sha256:a5726506034e89bbfca5e991e76f9acb49d758ab11f26885437a69eb9e79bdf0", + "image_config_digest": "sha256:28376c34fb003ad5445229905a6d243625414e3c52a1da0a29e296454bb04df2" + } + }, + "upstream_repository": "anchore/syft", + "upstream_source_commit": "f15c3343e29dfb20cf6d82c820e7090b49808c96", + "upstream_tag": "v1.55.0", + "upstream_tag_object": "18517c0bc3dbe44b7b54ed6b117da9d3b9c5a1e0", + "upstream_tag_observed_signed": false, + "version": "1.55.0" + }, + "schema": "durable-workflow.native-sbom-tools.v1" +} From 7b2490fc0873c3ef898ab723294e0375a657e74c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:43:53 +0000 Subject: [PATCH 02/22] Require explicit SPDX version semantics beyond upstream schema --- scripts/ci/native-sbom/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 5d694e3c7..61e8e73a4 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -15,7 +15,10 @@ is included verbatim as `SPDX-SCHEMA-LICENSE`; attribution is retained here. The pin map records exact source/blob, size and SHA-256. This schema validates SPDX-2.3 documents using JSON Schema 2019-09. Its references are all local; future validation must disable remote retrieval, including instance-supplied -schema URLs. Vendoring this schema does not qualify a schema-validator package. +schema URLs. The upstream `spdxVersion` property is only a string, so semantic +validation must additionally require the exact `SPDX-2.3` literal; schema success +alone is insufficient. Vendoring this schema does not qualify a schema-validator +package. The still-null validator dependency closure and generation mode are explicit unfinished source work, not permissive defaults. No workflow currently consumes From 66261aa99eb5631bac39f077072876473b4d7bf8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:23:55 +0000 Subject: [PATCH 03/22] Validate bounded SPDX document semantics and predicate identity --- scripts/ci/native-sbom/README.md | 11 ++ scripts/ci/native_sbom_policy.py | 171 ++++++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 117 ++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 scripts/ci/native_sbom_policy.py create mode 100644 tests/Unit/Support/native_sbom_policy_test.py diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 61e8e73a4..21f99d035 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -27,3 +27,14 @@ validation, exact Server child predicates, retained raw bytes/checksum map, Release asset equality/idempotency and fail-before controls remain required before a normal source/CI and Security review. No pull, build, tool/scan, attestation, registry, Release or frozen-subject operation is admitted here. + +The source-only `../native_sbom_policy.py` now performs fixed-schema validation +with remote retrieval disabled, followed by exact SPDX/tool identity, namespace, +unique element IDs and internal relationship checks. Declared files must be +linked; a package with `filesAnalyzed=true` must have file coverage. An explicit +`false` does not invent complete file enumeration. It retains raw SHA-256/size +separately from canonical predicate equality and requires distinct platform +namespaces. Eight synthetic test families cover these boundaries. Their formal +schema checks use the already-installed jsonschema4.19.2 solely as local source +check tooling. No dependency was installed or qualified, and direct execution of +the library refuses until the executable/dependency closure is completed. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py new file mode 100644 index 000000000..4cdc18d2c --- /dev/null +++ b/scripts/ci/native_sbom_policy.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Bounded SPDX schema/semantic observations; no scanner or publication authority. + +The future generator/verifier must additionally bind these bytes to the actual +smoked child, pinned tool execution, provider artifact and signed predicate. +This library neither fetches resources nor admits an operation. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from typing import Any +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parent / "native-sbom" +RAW_LIMIT = 16 * 1024 * 1024 +SCHEMA_SHA256 = "4126dc29f15e92feec92e9622f4939131ad58b125d6f578aa93e980a6e6f3212" +TOOL_CREATOR = "Tool: syft-1.55.0" +IDENTIFIER = re.compile(r"SPDXRef-[A-Za-z0-9.-]+\Z") +COLLECTION_LIMIT = 100000 + + +class Refusal(ValueError): + """Static diagnostics only; never emit untrusted package/file content.""" + + +def require(condition: bool, reason: str) -> None: + if not condition: + raise Refusal(reason) + + +def parse(raw: bytes, limit: int = RAW_LIMIT) -> Any: + require(type(raw) is bytes and 0 < len(raw) <= limit, "spdx_size") + + def pairs(items): + result = {} + for key, value in items: + require(key not in result, "spdx_duplicate_member") + result[key] = value + return result + + def noninteger(_): + raise Refusal("spdx_noninteger_number") + + try: + return json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=pairs, + parse_float=noninteger, parse_constant=noninteger) + except (UnicodeError, ValueError, RecursionError) as error: + if isinstance(error, Refusal): + raise + raise Refusal("spdx_json") from None + + +def canonical(value: Any) -> bytes: + try: + return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, + separators=(",", ":")).encode("utf-8") + except (UnicodeError, ValueError, RecursionError): + raise Refusal("spdx_canonical_json") from None + + +def schema_check(document: Any) -> None: + # The source/schema checks here use an already-installed validator. A + # future executable must first enforce the separately pinned dependency + # closure; that closure is currently null and no executable is wired. + from jsonschema import Draft201909Validator + from referencing import Registry + from referencing.exceptions import NoSuchResource + + with (ROOT / "spdx-schema-2.3.json").open("rb") as stream: + raw = stream.read(131073) + require(hashlib.sha256(raw).hexdigest() == SCHEMA_SHA256, "spdx_schema_source") + schema = parse(raw, 131072) + + def no_remote(uri): + raise NoSuchResource(ref=uri) + + validator = Draft201909Validator(schema, registry=Registry(retrieve=no_remote)) + # A fixed validator/schema ignores any instance-supplied $schema URL. + require(next(validator.iter_errors(document), None) is None, "spdx_schema_shape") + + +def collection(document: dict[str, Any], key: str, nonempty: bool = False) -> list[dict[str, Any]]: + value = document.get(key, []) + require(type(value) is list and len(value) <= COLLECTION_LIMIT + and (bool(value) or not nonempty) and all(type(item) is dict for item in value), + "spdx_collection_shape") + return value + + +def document(raw: bytes) -> dict[str, Any]: + value = parse(raw) + require(type(value) is dict, "spdx_document_shape") + schema_check(value) + # Upstream 2.3.1's schema declares only type=string for spdxVersion. + require(value.get("spdxVersion") == "SPDX-2.3" and value.get("SPDXID") == "SPDXRef-DOCUMENT" + and value.get("dataLicense") == "CC0-1.0", "spdx_document_identity") + namespace = value.get("documentNamespace") + require(type(namespace) is str and len(namespace) <= 2048 + and all(0x21 <= ord(char) <= 0x7e for char in namespace), "spdx_namespace_text") + try: + uri = urlsplit(namespace) + valid_uri = (uri.scheme == "https" and bool(uri.hostname) and not uri.username + and not uri.password and not uri.fragment and bool(uri.path)) + except ValueError: + valid_uri = False + require(valid_uri, "spdx_namespace_absolute") + creators = value.get("creationInfo", {}).get("creators") + require(type(creators) is list and creators.count(TOOL_CREATOR) == 1 + and all(type(item) is str for item in creators) + and all(item == TOOL_CREATOR for item in creators if item.startswith("Tool:")), + "spdx_creator_version") + packages = collection(value, "packages", nonempty=True) + files = collection(value, "files") + snippets = collection(value, "snippets") + relationships = collection(value, "relationships", nonempty=True) + ids, package_ids, file_ids = {"SPDXRef-DOCUMENT"}, set(), set() + for kind, members in (("packages", packages), ("files", files), ("snippets", snippets)): + for member in members: + identity = member.get("SPDXID") + require(type(identity) is str and IDENTIFIER.fullmatch(identity) is not None + and identity not in ids, "spdx_duplicate_or_invalid_id") + ids.add(identity) + if kind == "packages": + package_ids.add(identity) + elif kind == "files": + file_ids.add(identity) + edges = set() + described, linked_files, file_parents = set(), set(), set() + for edge in relationships: + source, relation, target = (edge.get(key) for key in + ("spdxElementId", "relationshipType", "relatedSpdxElement")) + require(type(source) is str and type(target) is str and type(relation) is str + and source in ids and target in ids, "spdx_relationship_target") + key = (source, relation, target) + require(key not in edges, "spdx_duplicate_relationship") + edges.add(key) + if source == "SPDXRef-DOCUMENT" and relation == "DESCRIBES" and target in package_ids: + described.add(target) + if source in package_ids and relation == "CONTAINS" and target in file_ids: + linked_files.add(target) + file_parents.add(source) + require(bool(described), "spdx_image_description_missing") + # No claim of complete file enumeration is invented when Syft explicitly + # marks a package filesAnalyzed=false. Declared files/true coverage must link. + require(linked_files == file_ids, "spdx_unlinked_file") + require(all(package.get("filesAnalyzed") is not True or package["SPDXID"] in file_parents + for package in packages), "spdx_analyzed_package_without_files") + return {"raw_bytes": len(raw), "raw_sha256": hashlib.sha256(raw).hexdigest(), + "canonical_sha256": hashlib.sha256(canonical(value)).hexdigest(), + "namespace": namespace, "package_count": len(packages), "file_count": len(files), + "relationship_count": len(relationships), "predicate": value} + + +def predicate_matches(raw: bytes, signed_predicate: bytes) -> dict[str, Any]: + observed = document(raw) + signed = document(signed_predicate) + require(observed["canonical_sha256"] == signed["canonical_sha256"], "spdx_signed_predicate_mismatch") + return observed + + +def distinct_platform_documents(amd64: bytes, arm64: bytes) -> tuple[dict[str, Any], dict[str, Any]]: + pair = document(amd64), document(arm64) + require(pair[0]["namespace"] != pair[1]["namespace"], "spdx_namespace_reused") + return pair + + +if __name__ == "__main__": + raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py new file mode 100644 index 000000000..cda447c4f --- /dev/null +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Synthetic SPDX shape/predicate controls, without a scan or provider call.""" +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location("native_sbom_policy", ROOT / "scripts/ci/native_sbom_policy.py") +P = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(P) + + +def fixture(): + return { + "spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT", "dataLicense": "CC0-1.0", + "name": "synthetic-image", "documentNamespace": "https://anchore.com/syft/image/synthetic-1", + "creationInfo": {"created": "2026-11-05T00:00:00Z", "creators": ["Organization: Anchore, Inc", "Tool: syft-1.55.0"]}, + "packages": [{"SPDXID": "SPDXRef-Package", "name": "synthetic-native", "versionInfo": "3.2.0", + "downloadLocation": "NOASSERTION", "filesAnalyzed": True, + "checksums": [{"algorithm": "SHA256", "checksumValue": "a" * 64}]}], + "files": [{"SPDXID": "SPDXRef-File", "fileName": "./server", "checksums": [{"algorithm": "SHA256", "checksumValue": "b" * 64}]}], + "relationships": [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package"}, + {"spdxElementId": "SPDXRef-Package", "relationshipType": "CONTAINS", "relatedSpdxElement": "SPDXRef-File"}], + } + + +def encoded(value): + return (json.dumps(value, indent=2) + "\n").encode() + + +class SbomPolicyTests(unittest.TestCase): + def test_shape_and_semantics_retain_raw_bytes_identity(self): + raw = encoded(fixture()) + result = P.document(raw) + self.assertEqual(result["raw_bytes"], len(raw)) + self.assertEqual(result["package_count"], 1) + self.assertEqual(result["file_count"], 1) + self.assertEqual(result["relationship_count"], 2) + compact = json.dumps(fixture(), separators=(",", ":")).encode() + other = P.predicate_matches(raw, compact) + self.assertEqual(result["canonical_sha256"], other["canonical_sha256"]) + self.assertNotEqual(result["raw_sha256"], P.document(compact)["raw_sha256"]) + + def test_malformed_duplicate_oversized_and_noninteger_json_refuse(self): + for raw in (b"{}", b'{"x":1,"x":2}', b'{"x":NaN}', b'{"x":1.0}', + b'{"x":"\\ud800"}', b"[", b"x" * (P.RAW_LIMIT + 1)): + with self.subTest(raw=raw[:50]), self.assertRaises(P.Refusal): + P.document(raw) + + def test_spdx_version_creator_namespace_and_schema_refusals(self): + changes = [lambda v: v.update(spdxVersion="SPDX-3.0"), + lambda v: v.update(documentNamespace="relative/path"), + lambda v: v.update(documentNamespace="https://user:secret@example.invalid/doc"), + lambda v: v.update(documentNamespace="https://example.invalid/doc#fragment"), + lambda v: v["creationInfo"].update(creators=["Tool: syft-1.54.0"]), + lambda v: v["creationInfo"]["creators"].append("Tool: syft-1.54.0"), + lambda v: v["creationInfo"].update(created="not-a-date"), + lambda v: v.update(packages=[]), lambda v: v.update(relationships=[])] + for change in changes: + value = fixture() + change(value) + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.document(encoded(value)) + + def test_duplicate_unknown_and_unlinked_relationships_refuse(self): + changes = [lambda v: v["packages"].append(copy.deepcopy(v["packages"][0])), + lambda v: v["relationships"][1].update(relatedSpdxElement="SPDXRef-Unknown"), + lambda v: v["relationships"][1].update(relatedSpdxElement="DocumentRef-External:SPDXRef-File"), + lambda v: v["relationships"].append(copy.deepcopy(v["relationships"][0])), + lambda v: v["relationships"].pop(), + lambda v: v["relationships"].pop(0)] + for change in changes: + value = fixture() + change(value) + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.document(encoded(value)) + + def test_file_coverage_is_explicit_and_pair_namespaces_are_unique(self): + value = fixture() + value["files"] = [] + value["relationships"].pop() + with self.assertRaises(P.Refusal): + P.document(encoded(value)) + value["packages"][0]["filesAnalyzed"] = False + self.assertEqual(P.document(encoded(value))["file_count"], 0) + with self.assertRaises(P.Refusal): + P.distinct_platform_documents(encoded(fixture()), encoded(fixture())) + arm = fixture() + arm["documentNamespace"] += "-arm64" + self.assertEqual(len(P.distinct_platform_documents(encoded(fixture()), encoded(arm))), 2) + + def test_signed_predicate_cannot_authorize_altered_package_bytes(self): + signed = fixture() + signed["packages"][0]["versionInfo"] = "different" + with self.assertRaisesRegex(P.Refusal, "spdx_signed_predicate_mismatch"): + P.predicate_matches(encoded(fixture()), encoded(signed)) + + def test_instance_schema_url_never_selects_validator_or_fetches(self): + value = fixture() + value["$schema"] = "https://untrusted.invalid/never-fetch" + with patch("urllib.request.urlopen", side_effect=AssertionError("remote retrieval")): + self.assertEqual(P.document(encoded(value))["file_count"], 1) + + def test_source_library_cannot_succeed_as_an_executable_gate(self): + result = subprocess.run([sys.executable, str(ROOT / "scripts/ci/native_sbom_policy.py")], + capture_output=True, check=False, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, b"") + + +if __name__ == "__main__": + unittest.main() From 1142340b0af618370376e68d7e6abaa040ee4a70 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:04:36 +0000 Subject: [PATCH 04/22] Record immutable offline SPDX validator input closure --- scripts/ci/native-sbom/README.md | 19 +- scripts/ci/native-sbom/tool-pins.v1.json | 6 +- .../validator-amd64.requirements.txt | 6 + .../validator-arm64.requirements.txt | 6 + scripts/ci/native-sbom/validator-pins.v1.json | 189 ++++++++++++++++++ 5 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/native-sbom/validator-amd64.requirements.txt create mode 100644 scripts/ci/native-sbom/validator-arm64.requirements.txt create mode 100644 scripts/ci/native-sbom/validator-pins.v1.json diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 21f99d035..0b731ff25 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -20,8 +20,8 @@ validation must additionally require the exact `SPDX-2.3` literal; schema succes alone is insufficient. Vendoring this schema does not qualify a schema-validator package. -The still-null validator dependency closure and generation mode are explicit -unfinished source work, not permissive defaults. No workflow currently consumes +The validator dependency closure now names the exact metadata proposal below; +generation mode and confinement remain unfinished, never permissive defaults. No workflow currently consumes these inputs. The complete generation/confinement, independent schema/semantic validation, exact Server child predicates, retained raw bytes/checksum map, Release asset equality/idempotency and fail-before controls remain required @@ -38,3 +38,18 @@ namespaces. Eight synthetic test families cover these boundaries. Their formal schema checks use the already-installed jsonschema4.19.2 solely as local source check tooling. No dependency was installed or qualified, and direct execution of the library refuses until the executable/dependency closure is completed. + +The candidate offline validator closure is `validator-pins.v1.json`: official +Python 3.13.18 slim-bookworm immutable index and selected amd64/arm64 child/config +pairs, plus JSON Schema 4.26.0, attrs26.1.0, specifications2025.9.1, +referencing0.37.0 and rpds-py2026.9.1. Each package has a concrete public wheel +URL/size/SHA-256; the rpds wheel is CPython3.13/Linux-architecture specific. +The official-images source block and selected active dependency edges are +retained. No optional format extras are selected; typing-extensions is inactive +for Python3.13. The two derived requirements files use local `/deps` paths for +an eventual network-disabled `--no-index --no-deps --require-hashes` install. +They do not authorize a download or install. Python's observed image user is +unset/root; future confinement must explicitly enforce a nonroot UID, read-only +filesystem, bounded scratch/output/resources, no capabilities, no-new-privileges, +no network, daemon socket or host credential mounts. That execution source is +still incomplete. Neither image layers nor wheels were acquired or executed. diff --git a/scripts/ci/native-sbom/tool-pins.v1.json b/scripts/ci/native-sbom/tool-pins.v1.json index 435ed396f..1c2d4ff87 100644 --- a/scripts/ci/native-sbom/tool-pins.v1.json +++ b/scripts/ci/native-sbom/tool-pins.v1.json @@ -4,7 +4,11 @@ "generator_binary_qualified": false, "metadata_source_only": true, "scan_or_publication_admitted": false, - "schema_validator_dependency_closure": null + "schema_validator_dependency_closure": { + "path": "scripts/ci/native-sbom/validator-pins.v1.json", + "sha256": "9b140b139b299d22bd92420a4ae2ff570eeb19c7977f485557b6119c407ad314", + "status": "candidate_metadata_not_executed" + } }, "attestation_action": { "commit": "1e69f48acb82d1966a394da916b4c1698aa569d6", diff --git a/scripts/ci/native-sbom/validator-amd64.requirements.txt b/scripts/ci/native-sbom/validator-amd64.requirements.txt new file mode 100644 index 000000000..1c63d0ab2 --- /dev/null +++ b/scripts/ci/native-sbom/validator-amd64.requirements.txt @@ -0,0 +1,6 @@ +# Offline target install only: --no-index --no-deps --require-hashes --only-binary=:all: +/deps/attrs-26.1.0-py3-none-any.whl --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +/deps/jsonschema-4.26.0-py3-none-any.whl --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +/deps/jsonschema_specifications-2025.9.1-py3-none-any.whl --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +/deps/referencing-0.37.0-py3-none-any.whl --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +/deps/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b diff --git a/scripts/ci/native-sbom/validator-arm64.requirements.txt b/scripts/ci/native-sbom/validator-arm64.requirements.txt new file mode 100644 index 000000000..d074d0934 --- /dev/null +++ b/scripts/ci/native-sbom/validator-arm64.requirements.txt @@ -0,0 +1,6 @@ +# Offline target install only: --no-index --no-deps --require-hashes --only-binary=:all: +/deps/attrs-26.1.0-py3-none-any.whl --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +/deps/jsonschema-4.26.0-py3-none-any.whl --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +/deps/jsonschema_specifications-2025.9.1-py3-none-any.whl --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +/deps/referencing-0.37.0-py3-none-any.whl --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +/deps/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 diff --git a/scripts/ci/native-sbom/validator-pins.v1.json b/scripts/ci/native-sbom/validator-pins.v1.json new file mode 100644 index 000000000..0852b5eb2 --- /dev/null +++ b/scripts/ci/native-sbom/validator-pins.v1.json @@ -0,0 +1,189 @@ +{ + "active_dependency_edges": [ + [ + "jsonschema", + "attrs>=22.2.0" + ], + [ + "jsonschema", + "jsonschema-specifications>=2023.03.6" + ], + [ + "jsonschema", + "referencing>=0.28.4" + ], + [ + "jsonschema", + "rpds-py>=0.25.0" + ], + [ + "jsonschema-specifications", + "referencing>=0.31.0" + ], + [ + "referencing", + "attrs>=22.2.0" + ], + [ + "referencing", + "rpds-py>=0.7.0" + ] + ], + "confinement_source_complete": false, + "extras": [], + "observation_scope": "public manifests/configs/PyPI metadata only; no layer/wheel acquisition or execution", + "package_download_and_install_admitted": false, + "packages": { + "attrs": { + "requires_dist": null, + "requires_python": ">=3.9", + "version": "26.1.0", + "wheels": { + "any": { + "filename": "attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "size": 67548, + "url": "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" + } + } + }, + "jsonschema": { + "requires_dist": [ + "attrs>=22.2.0", + "jsonschema-specifications>=2023.03.6", + "referencing>=0.28.4", + "rpds-py>=0.25.0", + "fqdn; extra == \"format\"", + "idna; extra == \"format\"", + "isoduration; extra == \"format\"", + "jsonpointer>1.13; extra == \"format\"", + "rfc3339-validator; extra == \"format\"", + "rfc3987; extra == \"format\"", + "uri-template; extra == \"format\"", + "webcolors>=1.11; extra == \"format\"", + "fqdn; extra == \"format-nongpl\"", + "idna; extra == \"format-nongpl\"", + "isoduration; extra == \"format-nongpl\"", + "jsonpointer>1.13; extra == \"format-nongpl\"", + "rfc3339-validator; extra == \"format-nongpl\"", + "rfc3986-validator>0.1.0; extra == \"format-nongpl\"", + "rfc3987-syntax>=1.1.0; extra == \"format-nongpl\"", + "uri-template; extra == \"format-nongpl\"", + "webcolors>=24.6.0; extra == \"format-nongpl\"" + ], + "requires_python": ">=3.10", + "version": "4.26.0", + "wheels": { + "any": { + "filename": "jsonschema-4.26.0-py3-none-any.whl", + "sha256": "d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", + "size": 90630, + "url": "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl" + } + } + }, + "jsonschema-specifications": { + "requires_dist": [ + "referencing>=0.31.0" + ], + "requires_python": ">=3.9", + "version": "2025.9.1", + "wheels": { + "any": { + "filename": "jsonschema_specifications-2025.9.1-py3-none-any.whl", + "sha256": "98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", + "size": 18437, + "url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl" + } + } + }, + "referencing": { + "requires_dist": [ + "attrs>=22.2.0", + "rpds-py>=0.7.0", + "typing-extensions>=4.4.0; python_version < \"3.13\"" + ], + "requires_python": ">=3.10", + "version": "0.37.0", + "wheels": { + "any": { + "filename": "referencing-0.37.0-py3-none-any.whl", + "sha256": "381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", + "size": 26766, + "url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl" + } + } + }, + "rpds-py": { + "requires_dist": null, + "requires_python": ">=3.11", + "version": "2026.9.1", + "wheels": { + "linux/amd64": { + "filename": "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", + "size": 370794, + "url": "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + }, + "linux/arm64": { + "filename": "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "sha256": "457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", + "size": 372611, + "url": "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + } + } + } + }, + "python_image": { + "index_digest": "sha256:281d39ff02e3b389401586134ff9e6572954687822a58055ec07a60ded4e0b7f", + "observed_exact_tag": "3.13.18-slim-bookworm", + "observed_selector": "3.13-slim-bookworm", + "official_images_source": { + "commit": "3877fb0d52982e781d1aab8391e580078656ce28", + "git_blob": "d88292a51909a0886b48a2f52c5ec040d7e3dd0d", + "path": "library/python", + "selected_block": "Tags: 3.13.18-slim-bookworm, 3.13-slim-bookworm\nArchitectures: amd64, arm32v7, arm64v8, i386, ppc64le\nGitCommit: 152874b138f0b1c4699fbdcb52b174608cfbfff5\nDirectory: 3.13/slim-bookworm" + }, + "platforms": { + "linux/amd64": { + "child_manifest_digest": "sha256:70bda1e795ded2a684ef9815955e05829b14ee5bb5c3eb2a940648e88f10fce9", + "cmd": [ + "python3" + ], + "configured_user": "", + "entrypoint": null, + "image_config_digest": "sha256:b7ddb46f14cdb6312b0ac3816e0513fe55904bd66e4525b8845058d3c1aa0f79", + "python_version": "3.13.18" + }, + "linux/arm64": { + "child_manifest_digest": "sha256:15c240ab61e2582e3513e1d8f6f5ae638b5c1625e5746d4b148152c977180275", + "cmd": [ + "python3" + ], + "configured_user": "", + "entrypoint": null, + "image_config_digest": "sha256:de95a1d4a30e87bf15f69bd2b88771defb87414a665d3f3e40d14d66d91a24b5", + "python_version": "3.13.18" + } + }, + "repository": "docker.io/library/python" + }, + "python_implementation": "cpython", + "python_minor": "3.13", + "qualified": false, + "requirements_files": { + "linux/amd64": { + "bytes": 743, + "path": "scripts/ci/native-sbom/validator-amd64.requirements.txt", + "sha256": "096d91c3f247bc31e72fee8c197acf751264564878059d95cbe773f92ed4c7fe" + }, + "linux/arm64": { + "bytes": 745, + "path": "scripts/ci/native-sbom/validator-arm64.requirements.txt", + "sha256": "fc35cde232eb922dffeea752a666630c85f5b38bc77c99f054129efe66a0e268" + } + }, + "schema": "durable-workflow.native-sbom-validator-inputs.v1", + "status": "candidate_metadata_not_executed", + "validator_python": "3.13.18" +} From 16654a239ba0d35ac644a49608c6e7a53a646220 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:35:54 +0000 Subject: [PATCH 05/22] Refuse altered or incomplete raw SBOM Release assets --- scripts/ci/native-sbom/README.md | 10 ++++++ scripts/ci/native_sbom_policy.py | 28 ++++++++++++++- tests/Unit/Support/native_sbom_policy_test.py | 36 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 0b731ff25..be30231a4 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -53,3 +53,13 @@ unset/root; future confinement must explicitly enforce a nonroot UID, read-only filesystem, bounded scratch/output/resources, no capabilities, no-new-privileges, no network, daemon socket or host credential mounts. That execution source is still incomplete. Neither image layers nor wheels were acquired or executed. + +The proposed deterministic raw assets are `server-linux-amd64.spdx.json`, +`server-linux-arm64.spdx.json` and `server-sbom-manifest.json`. +`exact_release_asset_bytes` rejects absent, extra, renamed, empty, oversized or +changed bytes for this set. Canonically equivalent JSON is insufficient for +existing raw assets. This helper accepts only previously verified expected +bytes; it does not authenticate a checksum map, signature, provider asset or +image tuple. The future caller must verify those independently and bound each +download before calling it. Ten synthetic test families now cover the source +library. No transport, replacement or publication is implemented by this helper. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 4cdc18d2c..31aedb192 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -20,6 +20,9 @@ TOOL_CREATOR = "Tool: syft-1.55.0" IDENTIFIER = re.compile(r"SPDXRef-[A-Za-z0-9.-]+\Z") COLLECTION_LIMIT = 100000 +SPDX_ASSETS = ("server-linux-amd64.spdx.json", "server-linux-arm64.spdx.json") +MANIFEST_ASSET = "server-sbom-manifest.json" +ASSET_LIMITS = {**dict.fromkeys(SPDX_ASSETS, RAW_LIMIT), MANIFEST_ASSET: 65536} class Refusal(ValueError): @@ -64,7 +67,7 @@ def canonical(value: Any) -> bytes: def schema_check(document: Any) -> None: # The source/schema checks here use an already-installed validator. A # future executable must first enforce the separately pinned dependency - # closure; that closure is currently null and no executable is wired. + # closure. Candidate metadata exists, but no executable is wired/qualified. from jsonschema import Draft201909Validator from referencing import Registry from referencing.exceptions import NoSuchResource @@ -167,5 +170,28 @@ def distinct_platform_documents(amd64: bytes, arm64: bytes) -> tuple[dict[str, A return pair +def exact_release_asset_bytes(expected: dict[str, bytes], observed: dict[str, bytes]) -> dict[str, Any]: + """Require retained raw bytes for an existing Release, with no replacement. + + The caller must FIRST verify the source/index/child/tool/attestation tuple, + signed predicates, and checksum-map contents. This function proves byte + equality only; it cannot authenticate a caller-supplied expected map. + Transport must bound downloads before passing bytes to this function. + """ + names = set(ASSET_LIMITS) + require(type(expected) is dict and type(observed) is dict + and set(expected) == set(observed) == names, "sbom_release_asset_names") + result = {} + for name, limit in ASSET_LIMITS.items(): + original, retained = expected[name], observed[name] + require(type(original) is bytes and type(retained) is bytes + and 0 < len(original) <= limit and 0 < len(retained) <= limit, + "sbom_release_asset_size") + # Canonical JSON equality is deliberately insufficient for raw assets. + require(original == retained, "sbom_release_asset_bytes_changed") + result[name] = {"bytes": len(original), "sha256": hashlib.sha256(original).hexdigest()} + return result + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index cda447c4f..679fc773c 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -34,6 +34,42 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def test_existing_release_requires_all_three_exact_raw_assets(self): + arm = fixture() + arm["documentNamespace"] += "-arm64" + expected = {P.SPDX_ASSETS[0]: encoded(fixture()), P.SPDX_ASSETS[1]: encoded(arm), + P.MANIFEST_ASSET: b'{"synthetic":"previously-verified-map"}\n'} + observed = dict(expected) + result = P.exact_release_asset_bytes(expected, observed) + self.assertEqual(set(result), set(expected)) + for name in expected: + self.assertEqual(result[name]["bytes"], len(expected[name])) + for name in expected: + for changed in (expected[name] + b"\n", expected[name][:-1], b"different"): + altered = {**observed, name: changed} + with self.subTest(name=name, changed=changed[:20]), self.assertRaisesRegex(P.Refusal, "bytes_changed"): + P.exact_release_asset_bytes(expected, altered) + # Predicates may be canonically equal; raw Release assets must still + # have exactly the retained generation bytes, without reformatting. + compact = json.dumps(fixture(), separators=(",", ":")).encode() + self.assertEqual(P.predicate_matches(expected[P.SPDX_ASSETS[0]], compact)["package_count"], 1) + altered = {**observed, P.SPDX_ASSETS[0]: compact} + with self.assertRaisesRegex(P.Refusal, "bytes_changed"): + P.exact_release_asset_bytes(expected, altered) + + def test_existing_release_refuses_absent_extra_renamed_or_unbounded_assets(self): + expected = dict.fromkeys(P.ASSET_LIMITS, b"synthetic-preverified-bytes") + for name in expected: + missing = {key: value for key, value in expected.items() if key != name} + renamed = {**missing, name.upper(): expected[name]} + for observed in (missing, renamed, {**expected, "unexpected.json": b"extra"}): + with self.subTest(name=name), self.assertRaisesRegex(P.Refusal, "asset_names"): + P.exact_release_asset_bytes(expected, observed) + for value in (b"", "not bytes", b"x" * (P.ASSET_LIMITS[name] + 1)): + altered = {**expected, name: value} + with self.subTest(name=name, size=len(value)), self.assertRaisesRegex(P.Refusal, "asset_size"): + P.exact_release_asset_bytes(expected, altered) + def test_shape_and_semantics_retain_raw_bytes_identity(self): raw = encoded(fixture()) result = P.document(raw) From 90a8ae0609eba53a8e2bf6ffedb4a1737fa727de Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:44:05 +0000 Subject: [PATCH 06/22] Bind SBOM verification requests to exact release children and attempts --- scripts/ci/native-sbom/README.md | 10 +++++ scripts/ci/native_sbom_policy.py | 39 ++++++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 40 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index be30231a4..2cfb65776 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -63,3 +63,13 @@ bytes; it does not authenticate a checksum map, signature, provider asset or image tuple. The future caller must verify those independently and bound each download before calling it. Ten synthetic test families now cover the source library. No transport, replacement or publication is implemented by this helper. + +The source library also constructs the fixed `gh attestation verify` request: +one selected child under each fixed registry name, exact release signer workflow, +tag ref and source commit, the SPDX 2.3 predicate type and self-hosted refusal. +The index cannot be substituted for the requested child. A separate comparison +requires the authenticated certificate invocation to match the exact release +run and attempt, with no prefix or suffix acceptance. Fourteen source test +families pass. These functions neither run `gh` nor authenticate caller-supplied +tuples/strings; future index membership, provider/signature verification and +bounded transport remain required before using their results. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 31aedb192..736dfbb44 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -23,6 +23,9 @@ SPDX_ASSETS = ("server-linux-amd64.spdx.json", "server-linux-arm64.spdx.json") MANIFEST_ASSET = "server-sbom-manifest.json" ASSET_LIMITS = {**dict.fromkeys(SPDX_ASSETS, RAW_LIMIT), MANIFEST_ASSET: 65536} +REGISTRIES = ("ghcr.io/durable-workflow/server", "durableworkflow/server") +REPOSITORY = "durable-workflow/server" +PREDICATE_TYPE = "https://spdx.dev/Document/v2.3" class Refusal(ValueError): @@ -193,5 +196,41 @@ def exact_release_asset_bytes(expected: dict[str, bytes], observed: dict[str, by return result +def sbom_verification_command(registry: str, child: str, index: str, tag: str, source: str) -> list[str]: + """Construct, never execute, the fixed signature-verification request. + + A future caller must independently prove index membership/platform and the + expected release tuple first. Successful command construction is not a + verified signature or proof that the digest is a platform child. + """ + if registry not in REGISTRIES: + raise Refusal("sbom_registry") + for digest in (child, index): + if type(digest) is not str or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None: + raise Refusal("sbom_subject_digest") + require(child != index, "sbom_index_is_not_child") + if type(tag) is not str or re.fullmatch(r"(?:native-)?(?:[3-9]|[1-9]\d+)\.\d+\.\d+(?:-rc\.\d+)?", tag, re.ASCII) is None: + raise Refusal("sbom_release_tag") + if type(source) is not str or re.fullmatch(r"[0-9a-f]{40}", source) is None: + raise Refusal("sbom_source_commit") + return ["gh", "attestation", "verify", "oci://" + registry + "@" + child, + "--repo", REPOSITORY, "--signer-workflow", "github.com/" + REPOSITORY + "/.github/workflows/native-release.yml", + "--source-ref", "refs/tags/" + tag, "--source-digest", source, + "--predicate-type", PREDICATE_TYPE, "--deny-self-hosted-runners", "--format", "json"] + + +def require_release_invocation(uri: str, run_id: int, attempt: int) -> None: + """Bind a certificate's verified invocation URI to one exact run attempt. + + The caller must extract this field from authenticated verifier output; + arbitrary strings passed here do not become authenticated observations. + """ + require(type(run_id) is int and 0 < run_id <= 9007199254740991 + and type(attempt) is int and 0 < attempt <= 9007199254740991, + "sbom_release_run") + expected = f"https://github.com/{REPOSITORY}/actions/runs/{run_id}/attempts/{attempt}" + require(type(uri) is str and uri == expected, "sbom_release_invocation") + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 679fc773c..bba96b4d1 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -34,6 +34,46 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def test_build_provenance_cannot_substitute_for_an_spdx_predicate(self): + provenance = encoded({"buildDefinition": {"buildType": "synthetic-provenance"}, "runDetails": {}}) + raw = encoded(fixture()) + with self.assertRaisesRegex(P.Refusal, "spdx_schema_shape"): + P.predicate_matches(raw, provenance) + + def test_verification_request_uses_spdx_child_and_fixed_release_identity(self): + child, index, source = "sha256:" + "a" * 64, "sha256:" + "b" * 64, "c" * 40 + for registry in P.REGISTRIES: + command = P.sbom_verification_command(registry, child, index, "3.2.0-rc.1", source) + self.assertEqual(command[:4], ["gh", "attestation", "verify", "oci://" + registry + "@" + child]) + self.assertEqual(command[4:], ["--repo", P.REPOSITORY, "--signer-workflow", + "github.com/durable-workflow/server/.github/workflows/native-release.yml", + "--source-ref", "refs/tags/3.2.0-rc.1", "--source-digest", source, + "--predicate-type", "https://spdx.dev/Document/v2.3", "--deny-self-hosted-runners", "--format", "json"]) + self.assertNotIn(index, command) + + def test_verification_request_refuses_redirects_indexes_and_cli_injection(self): + original = [P.REGISTRIES[0], "sha256:" + "a" * 64, "sha256:" + "b" * 64, "3.2.0", "c" * 40] + for position, replacement in ((0, "foreign.invalid/server"), (0, "--repo=foreign"), + (1, original[2]), (1, "--format=env"), (2, "not-a-digest"), + (3, "refs/heads/main"), (3, "3.2.0\n--repo=foreign"), + (3, "3.2.\u0661"), + (4, "c" * 39), (4, "--source-digest=foreign")): + args = list(original) + args[position] = replacement + with self.subTest(position=position, replacement=replacement), self.assertRaises(P.Refusal): + P.sbom_verification_command(*args) + + def test_verified_invocation_requires_exact_repo_run_and_attempt_without_prefixes(self): + uri = "https://github.com/durable-workflow/server/actions/runs/123/attempts/2" + P.require_release_invocation(uri, 123, 2) + for changed in (uri.replace("123", "1234"), uri.replace("attempts/2", "attempts/3"), + uri.replace("durable-workflow/server", "foreign/server"), uri + "/", uri + "?suffix=1"): + with self.subTest(changed=changed), self.assertRaisesRegex(P.Refusal, "invocation"): + P.require_release_invocation(changed, 123, 2) + for run, attempt in ((True, 2), (123, False), (0, 2), (123, 0), (2 ** 53, 2)): + with self.subTest(run=run, attempt=attempt), self.assertRaisesRegex(P.Refusal, "release_run"): + P.require_release_invocation(uri, run, attempt) + def test_existing_release_requires_all_three_exact_raw_assets(self): arm = fixture() arm["documentNamespace"] += "-arm64" From 1610e8f55bd544737d7cd0ee5f28a331bac138ae Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:01:23 +0000 Subject: [PATCH 07/22] Bind SBOM subjects to exact two-platform index bytes --- scripts/ci/native-sbom/README.md | 8 +++ scripts/ci/native_sbom_policy.py | 52 +++++++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 50 ++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 2cfb65776..86216ebc9 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -73,3 +73,11 @@ run and attempt, with no prefix or suffix acceptance. Fourteen source test families pass. These functions neither run `gh` nor authenticate caller-supplied tuples/strings; future index membership, provider/signature verification and bounded transport remain required before using their results. + +The exact-index byte guard verifies the expected raw digest and binds exactly +one Linux child for each selected architecture, independently of array order. +Missing/extra/duplicate/unknown platforms, changed bytes, wrong children, +index-as-child and nested-index descriptors refuse. This does not authenticate +the expected publication tuple or a child's fetched config/source; those remain +future transport checks. Seventeen synthetic source test families cover the +library, which remains unwired and refuses direct execution. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 736dfbb44..231de4d8b 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -26,6 +26,11 @@ REGISTRIES = ("ghcr.io/durable-workflow/server", "durableworkflow/server") REPOSITORY = "durable-workflow/server" PREDICATE_TYPE = "https://spdx.dev/Document/v2.3" +PLATFORMS = ("linux/amd64", "linux/arm64") +INDEX_MEDIA_TYPES = ("application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json") +MANIFEST_MEDIA_TYPES = ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") class Refusal(ValueError): @@ -173,6 +178,53 @@ def distinct_platform_documents(amd64: bytes, arm64: bytes) -> tuple[dict[str, A return pair +def is_digest(value: Any) -> bool: + return type(value) is str and re.fullmatch(r"sha256:[0-9a-f]{64}", value) is not None + + +def descriptor_platform(member: Any) -> str: + require(type(member) is dict + and set(member) <= {"mediaType", "digest", "size", "platform", "annotations"} + and member.get("mediaType") in MANIFEST_MEDIA_TYPES and is_digest(member.get("digest")) + and type(member.get("size")) is int and 0 < member["size"] <= 1048576, + "sbom_child_descriptor") + platform = member.get("platform") + require(type(platform) is dict and set(platform) <= {"os", "architecture", "variant"} + and platform.get("os") == "linux" and platform.get("architecture") in ("amd64", "arm64"), + "sbom_child_platform") + require("variant" not in platform + or (platform["architecture"] == "arm64" and platform["variant"] == "v8"), + "sbom_child_variant") + return "linux/" + platform["architecture"] + + +def index_children(raw: bytes, index_digest: str, expected: dict[str, str]) -> dict[str, str]: + """Bind one content-addressed index to the independently expected children. + + This checks bytes and descriptors, not publication/signature authority or + the fetched children's actual config/platform/source. The caller must + independently obtain the expected release tuple and verify those next. + """ + require(is_digest(index_digest) and type(expected) is dict and set(expected) == set(PLATFORMS) + and all(is_digest(value) and value != index_digest for value in expected.values()) + and len(set(expected.values())) == 2, "sbom_expected_children") + require(type(raw) is bytes and 0 < len(raw) <= 1048576 + and "sha256:" + hashlib.sha256(raw).hexdigest() == index_digest, "sbom_index_bytes") + value = parse(raw, 1048576) + require(type(value) is dict and set(value) <= {"schemaVersion", "mediaType", "manifests", "annotations"} + and type(value.get("schemaVersion")) is int and value["schemaVersion"] == 2 + and value.get("mediaType") in INDEX_MEDIA_TYPES + and type(value.get("manifests")) is list and len(value["manifests"]) == 2, + "sbom_index_shape") + found = {} + for member in value["manifests"]: + platform = descriptor_platform(member) + require(platform not in found and member["digest"] == expected[platform], "sbom_index_child_binding") + found[platform] = member["digest"] + require(set(found) == set(PLATFORMS), "sbom_index_platform_set") + return found + + def exact_release_asset_bytes(expected: dict[str, bytes], observed: dict[str, bytes]) -> dict[str, Any]: """Require retained raw bytes for an existing Release, with no replacement. diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index bba96b4d1..6b6b2c6ec 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Synthetic SPDX shape/predicate controls, without a scan or provider call.""" import copy +import hashlib import importlib.util import json from pathlib import Path @@ -34,6 +35,55 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def index_fixture(self): + expected = {"linux/amd64": "sha256:" + "a" * 64, "linux/arm64": "sha256:" + "b" * 64} + members = [{"mediaType": P.MANIFEST_MEDIA_TYPES[0], "digest": digest, "size": 123, + "platform": {"os": "linux", "architecture": platform.split("/")[1]}} + for platform, digest in expected.items()] + return {"schemaVersion": 2, "mediaType": P.INDEX_MEDIA_TYPES[0], "manifests": members}, expected + + def test_index_children_match_exact_platforms_independently_of_order(self): + value, expected = self.index_fixture() + value["manifests"][1]["platform"]["variant"] = "v8" + for media in P.INDEX_MEDIA_TYPES: + value["mediaType"] = media + value["manifests"].reverse() + raw = encoded(value) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + self.assertEqual(P.index_children(raw, digest, expected), expected) + altered = raw + b"\n" + with self.assertRaisesRegex(P.Refusal, "index_bytes"): + P.index_children(altered, digest, expected) + + def test_index_refuses_extra_missing_duplicate_unknown_and_nested_members(self): + changes = [lambda v: v["manifests"].pop(), + lambda v: v["manifests"].append(copy.deepcopy(v["manifests"][0])), + lambda v: v["manifests"].__setitem__(1, copy.deepcopy(v["manifests"][0])), + lambda v: v["manifests"][0]["platform"].update(os="unknown", architecture="unknown"), + lambda v: v["manifests"][0]["platform"].update(variant="v3"), + lambda v: v["manifests"][0].update(digest="sha256:" + "c" * 64), + lambda v: v["manifests"][0].update(mediaType=P.INDEX_MEDIA_TYPES[0]), + lambda v: v["manifests"][0].update(size=True), + lambda v: v.update(schemaVersion=True)] + for change in changes: + value, expected = self.index_fixture() + change(value) + raw = encoded(value) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.index_children(raw, digest, expected) + + def test_index_refuses_an_incomplete_or_self_referential_expected_tuple(self): + value, original = self.index_fixture() + raw = encoded(value) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + for expected in ({}, {"linux/amd64": original["linux/amd64"]}, + {**original, "linux/unknown": "sha256:" + "c" * 64}, + {**original, "linux/amd64": digest}, + {**original, "linux/amd64": original["linux/arm64"]}): + with self.subTest(expected=expected), self.assertRaisesRegex(P.Refusal, "expected_children"): + P.index_children(raw, digest, expected) + def test_build_provenance_cannot_substitute_for_an_spdx_predicate(self): provenance = encoded({"buildDefinition": {"buildType": "synthetic-provenance"}, "runDetails": {}}) raw = encoded(fixture()) From 6f6e2640d9418b5df764777becf7d4389dcfd5bd Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:29 +0000 Subject: [PATCH 08/22] Bind SBOM child and config bytes to release identity --- scripts/ci/native-sbom/README.md | 14 ++++ scripts/ci/native_sbom_policy.py | 43 ++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 68 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 86216ebc9..f6167a8fa 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -81,3 +81,17 @@ index-as-child and nested-index descriptors refuse. This does not authenticate the expected publication tuple or a child's fetched config/source; those remain future transport checks. Seventeen synthetic source test families cover the library, which remains unwired and refuses direct execution. + +`child_identity` now separately checks content-addressed manifest and config +bytes (each at most 1 MiB), the manifest's exact config digest/size, actual +Linux architecture/variant and the expected source/release labels. It accepts +the image/config pairs defined by the +[OCI image manifest specification](https://github.com/opencontainers/image-spec/blob/v1.1.1/manifest.md) +and [Docker schema 2 specification](https://distribution.github.io/distribution/spec/manifest-v2-2/). +An index, changed raw bytes, mismatched config descriptor or correctly hashed +config for another platform/source/tag refuses. Twenty synthetic source test +families pass. The expected tuple must already come from independently verified +publication/index and smoke evidence. This guard does not authenticate those +inputs, fetch an object, validate layer/archive contents, run smoke or scan, +or authorize an execution. Generation mode remains null and direct execution +still refuses. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 231de4d8b..97461d37d 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -31,6 +31,8 @@ "application/vnd.docker.distribution.manifest.list.v2+json") MANIFEST_MEDIA_TYPES = ("application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json") +CONFIG_MEDIA_TYPES = ("application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json") class Refusal(ValueError): @@ -225,6 +227,47 @@ def index_children(raw: bytes, index_digest: str, expected: dict[str, str]) -> d return found +def child_identity(manifest_raw: bytes, child_digest: str, config_raw: bytes, + config_digest: str, platform: str, source: str, tag: str) -> dict[str, str]: + """Bind content-addressed child/config bytes to the expected release tuple. + + Expected digests must come from independently verified publication/index + and smoke evidence. This checks bytes/identity only, not their provider, + signatures, smoke success, layers, archive contents or scanner execution. + """ + require(is_digest(child_digest) and is_digest(config_digest) and child_digest != config_digest + and platform in PLATFORMS, "sbom_child_expected_tuple") + require(type(source) is str and re.fullmatch(r"[0-9a-f]{40}", source) is not None + and type(tag) is str and re.fullmatch( + r"(?:native-)?(?:[3-9]|[1-9]\d+)\.\d+\.\d+(?:-rc\.\d+)?", tag, re.ASCII) is not None, + "sbom_child_release_identity") + for raw, digest in ((manifest_raw, child_digest), (config_raw, config_digest)): + require(type(raw) is bytes and 0 < len(raw) <= 1048576 + and "sha256:" + hashlib.sha256(raw).hexdigest() == digest, "sbom_child_content_bytes") + manifest = parse(manifest_raw, 1048576) + require(type(manifest) is dict and type(manifest.get("schemaVersion")) is int + and manifest["schemaVersion"] == 2 and manifest.get("mediaType") in MANIFEST_MEDIA_TYPES + and type(manifest.get("config")) is dict and type(manifest.get("layers")) is list, + "sbom_child_manifest_shape") + descriptor = manifest["config"] + media_index = MANIFEST_MEDIA_TYPES.index(manifest["mediaType"]) + require(descriptor.get("mediaType") == CONFIG_MEDIA_TYPES[media_index] + and descriptor.get("digest") == config_digest + and type(descriptor.get("size")) is int and descriptor["size"] == len(config_raw), + "sbom_child_config_descriptor") + config = parse(config_raw, 1048576) + architecture = platform.split("/")[1] + require(type(config) is dict and config.get("os") == "linux" + and config.get("architecture") == architecture + and ("variant" not in config or (architecture == "arm64" and config["variant"] == "v8")) + and type(config.get("config")) is dict, "sbom_child_config_platform") + labels = config["config"].get("Labels") + require(type(labels) is dict and labels.get("org.opencontainers.image.revision") == source + and labels.get("dev.durable-workflow.release.tag") == tag, "sbom_child_config_source") + return {"child_manifest_digest": child_digest, "image_config_digest": config_digest, + "platform": platform, "source_commit": source, "tag": tag} + + def exact_release_asset_bytes(expected: dict[str, bytes], observed: dict[str, bytes]) -> dict[str, Any]: """Require retained raw bytes for an existing Release, with no replacement. diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 6b6b2c6ec..8b667c61b 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -35,6 +35,74 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def child_fixture(self, architecture="amd64", media_index=0): + source, tag = "c" * 40, "3.2.0-rc.1" + config = {"os": "linux", "architecture": architecture, "config": {"Labels": { + "org.opencontainers.image.revision": source, "dev.durable-workflow.release.tag": tag}}} + raw = encoded(config) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + manifest = {"schemaVersion": 2, "mediaType": P.MANIFEST_MEDIA_TYPES[media_index], + "config": {"mediaType": P.CONFIG_MEDIA_TYPES[media_index], "digest": digest, "size": len(raw)}, + "layers": []} + return manifest, config, source, tag + + def bind_child(self, manifest, config, platform, source, tag): + config_raw = encoded(config) + config_digest = "sha256:" + hashlib.sha256(config_raw).hexdigest() + manifest = copy.deepcopy(manifest) + manifest["config"].update(digest=config_digest, size=len(config_raw)) + manifest_raw = encoded(manifest) + child_digest = "sha256:" + hashlib.sha256(manifest_raw).hexdigest() + return P.child_identity(manifest_raw, child_digest, config_raw, config_digest, platform, source, tag) + + def test_child_bytes_bind_actual_config_platform_and_release_labels(self): + for architecture in ("amd64", "arm64"): + for media_index in (0, 1): + manifest, config, source, tag = self.child_fixture(architecture, media_index) + if architecture == "arm64": + config["variant"] = "v8" + result = self.bind_child(manifest, config, "linux/" + architecture, source, tag) + self.assertEqual(result["platform"], "linux/" + architecture) + self.assertEqual(result["source_commit"], source) + self.assertEqual(result["tag"], tag) + self.assertNotEqual(result["child_manifest_digest"], result["image_config_digest"]) + + def test_hash_valid_foreign_platform_source_and_tag_configs_refuse(self): + changes = [lambda v: v.update(os="windows"), lambda v: v.update(architecture="arm64"), + lambda v: v.update(variant="v3"), + lambda v: v["config"]["Labels"].update({"org.opencontainers.image.revision": "d" * 40}), + lambda v: v["config"]["Labels"].update({"dev.durable-workflow.release.tag": "3.2.1"}), + lambda v: v["config"].update(Labels={})] + for change in changes: + manifest, config, source, tag = self.child_fixture() + change(config) + # Recompute both content addresses: wrong identities must refuse + # even when the registry bytes are correctly named by their hashes. + with self.subTest(change=change), self.assertRaises(P.Refusal): + self.bind_child(manifest, config, "linux/amd64", source, tag) + + def test_child_bytes_and_config_descriptor_cannot_be_substituted(self): + manifest, config, source, tag = self.child_fixture() + config_raw = encoded(config) + config_digest = "sha256:" + hashlib.sha256(config_raw).hexdigest() + manifest_raw = encoded(manifest) + digest = "sha256:" + hashlib.sha256(manifest_raw).hexdigest() + for child_raw, image_raw in ((manifest_raw + b"\n", config_raw), (manifest_raw, config_raw + b"\n")): + with self.assertRaisesRegex(P.Refusal, "content_bytes"): + P.child_identity(child_raw, digest, image_raw, config_digest, "linux/amd64", source, tag) + for change in (lambda v: v.update(mediaType=P.INDEX_MEDIA_TYPES[0]), + lambda v: v.update(schemaVersion=True), + lambda v: v["config"].update(mediaType=P.CONFIG_MEDIA_TYPES[1]), + lambda v: v["config"].update(size=True), + lambda v: v["config"].update(size=len(config_raw) + 1), + lambda v: v["config"].update(digest="sha256:" + "e" * 64)): + changed = copy.deepcopy(manifest) + change(changed) + raw = encoded(changed) + child = "sha256:" + hashlib.sha256(raw).hexdigest() + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.child_identity(raw, child, config_raw, config_digest, "linux/amd64", source, tag) + def index_fixture(self): expected = {"linux/amd64": "sha256:" + "a" * 64, "linux/arm64": "sha256:" + "b" * 64} members = [{"mediaType": P.MANIFEST_MEDIA_TYPES[0], "digest": digest, "size": 123, From 06afd758496fc920f8f8c7103e8dcf796f0ab48a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:25:06 +0000 Subject: [PATCH 09/22] Require SBOM attestation verification from each OCI registry --- scripts/ci/native-sbom/README.md | 10 ++++++++++ scripts/ci/native_sbom_policy.py | 3 ++- tests/Unit/Support/native_sbom_policy_test.py | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index f6167a8fa..f3b51fd33 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -95,3 +95,13 @@ publication/index and smoke evidence. This guard does not authenticate those inputs, fetch an object, validate layer/archive contents, run smoke or scan, or authorize an execution. Generation mode remains null and direct execution still refuses. + +The SBOM verification request explicitly uses `--bundle-from-oci` for each +fixed registry/child. The [GitHub CLI verifier contract](https://cli.github.com/manual/gh_attestation_verify) +states that the default retrieves bundles from GitHub, even for an OCI image, +so the default cannot prove the SBOM attachment exists in each registry. +Registry-bundle mode checks certificate names as recorded at signing time. +The future executable must additionally bind the fixed repository ID/current +publication identity and authenticated exact release invocation, rather than +infer current repository ownership from recorded names alone. This source +correction runs no verifier and does not complete those transport gates. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 97461d37d..dbafc7d11 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -311,7 +311,8 @@ def sbom_verification_command(registry: str, child: str, index: str, tag: str, s return ["gh", "attestation", "verify", "oci://" + registry + "@" + child, "--repo", REPOSITORY, "--signer-workflow", "github.com/" + REPOSITORY + "/.github/workflows/native-release.yml", "--source-ref", "refs/tags/" + tag, "--source-digest", source, - "--predicate-type", PREDICATE_TYPE, "--deny-self-hosted-runners", "--format", "json"] + "--predicate-type", PREDICATE_TYPE, "--deny-self-hosted-runners", "--format", "json", + "--bundle-from-oci"] def require_release_invocation(uri: str, run_id: int, attempt: int) -> None: diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 8b667c61b..9a52fb593 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -166,7 +166,8 @@ def test_verification_request_uses_spdx_child_and_fixed_release_identity(self): self.assertEqual(command[4:], ["--repo", P.REPOSITORY, "--signer-workflow", "github.com/durable-workflow/server/.github/workflows/native-release.yml", "--source-ref", "refs/tags/3.2.0-rc.1", "--source-digest", source, - "--predicate-type", "https://spdx.dev/Document/v2.3", "--deny-self-hosted-runners", "--format", "json"]) + "--predicate-type", "https://spdx.dev/Document/v2.3", "--deny-self-hosted-runners", "--format", "json", + "--bundle-from-oci"]) self.assertNotIn(index, command) def test_verification_request_refuses_redirects_indexes_and_cli_injection(self): From 83af727cc836bfbde5ba766dabfe1706ecf70095 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:31:33 +0000 Subject: [PATCH 10/22] Bind verified SBOM certificates to immutable repository identity --- scripts/ci/native-sbom/README.md | 14 ++++++++ scripts/ci/native_sbom_policy.py | 35 +++++++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 35 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index f3b51fd33..0abc1ca67 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -105,3 +105,17 @@ The future executable must additionally bind the fixed repository ID/current publication identity and authenticated exact release invocation, rather than infer current repository ownership from recorded names alone. This source correction runs no verifier and does not complete those transport gates. + +`require_certificate_identity` checks claims already extracted from successful +cryptographic verifier output. It requires the fixed repository/owner numeric +IDs as strings, exact GitHub issuer and repository URIs, source/tag, +native-release signer URI/digest, hosted runner, dispatch event and exact +run/attempt URI. The field names follow the +[sigstore-go 1.3.0 certificate extensions](https://github.com/sigstore/sigstore-go/blob/v1.3.0/pkg/fulcio/certificate/extensions.go) +used by [GitHub CLI 2.103.3](https://github.com/cli/cli/blob/v2.103.3/go.mod). +Read-only repository metadata independently matches the fixed IDs. Twenty-two +synthetic source families pass, including same-name/different-ID and every +missing/wrong-type/changed claim. Passing a dictionary does not authenticate it. +The future executable still must verify the actual CLI/tool closure, current +provider identity, selected statement/subject/predicate, and both registry +origins. No attestation or signature was fetched or verified by these tests. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index dbafc7d11..234ea0f8a 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -25,6 +25,8 @@ ASSET_LIMITS = {**dict.fromkeys(SPDX_ASSETS, RAW_LIMIT), MANIFEST_ASSET: 65536} REGISTRIES = ("ghcr.io/durable-workflow/server", "durableworkflow/server") REPOSITORY = "durable-workflow/server" +REPOSITORY_ID = "1208272134" +OWNER_ID = "100327932" PREDICATE_TYPE = "https://spdx.dev/Document/v2.3" PLATFORMS = ("linux/amd64", "linux/arm64") INDEX_MEDIA_TYPES = ("application/vnd.oci.image.index.v1+json", @@ -328,5 +330,38 @@ def require_release_invocation(uri: str, run_id: int, attempt: int) -> None: require(type(uri) is str and uri == expected, "sbom_release_invocation") +def require_certificate_identity(certificate: dict[str, Any], source: str, tag: str, + run_id: int, attempt: int) -> None: + """Check claims extracted from successful cryptographic verifier output. + + Arbitrary dictionaries are not certificates or authenticated evidence. + Current repository/provider identity and the verified statement/predicate + must also be bound separately by the future executable. + """ + require(type(certificate) is dict and type(source) is str + and re.fullmatch(r"[0-9a-f]{40}", source) is not None + and type(tag) is str and re.fullmatch( + r"(?:native-)?(?:[3-9]|[1-9]\d+)\.\d+\.\d+(?:-rc\.\d+)?", tag, re.ASCII) is not None, + "sbom_certificate_inputs") + repository_uri = "https://github.com/" + REPOSITORY + workflow_uri = repository_uri + "/.github/workflows/native-release.yml@refs/tags/" + tag + expected = { + "issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": repository_uri, + "sourceRepositoryIdentifier": REPOSITORY_ID, + "sourceRepositoryOwnerURI": "https://github.com/durable-workflow", + "sourceRepositoryOwnerIdentifier": OWNER_ID, + "sourceRepositoryDigest": source, + "sourceRepositoryRef": "refs/tags/" + tag, + "buildSignerURI": workflow_uri, + "buildSignerDigest": source, + "runnerEnvironment": "github-hosted", + "buildTrigger": "workflow_dispatch", + } + require(all(type(certificate.get(key)) is str and certificate[key] == value + for key, value in expected.items()), "sbom_certificate_identity") + require_release_invocation(certificate.get("runInvocationURI"), run_id, attempt) + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 9a52fb593..db0694038 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -35,6 +35,41 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def certificate_fixture(self): + source, tag = "c" * 40, "3.2.0-rc.1" + return {"issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": "https://github.com/durable-workflow/server", + "sourceRepositoryIdentifier": "1208272134", + "sourceRepositoryOwnerURI": "https://github.com/durable-workflow", + "sourceRepositoryOwnerIdentifier": "100327932", + "sourceRepositoryDigest": source, "sourceRepositoryRef": "refs/tags/" + tag, + "buildSignerURI": "https://github.com/durable-workflow/server/.github/workflows/native-release.yml@refs/tags/" + tag, + "buildSignerDigest": source, "runnerEnvironment": "github-hosted", "buildTrigger": "workflow_dispatch", + "runInvocationURI": "https://github.com/durable-workflow/server/actions/runs/123/attempts/2"}, source, tag + + def test_authenticated_certificate_claims_bind_immutable_ids_and_exact_release(self): + certificate, source, tag = self.certificate_fixture() + P.require_certificate_identity(certificate, source, tag, 123, 2) + for key in certificate: + for value in (None, True, 123, certificate[key] + "/foreign"): + changed = {**certificate, key: value} + with self.subTest(key=key, value=value), self.assertRaises(P.Refusal): + P.require_certificate_identity(changed, source, tag, 123, 2) + missing = {k: v for k, v in certificate.items() if k != key} + with self.subTest(missing=key), self.assertRaises(P.Refusal): + P.require_certificate_identity(missing, source, tag, 123, 2) + + def test_certificate_repo_names_do_not_substitute_for_ids_or_attempt(self): + certificate, source, tag = self.certificate_fixture() + for changed in ({**certificate, "sourceRepositoryIdentifier": "1208272135"}, + {**certificate, "sourceRepositoryOwnerIdentifier": "100327933"}, + {**certificate, "sourceRepositoryDigest": "d" * 40}, + {**certificate, "runnerEnvironment": "self-hosted"}): + with self.assertRaisesRegex(P.Refusal, "certificate_identity"): + P.require_certificate_identity(changed, source, tag, 123, 2) + with self.assertRaisesRegex(P.Refusal, "release_invocation"): + P.require_certificate_identity(certificate, source, tag, 123, 3) + def child_fixture(self, architecture="amd64", media_index=0): source, tag = "c" * 40, "3.2.0-rc.1" config = {"os": "linux", "architecture": architecture, "config": {"Labels": { From daddccf94a9f2ad2f66d047409544684f27949a3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:43:25 +0000 Subject: [PATCH 11/22] Match verified SBOM statements to retained child predicates --- scripts/ci/native-sbom/README.md | 12 +++++++ scripts/ci/native_sbom_policy.py | 26 +++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 33 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 0abc1ca67..5a5e474b8 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -119,3 +119,15 @@ missing/wrong-type/changed claim. Passing a dictionary does not authenticate it. The future executable still must verify the actual CLI/tool closure, current provider identity, selected statement/subject/predicate, and both registry origins. No attestation or signature was fetched or verified by these tests. + +`verified_statement_matches` composes these checks for one already-authenticated +certificate/statement pair. The statement must be in-toto v1 with the exact +SPDX predicate type and exactly one fixed-registry subject whose SHA-256 is the +selected Server child. Its predicate must match the retained, independently +validated SPDX document canonically; raw asset identity remains separate. +Twenty-four synthetic source families now cover this composition, including +index/provenance/foreign-registry substitutions and altered signed predicates. +The future transport still must authenticate and select the actual bounded +current-run pair, bind publication/index/config and generator custody, reject +ambiguous current predicates, and verify both registry origins. A caller-supplied +dictionary or a passing synthetic control supplies none of those authorities. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 234ea0f8a..89ffd02ea 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -363,5 +363,31 @@ def require_certificate_identity(certificate: dict[str, Any], source: str, tag: require_release_invocation(certificate.get("runInvocationURI"), run_id, attempt) +def verified_statement_matches(raw_document: bytes, statement: dict[str, Any], + certificate: dict[str, Any], registry: str, child: str, + index: str, source: str, tag: str, run_id: int, + attempt: int) -> dict[str, Any]: + """Compare one already-authenticated statement with the retained raw SPDX. + + Only the future cryptographic-verifier transport may supply authenticated + statement/certificate pairs. This function does not verify a signature, + authenticate arbitrary dictionaries, select among bundles or fetch data. + """ + # Reuse the closed subject/release request validation without executing it. + sbom_verification_command(registry, child, index, tag, source) + require_certificate_identity(certificate, source, tag, run_id, attempt) + require(type(statement) is dict and statement.get("_type") == "https://in-toto.io/Statement/v1" + and statement.get("predicateType") == PREDICATE_TYPE + and type(statement.get("subject")) is list and len(statement["subject"]) == 1, + "sbom_verified_statement_shape") + subject = statement["subject"][0] + require(type(subject) is dict and set(subject) == {"name", "digest"} + and subject["name"] == registry and type(subject["digest"]) is dict + and subject["digest"] == {"sha256": child.removeprefix("sha256:")}, + "sbom_verified_statement_subject") + require(type(statement.get("predicate")) is dict, "sbom_verified_predicate_shape") + return predicate_matches(raw_document, canonical(statement["predicate"])) + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index db0694038..e1629a907 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -70,6 +70,39 @@ def test_certificate_repo_names_do_not_substitute_for_ids_or_attempt(self): with self.assertRaisesRegex(P.Refusal, "release_invocation"): P.require_certificate_identity(certificate, source, tag, 123, 3) + def statement_fixture(self, registry): + child, index = "sha256:" + "a" * 64, "sha256:" + "b" * 64 + certificate, source, tag = self.certificate_fixture() + statement = {"_type": "https://in-toto.io/Statement/v1", "predicateType": P.PREDICATE_TYPE, + "subject": [{"name": registry, "digest": {"sha256": "a" * 64}}], "predicate": fixture()} + return statement, certificate, child, index, source, tag + + def test_verified_statement_binds_child_registry_and_retained_predicate(self): + for registry in P.REGISTRIES: + statement, certificate, child, index, source, tag = self.statement_fixture(registry) + raw = encoded(fixture()) + observed = P.verified_statement_matches(raw, statement, certificate, registry, child, index, source, tag, 123, 2) + self.assertEqual(observed["raw_sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(observed["raw_bytes"], len(raw)) + + def test_provenance_indexes_foreign_subjects_and_changed_signed_predicates_refuse(self): + registry = P.REGISTRIES[0] + statement, certificate, child, index, source, tag = self.statement_fixture(registry) + changes = [lambda v: v.update(_type="https://in-toto.io/Statement/v0.1"), + lambda v: v.update(predicateType="https://slsa.dev/provenance/v1"), + lambda v: v.update(subject=[]), + lambda v: v["subject"].append(copy.deepcopy(v["subject"][0])), + lambda v: v["subject"][0].update(name=P.REGISTRIES[1]), + lambda v: v["subject"][0].update(digest={"sha256": index.removeprefix("sha256:")}), + lambda v: v["subject"][0].update(digest={"sha256": "a" * 64, "sha512": "c" * 128}), + lambda v: v["predicate"].update(documentNamespace="https://example.invalid/foreign"), + lambda v: v["predicate"].update(name="different-image")] + for change in changes: + changed = copy.deepcopy(statement) + change(changed) + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.verified_statement_matches(encoded(fixture()), changed, certificate, registry, child, index, source, tag, 123, 2) + def child_fixture(self, architecture="amd64", media_index=0): source, tag = "c" * 40, "3.2.0-rc.1" config = {"os": "linux", "architecture": architecture, "config": {"Labels": { From ad70d1544a63b38b8365031dc6fd205a4b2e0098 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:48:51 +0000 Subject: [PATCH 12/22] Compile exact SBOM release asset checksum manifests --- scripts/ci/native-sbom/README.md | 12 +++++ scripts/ci/native_sbom_policy.py | 44 ++++++++++++++++++ tests/Unit/Support/native_sbom_policy_test.py | 45 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 5a5e474b8..59c3e7db1 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -131,3 +131,15 @@ The future transport still must authenticate and select the actual bounded current-run pair, bind publication/index/config and generator custody, reject ambiguous current predicates, and verify both registry origins. A caller-supplied dictionary or a passing synthetic control supplies none of those authorities. + +`release_asset_manifest` compiles the proposed unsigned checksum-map bytes from +independently verified release, two-platform child/config tuples and the two +retained raw documents. It binds the fixed repository/registries, tag/source, +index/run/attempt, platform-specific asset names, raw SHA-256/size, canonical +predicate hashes and distinct namespaces. Missing/ambiguous platforms and +interchanged child/config identities refuse. Input map order does not alter the +output; reformatting raw SPDX does. Twenty-six synthetic families cover the +source library. This map is not a signature or qualification receipt, and the +future caller must establish generation and signed-subject custody first. +Existing Release assets must retain their original bytes, not be reconstructed +through this compiler. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 89ffd02ea..26e9d3c05 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -389,5 +389,49 @@ def verified_statement_matches(raw_document: bytes, statement: dict[str, Any], return predicate_matches(raw_document, canonical(statement["predicate"])) +def release_asset_manifest(release: dict[str, Any], subjects: dict[str, dict[str, str]], + raw_documents: dict[str, bytes]) -> bytes: + """Compile raw-asset identities from independently verified inputs. + + This unsigned checksum map is not qualification/signature authority. Its + caller must first authenticate publication, both registry subject tuples, + generation custody and signed predicates. Existing Release bytes must + still pass exact_release_asset_bytes rather than being reconstructed. + """ + require(type(release) is dict and set(release) == { + "tag", "source_commit", "index_digest", "run_id", "run_attempt"}, "sbom_manifest_release") + require(type(release["run_id"]) is int and 0 < release["run_id"] <= 9007199254740991 + and type(release["run_attempt"]) is int and 0 < release["run_attempt"] <= 9007199254740991, + "sbom_manifest_release_run") + require_release_invocation( + f"https://github.com/{REPOSITORY}/actions/runs/{release['run_id']}/attempts/{release['run_attempt']}", + release["run_id"], release["run_attempt"]) + require(type(subjects) is dict and set(subjects) == set(PLATFORMS) + and type(raw_documents) is dict and set(raw_documents) == set(PLATFORMS), "sbom_manifest_platform_set") + children, configs = set(), set() + for platform in PLATFORMS: + subject = subjects[platform] + require(type(subject) is dict and set(subject) == {"child_manifest_digest", "image_config_digest"} + and all(is_digest(value) for value in subject.values()), "sbom_manifest_subject") + child, config = subject["child_manifest_digest"], subject["image_config_digest"] + require(child != config and config != release["index_digest"], "sbom_manifest_config") + sbom_verification_command(REGISTRIES[0], child, release["index_digest"], release["tag"], release["source_commit"]) + children.add(child) + configs.add(config) + require(len(children) == len(configs) == 2 and children.isdisjoint(configs), "sbom_manifest_distinct_subjects") + documents = distinct_platform_documents(*(raw_documents[platform] for platform in PLATFORMS)) + members = [] + for platform, asset, observation in zip(PLATFORMS, SPDX_ASSETS, documents): + members.append({"platform": platform, **subjects[platform], "asset": asset, + "bytes": observation["raw_bytes"], "sha256": observation["raw_sha256"], + "canonical_predicate_sha256": observation["canonical_sha256"], + "document_namespace": observation["namespace"]}) + result = canonical({"schema": "durable-workflow.native-sbom-manifest.v1", + "repository": {"name": REPOSITORY, "id": int(REPOSITORY_ID), "owner_id": int(OWNER_ID)}, + "release": release, "registries": list(REGISTRIES), "documents": members}) + b"\n" + require(len(result) <= ASSET_LIMITS[MANIFEST_ASSET], "sbom_manifest_size") + return result + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index e1629a907..9ac27bebe 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -103,6 +103,51 @@ def test_provenance_indexes_foreign_subjects_and_changed_signed_predicates_refus with self.subTest(change=change), self.assertRaises(P.Refusal): P.verified_statement_matches(encoded(fixture()), changed, certificate, registry, child, index, source, tag, 123, 2) + def release_manifest_fixture(self): + release = {"tag": "3.2.0-rc.1", "source_commit": "c" * 40, "index_digest": "sha256:" + "d" * 64, + "run_id": 123, "run_attempt": 2} + subjects = {platform: {"child_manifest_digest": "sha256:" + child * 64, + "image_config_digest": "sha256:" + config * 64} + for platform, child, config in (("linux/amd64", "a", "1"), ("linux/arm64", "b", "2"))} + arm = fixture() + arm["documentNamespace"] += "-arm64" + return release, subjects, {"linux/amd64": encoded(fixture()), "linux/arm64": encoded(arm)} + + def test_checksum_map_binds_release_platform_subjects_and_raw_asset_bytes(self): + release, subjects, documents = self.release_manifest_fixture() + raw = P.release_asset_manifest(release, subjects, documents) + value = json.loads(raw) + self.assertEqual(value["release"], release) + self.assertEqual(value["registries"], list(P.REGISTRIES)) + self.assertEqual([v["platform"] for v in value["documents"]], list(P.PLATFORMS)) + for item, name in zip(value["documents"], P.SPDX_ASSETS): + platform = item["platform"] + self.assertEqual(item["asset"], name) + self.assertEqual(item["bytes"], len(documents[platform])) + self.assertEqual(item["sha256"], hashlib.sha256(documents[platform]).hexdigest()) + self.assertEqual(item["child_manifest_digest"], subjects[platform]["child_manifest_digest"]) + self.assertEqual(item["image_config_digest"], subjects[platform]["image_config_digest"]) + changed = {**documents, "linux/amd64": json.dumps(fixture(), separators=(",", ":")).encode()} + self.assertNotEqual(raw, P.release_asset_manifest(release, subjects, changed)) + self.assertEqual(raw, P.release_asset_manifest(dict(reversed(list(release.items()))), + dict(reversed(list(subjects.items()))), dict(reversed(list(documents.items()))))) + + def test_checksum_map_refuses_incomplete_ambiguous_and_wrong_release_tuples(self): + release, subjects, documents = self.release_manifest_fixture() + mutations = [lambda r, s, d: r.update(run_id=True), lambda r, s, d: r.update(run_attempt=0), + lambda r, s, d: r.update(extra="unreviewed"), + lambda r, s, d: s.pop("linux/arm64"), lambda r, s, d: d.pop("linux/arm64"), + lambda r, s, d: s.__setitem__("linux/arm64", copy.deepcopy(s["linux/amd64"])), + lambda r, s, d: s["linux/amd64"].update(child_manifest_digest=r["index_digest"]), + lambda r, s, d: s["linux/amd64"].update(image_config_digest="wrong-config"), + lambda r, s, d: s["linux/amd64"].update(image_config_digest=s["linux/arm64"]["child_manifest_digest"]), + lambda r, s, d: d.__setitem__("linux/arm64", d["linux/amd64"])] + for mutate in mutations: + args = copy.deepcopy((release, subjects, documents)) + mutate(*args) + with self.subTest(mutate=mutate), self.assertRaises(P.Refusal): + P.release_asset_manifest(*args) + def child_fixture(self, architecture="amd64", media_index=0): source, tag = "c" * 40, "3.2.0-rc.1" config = {"os": "linux", "architecture": architecture, "config": {"Labels": { From 1d8784b80b9d67c8db2780c4566520dd7c7ca96d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:56:33 +0000 Subject: [PATCH 13/22] Require complete current SBOM proofs before returning release assets --- scripts/ci/native-sbom/README.md | 12 ++++ scripts/ci/native_sbom_policy.py | 50 +++++++++++++++- tests/Unit/Support/native_sbom_policy_test.py | 57 ++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 59c3e7db1..139201479 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -143,3 +143,15 @@ source library. This map is not a signature or qualification receipt, and the future caller must establish generation and signed-subject custody first. Existing Release assets must retain their original bytes, not be reconstructed through this compiler. + +`verified_release_assets` returns that complete three-file set only after both +registries have qualifying current-run predicates for both platforms. The +verifier request caps bundles at four and projects only its authenticated +certificate/statement output. Each pair's closed identity is checked. Older +verified invocations supply no current authority; an exact current pair is +required, and every current predicate must agree with the retained document. +Conflicting current predicates, incomplete registry/platform sets and foreign +subjects refuse the whole result. Twenty-nine synthetic source families pass. +The future transport must still execute/authenticate those fixed requests and +bind their actual registry origins. Synthetic dictionaries exercise these +requirements without supplying cryptographic or publication evidence. diff --git a/scripts/ci/native_sbom_policy.py b/scripts/ci/native_sbom_policy.py index 26e9d3c05..70b2f860b 100644 --- a/scripts/ci/native_sbom_policy.py +++ b/scripts/ci/native_sbom_policy.py @@ -35,6 +35,8 @@ "application/vnd.docker.distribution.manifest.v2+json") CONFIG_MEDIA_TYPES = ("application/vnd.oci.image.config.v1+json", "application/vnd.docker.container.image.v1+json") +VERIFIED_PAIR_PROJECTION = "map({certificate:.verificationResult.signature.certificate,statement:.verificationResult.statement})" +VERIFIED_PAIR_LIMIT = 4 class Refusal(ValueError): @@ -314,7 +316,7 @@ def sbom_verification_command(registry: str, child: str, index: str, tag: str, s "--repo", REPOSITORY, "--signer-workflow", "github.com/" + REPOSITORY + "/.github/workflows/native-release.yml", "--source-ref", "refs/tags/" + tag, "--source-digest", source, "--predicate-type", PREDICATE_TYPE, "--deny-self-hosted-runners", "--format", "json", - "--bundle-from-oci"] + "--bundle-from-oci", "--limit", str(VERIFIED_PAIR_LIMIT), "--jq", VERIFIED_PAIR_PROJECTION] def require_release_invocation(uri: str, run_id: int, attempt: int) -> None: @@ -433,5 +435,51 @@ def release_asset_manifest(release: dict[str, Any], subjects: dict[str, dict[str return result +def require_current_pairs(pairs: list[dict[str, Any]], release: dict[str, Any], registry: str, + subject: dict[str, str], raw_document: bytes) -> None: + require(type(pairs) is list and 1 <= len(pairs) <= VERIFIED_PAIR_LIMIT, "sbom_verified_pair_count") + current = 0 + for pair in pairs: + require(type(pair) is dict and set(pair) == {"certificate", "statement"} + and type(pair["certificate"]) is dict and type(pair["statement"]) is dict, + "sbom_verified_pair_shape") + certificate = pair["certificate"] + uri = certificate.get("runInvocationURI") + require(type(uri) is str, "sbom_verified_pair_invocation") + invocation = re.fullmatch(r"https://github\.com/" + re.escape(REPOSITORY) + + r"/actions/runs/([1-9][0-9]{0,15})/attempts/([1-9][0-9]{0,15})", uri) + require(invocation is not None, "sbom_verified_pair_invocation") + run_id, attempt = int(invocation[1]), int(invocation[2]) + require_certificate_identity(certificate, release["source_commit"], release["tag"], run_id, attempt) + if (run_id, attempt) != (release["run_id"], release["run_attempt"]): + continue + verified_statement_matches(raw_document, pair["statement"], certificate, + registry, subject["child_manifest_digest"], release["index_digest"], + release["source_commit"], release["tag"], run_id, attempt) + current += 1 + require(current > 0, "sbom_current_attestation_missing") + + +def verified_release_assets(release: dict[str, Any], subjects: dict[str, dict[str, str]], + raw_documents: dict[str, bytes], + verified_pairs: dict[str, dict[str, list[dict[str, Any]]]]) -> dict[str, bytes]: + """Require complete current-run predicates in both registries before return. + + The future transport must obtain each pair from the successful fixed + cryptographic-verification command for that registry/child. These arguments + alone do not authenticate arbitrary dictionaries or their registry origin. + """ + manifest = release_asset_manifest(release, subjects, raw_documents) + require(type(verified_pairs) is dict and set(verified_pairs) == set(REGISTRIES), + "sbom_verified_registry_set") + for registry in REGISTRIES: + by_platform = verified_pairs[registry] + require(type(by_platform) is dict and set(by_platform) == set(PLATFORMS), "sbom_verified_platform_set") + for platform in PLATFORMS: + require_current_pairs(by_platform[platform], release, registry, subjects[platform], raw_documents[platform]) + return {**{asset: raw_documents[platform] for platform, asset in zip(PLATFORMS, SPDX_ASSETS)}, + MANIFEST_ASSET: manifest} + + if __name__ == "__main__": raise SystemExit("native_sbom_policy is a source library; executable/dependency closure is not wired") diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 9ac27bebe..2c4ae5995 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -148,6 +148,60 @@ def test_checksum_map_refuses_incomplete_ambiguous_and_wrong_release_tuples(self with self.subTest(mutate=mutate), self.assertRaises(P.Refusal): P.release_asset_manifest(*args) + def verified_asset_fixture(self): + release, subjects, documents = self.release_manifest_fixture() + certificate, _, _ = self.certificate_fixture() + pairs = {} + for registry in P.REGISTRIES: + pairs[registry] = {} + for platform in P.PLATFORMS: + statement = {"_type": "https://in-toto.io/Statement/v1", "predicateType": P.PREDICATE_TYPE, + "subject": [{"name": registry, "digest": {"sha256": subjects[platform]["child_manifest_digest"][7:]}}], + "predicate": json.loads(documents[platform])} + pairs[registry][platform] = [{"certificate": copy.deepcopy(certificate), "statement": statement}] + return release, subjects, documents, pairs + + def test_complete_current_registry_predicates_return_only_the_three_retained_assets(self): + release, subjects, documents, pairs = self.verified_asset_fixture() + assets = P.verified_release_assets(release, subjects, documents, pairs) + self.assertEqual(set(assets), set(P.ASSET_LIMITS)) + self.assertEqual(assets[P.SPDX_ASSETS[0]], documents["linux/amd64"]) + self.assertEqual(assets[P.SPDX_ASSETS[1]], documents["linux/arm64"]) + self.assertEqual(json.loads(assets[P.MANIFEST_ASSET])["release"], release) + + def test_incomplete_foreign_old_only_and_ambiguous_registry_proof_refuses(self): + registry, platform = P.REGISTRIES[0], P.PLATFORMS[0] + mutations = [lambda p: p.pop(P.REGISTRIES[1]), lambda p: p[registry].pop(platform), + lambda p: p[registry].__setitem__(platform, []), + lambda p: p[registry].__setitem__(platform, p[registry][platform] * 5), + lambda p: p[registry][platform][0]["certificate"].update(runInvocationURI="https://github.com/durable-workflow/server/actions/runs/123/attempts/1"), + lambda p: p[registry][platform][0]["certificate"].update(sourceRepositoryIdentifier="1208272135"), + lambda p: p[registry][platform][0]["statement"]["subject"][0].update(name=P.REGISTRIES[1]), + lambda p: p[registry][platform][0].update(unverified="extra")] + for change in mutations: + release, subjects, documents, pairs = self.verified_asset_fixture() + change(pairs) + with self.subTest(change=change), self.assertRaises(P.Refusal): + P.verified_release_assets(release, subjects, documents, pairs) + release, subjects, documents, pairs = self.verified_asset_fixture() + conflict = copy.deepcopy(pairs[registry][platform][0]) + conflict["statement"]["predicate"]["name"] = "different-current-predicate" + pairs[registry][platform].append(conflict) + with self.assertRaisesRegex(P.Refusal, "predicate_mismatch"): + P.verified_release_assets(release, subjects, documents, pairs) + + def test_old_verified_attempt_is_not_current_authority_and_identical_current_duplicates_are_safe(self): + release, subjects, documents, pairs = self.verified_asset_fixture() + expected = P.verified_release_assets(release, subjects, documents, pairs) + for registry in P.REGISTRIES: + for platform in P.PLATFORMS: + current = pairs[registry][platform][0] + old = copy.deepcopy(current) + old["certificate"]["runInvocationURI"] = "https://github.com/durable-workflow/server/actions/runs/123/attempts/1" + old["statement"]["predicate"]["name"] = "old-predicate-is-not-current-proof" + pairs[registry][platform] = [old, current, copy.deepcopy(current)] + self.assertEqual(P.verified_release_assets(release, subjects, documents, pairs), expected) + def child_fixture(self, architecture="amd64", media_index=0): source, tag = "c" * 40, "3.2.0-rc.1" config = {"os": "linux", "architecture": architecture, "config": {"Labels": { @@ -280,7 +334,8 @@ def test_verification_request_uses_spdx_child_and_fixed_release_identity(self): "github.com/durable-workflow/server/.github/workflows/native-release.yml", "--source-ref", "refs/tags/3.2.0-rc.1", "--source-digest", source, "--predicate-type", "https://spdx.dev/Document/v2.3", "--deny-self-hosted-runners", "--format", "json", - "--bundle-from-oci"]) + "--bundle-from-oci", "--limit", "4", "--jq", + "map({certificate:.verificationResult.signature.certificate,statement:.verificationResult.statement})"]) self.assertNotIn(index, command) def test_verification_request_refuses_redirects_indexes_and_cli_injection(self): From 34166c48a3e57f1db8381244ba49c1ab3280b858 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:27:54 +0000 Subject: [PATCH 14/22] Bind the proposed SBOM validator installer source identity --- scripts/ci/native-sbom/README.md | 10 ++++++ scripts/ci/native-sbom/tool-pins.v1.json | 2 +- scripts/ci/native-sbom/validator-pins.v1.json | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 139201479..fe9629df9 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -54,6 +54,16 @@ filesystem, bounded scratch/output/resources, no capabilities, no-new-privileges no network, daemon socket or host credential mounts. That execution source is still incomplete. Neither image layers nor wheels were acquired or executed. +The candidate also records its installer's source identity. The exact official +Python build enables `ensurepip`; the signed CPython 3.13.18 tag's immutable +source declares bundled pip 26.2.2. Its source/blob and public PyPI wheel metadata +are retained separately. This is an expected version, not an observed runtime, +bundled-wheel hash proof or qualified installed pip. The future confined +executable must bind the actual pinned image/config and isolated Python/pip +readbacks before its offline, hash-closed dependency install. Ambient site or +configuration and network fallback cannot supply that prerequisite. The five +validator dependencies and both derived requirements files are unchanged. + The proposed deterministic raw assets are `server-linux-amd64.spdx.json`, `server-linux-arm64.spdx.json` and `server-sbom-manifest.json`. `exact_release_asset_bytes` rejects absent, extra, renamed, empty, oversized or diff --git a/scripts/ci/native-sbom/tool-pins.v1.json b/scripts/ci/native-sbom/tool-pins.v1.json index 1c2d4ff87..70f04030a 100644 --- a/scripts/ci/native-sbom/tool-pins.v1.json +++ b/scripts/ci/native-sbom/tool-pins.v1.json @@ -6,7 +6,7 @@ "scan_or_publication_admitted": false, "schema_validator_dependency_closure": { "path": "scripts/ci/native-sbom/validator-pins.v1.json", - "sha256": "9b140b139b299d22bd92420a4ae2ff570eeb19c7977f485557b6119c407ad314", + "sha256": "b6374004e922d0f4789e22ad214639c9562f6815fdf67e13528f86885fca3e4c", "status": "candidate_metadata_not_executed" } }, diff --git a/scripts/ci/native-sbom/validator-pins.v1.json b/scripts/ci/native-sbom/validator-pins.v1.json index 0852b5eb2..492b7ae75 100644 --- a/scripts/ci/native-sbom/validator-pins.v1.json +++ b/scripts/ci/native-sbom/validator-pins.v1.json @@ -29,6 +29,42 @@ "rpds-py>=0.7.0" ] ], + "bundled_installer": { + "execution_qualified": false, + "future_boundary": "Bind the exact immutable Python config and effective isolated Python/pip versions before the offline hash-closed install; no ambient site/config or network fallback. Runtime and byte closure remain unqualified.", + "image_build_source": { + "configure_option": "--with-ensurepip", + "git_blob": "731ba320c351ff9980eef4a61cff5d8c3a2e5a80", + "path": "3.13/slim-bookworm/Dockerfile", + "python_source_archive_sha256": "abb0102e6d4ae027509bcb82327d9dd85515b44a3ba8ad538cfc5e9847029f68", + "repository": "docker-library/python", + "source_commit": "152874b138f0b1c4699fbdcb52b174608cfbfff5" + }, + "image_runtime_version_observed": false, + "name": "pip", + "python_source": { + "declared_constant": "_PIP_VERSION = \"26.2.2\"", + "git_blob": "6e01881fefeaf51261c7f67d23ad7112c246d446", + "path": "Lib/ensurepip/__init__.py", + "repository": "python/cpython", + "source_commit": "0d4bd512fc429e3d4eacdd113c975e161f21644d", + "tag": "v3.13.18", + "tag_object": "c4aea32388690378deb747cd4afe01e3253f0696" + }, + "reference_pypi_metadata": { + "requires_dist": null, + "requires_python": ">=3.10", + "scope": "metadata only; not proof of bundled-wheel or installed bytes", + "version": "26.2.2", + "wheel": { + "filename": "pip-26.2.2-py3-none-any.whl", + "sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70", + "size": 1819850, + "url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl" + } + }, + "source_expected_version": "26.2.2" + }, "confinement_source_complete": false, "extras": [], "observation_scope": "public manifests/configs/PyPI metadata only; no layer/wheel acquisition or execution", From a8489b5ec785b307eec2b1c6b1e7f135930c8ad4 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:34:45 +0000 Subject: [PATCH 15/22] Specify offline SBOM export integrity and confinement boundaries --- docs/contracts/native-sbom-generation.md | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/contracts/native-sbom-generation.md diff --git a/docs/contracts/native-sbom-generation.md b/docs/contracts/native-sbom-generation.md new file mode 100644 index 000000000..60e1f067f --- /dev/null +++ b/docs/contracts/native-sbom-generation.md @@ -0,0 +1,101 @@ +# Native SBOM generation source proposal + +This is the remaining generation design for Server #1012, targeting the future +3.2 line. It is not an executable, accepted producer format or operation +admission. `generation_mode` stays null until the complete source and tool +closure are reviewed. Frozen 3.1 artifacts keep their recorded SBOM-free status. + +## Subject and export authority + +Each native architecture job must first retain its actual successful smoke +observation, immutable local config ID, source commit, release, platform and +provider run/attempt/job. Export only that config ID, with an explicit platform; +never resolve a mutable name at export time. The independently verified +published index/child/config tuple remains the attestation authority. An archive +hash or an exporter-generated manifest is not a replacement registry subject. + +The proposed offline input is one uncompressed Docker-save archive. Docker's +[save interface](https://docs.docker.com/reference/cli/docker/image/save/) +can otherwise include multiple platforms and references. The actual selected +CLI/daemon/image-store implementation and its source/format must be pinned +before supporting its output. An illustrative +[Moby v28.5.2 exporter](https://github.com/moby/moby/blob/v28.5.2/image/tarexport/save.go) +writes both Docker and OCI metadata, references config bytes by their hash, and +orders archive layers from the image config's diff IDs. This source example is +not a selection or observation of a running daemon. + +The archive guard must stream under a fixed total-byte/member/deadline bound, +without extracting to the host. It must reject duplicate names, traversal, +absolute paths, links, special/sparse files, unsupported header extensions, +truncation, unrelated image graphs and unrecognized formats. Require exactly +one selected image; hash the complete original config bytes against the smoked +config ID; and hash each uncompressed layer against that config's ordered +`rootfs.diff_ids`. Config equality alone does not authenticate layer contents. +Any additional OCI index/manifest must resolve to the same complete graph; +unreferenced or contradictory members refuse. A generated archive manifest may +differ from the registry manifest because its layer representation differs. + +Export into an exclusively owned private directory. Retain the original regular +file identity, size and hash through validation, read-only scanner mounting and +post-scan revalidation. A replaced path, changed inode/bytes, unknown reader +state or failed export produces no SBOM authority. Exact exporter-format +fixtures and old-config/new-config, reordered/changed-layer, extra-image, +duplicate/path/link/truncation countercontrols are required before adoption. + +## Confined generation and validation + +The proposed scanner input is exclusively the held archive. The pinned +[Syft 1.55.0 source](https://github.com/anchore/syft/blob/f15c3343e29dfb20cf6d82c820e7090b49808c96/cmd/syft/internal/commands/scan.go) +supports explicit `--from docker-archive`; omitting the source selection can +fall back to a daemon or registry. The future fixed invocation must select the +archive source, exact platform, SPDX JSON and intended filesystem scope +explicitly, with automatic update/config discovery disabled by reviewed inputs. +No daemon socket, registry access, host credentials or repository configuration +may reach the scanner. Its platform-specific image child/config and actual +Syft version must match the committed tool pins before accepting output. + +Both scanner and validator must run as an explicit nonroot UID/GID, with a +read-only root, no capabilities, no-new-privileges, no network or published +ports, and bounded tmpfs/scratch, CPU, memory, PIDs, output and absolute cleanup +deadlines. Mount only the sealed input and reviewed tool/dependency files +read-only. Bound scanner stdout to the existing 16-MiB document limit and retain +the original bytes; stderr is bounded diagnostics, never provider authority. +Source tests must inspect the complete effective create/start arguments and +reject an extra mount, environment key, source, privilege or mutable tool name. + +The validator uses the independently pinned Python child/config and installer +source identity, held hash-checked wheels and the unchanged architecture-specific +requirements. Its install is offline with no dependency resolution or ambient +site/config fallback. Effective isolated Python/pip versions and complete +installed dependency identities must match before importing the schema checker. +The pinned vendored schema denies remote retrieval. Validation returns bounded +hash/count observations; it does not rewrite the original SPDX asset. + +Use existing job deadlines and the reviewed exact-owner cleanup mechanism, +extended narrowly for the new kind/scope and bound helper bytes. Always-run +cleanup must inspect exact IDs plus owner labels, retain the original failure, +and refuse foreign/unknown resources. Do not add a by-name fallback or another +lifecycle controller. No positive receipt exists until body and cleanup both +succeed and an independent terminal absence readback passes. + +## Provider and publication wiring + +Retain each platform's original SPDX, config/export observations and cleanup +receipt as bounded provider artifacts bound to exact source/run/attempt/job. +The later publisher must independently verify those bindings against both +registries' actual immutable index/child/config tuples before signing each +platform predicate for each registry. Signing an index or provenance predicate +does not satisfy this contract. + +The Release verifier must execute the fixed registry-bundle signature requests, +check current repository/owner identity and exact signer/source/tag/invocation, +and compare every current predicate with the retained document. All four +registry/platform slots must pass before returning the two raw SPDX files and +the checksum map. Existing assets require original byte equality; conflicting, +missing, failed or ambiguous evidence permits no writer or replacement. + +Future 3.2 workflow wiring must preserve the shared promotion/Release lock and +revalidate provider evidence after protection approval. It must not apply the +new SBOM requirement retroactively to frozen 3.1. Normal source publication, +complete Security review and General's source/operation selections remain +separate prerequisites; this document selects no scan, build or release. From 7450cd02f6adb22b737b261f03abaa2d01fafb3a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:47:31 +0000 Subject: [PATCH 16/22] Bind bounded OCI archive contents to the smoked config and layers --- scripts/ci/native-sbom/README.md | 17 ++ scripts/ci/native_sbom_archive.py | 179 ++++++++++++++++++ .../Unit/Support/native_sbom_archive_test.py | 167 ++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 scripts/ci/native_sbom_archive.py create mode 100644 tests/Unit/Support/native_sbom_archive_test.py diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index fe9629df9..27aa9ea22 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -165,3 +165,20 @@ subjects refuse the whole result. Twenty-nine synthetic source families pass. The future transport must still execute/authenticate those fixed requests and bind their actual registry origins. Synthetic dictionaries exercise these requirements without supplying cryptographic or publication evidence. + +The separate source component `native_sbom_archive.py` implements a narrower +initial input guard: one uncompressed canonical OCI archive, with all blob +hashes/sizes and exactly one config/layer graph matching the independently +smoked config and its ordered diff IDs. It reads under byte/member/deadline +bounds without host extraction or extension processing. Duplicate/traversal/link/ +sparse/extension entries, changed or reordered layers, extra graphs/members, +wrong descriptors, malformed JSON, truncation and changed reread metadata refuse. +It returns an archive-specific manifest identity, never a registry subject. + +This guard is not yet a selected generator input. Docker-save hybrid exports, +compressed layers and other unqualified formats deliberately refuse; a reviewed +producer/adapter and actual format qualification are still required before +adoption. The future caller must own/seal the original regular file, bind its +config to actual smoke/provider authority and retain/recheck byte identity +through the confined scan. Generation mode stays null and direct component +execution refuses. Synthetic tar fixtures are not image exports or scan proof. diff --git a/scripts/ci/native_sbom_archive.py b/scripts/ci/native_sbom_archive.py new file mode 100644 index 000000000..8041f3df5 --- /dev/null +++ b/scripts/ci/native_sbom_archive.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Bound one strict, uncompressed OCI archive graph; no extraction or execution. + +This source component does not select/qualify an exporter or authenticate the +expected smoked config. Docker's hybrid export is deliberately unsupported. +""" +from __future__ import annotations + +import hashlib +import re +import tarfile +import time +from typing import BinaryIO + +from native_sbom_policy import Refusal, is_digest, parse, require + + +ARCHIVE_LIMIT = 2 * 1024 * 1024 * 1024 +MEMBER_LIMIT = 1024 +LAYER_LIMIT = 128 +JSON_LIMIT = 1048576 +BLOB = re.compile(r"blobs/sha256/[0-9a-f]{64}\Z") +MANIFEST_TYPE = "application/vnd.oci.image.manifest.v1+json" +CONFIG_TYPE = "application/vnd.oci.image.config.v1+json" +LAYER_TYPE = "application/vnd.oci.image.layer.v1.tar" + + +class ArchiveReader: + def __init__(self, stream: BinaryIO, seconds: float): + self.stream = stream + self.deadline = time.monotonic() + seconds + self.count = 0 + self.digest = hashlib.sha256() + + def read(self, size: int) -> bytes: + require(time.monotonic() < self.deadline, "sbom_archive_deadline") + raw = self.stream.read(size) + require(type(raw) is bytes and len(raw) <= size, "sbom_archive_reader") + self.count += len(raw) + require(self.count <= ARCHIVE_LIMIT, "sbom_archive_size") + self.digest.update(raw) + require(time.monotonic() < self.deadline, "sbom_archive_deadline") + return raw + + def exact(self, size: int) -> bytes: + raw = self.read(size) + require(len(raw) == size, "sbom_archive_truncated") + return raw + + +def members(reader: ArchiveReader) -> dict[str, dict]: + result = {} + names = set() + while True: + block = reader.exact(512) + if block == bytes(512): + require(reader.exact(512) == bytes(512), "sbom_archive_end") + padding = 0 + while True: + trailing = reader.read(8192) + padding += len(trailing) + require(padding <= 65536 and not any(trailing), "sbom_archive_trailing") + if not trailing: + return result + try: + # Parse only this header. Never invoke tarfile's extension reader, + # extractor, sparse expansion or compressed-archive detection. + item = tarfile.TarInfo.frombuf(block, encoding="utf-8", errors="strict") + except (tarfile.HeaderError, UnicodeError, ValueError): + raise Refusal("sbom_archive_header") from None + name = item.name + directory = item.type == tarfile.DIRTYPE + allowed_directory = directory and name in ("blobs", "blobs/sha256") + allowed_file = item.type in (tarfile.REGTYPE, tarfile.AREGTYPE) and ( + name in ("oci-layout", "index.json") or BLOB.fullmatch(name) is not None) + require((allowed_directory or allowed_file) and not item.linkname + and type(item.size) is int and 0 <= item.size <= ARCHIVE_LIMIT, + "sbom_archive_member") + require(name not in names and len(names) < MEMBER_LIMIT, "sbom_archive_duplicate_or_count") + names.add(name) + if directory: + require(item.size == 0, "sbom_archive_directory_size") + continue + offset, remaining = reader.count, item.size + digest = hashlib.sha256() + while remaining: + data = reader.exact(min(65536, remaining)) + digest.update(data) + remaining -= len(data) + padding = reader.exact((-item.size) % 512) + require(not any(padding), "sbom_archive_member_padding") + hexdigest = digest.hexdigest() + if name.startswith("blobs/"): + require(name == "blobs/sha256/" + hexdigest, "sbom_archive_blob_digest") + result[name] = {"offset": offset, "size": item.size, "sha256": hexdigest} + + +def json_member(reader: ArchiveReader, table: dict[str, dict], name: str, limit: int = JSON_LIMIT): + require(name in table and 0 < table[name]["size"] <= limit, "sbom_archive_json_member") + require(time.monotonic() < reader.deadline, "sbom_archive_deadline") + item = table[name] + reader.stream.seek(item["offset"]) + raw = reader.stream.read(item["size"]) + require(type(raw) is bytes and len(raw) == item["size"] + and hashlib.sha256(raw).hexdigest() == item["sha256"], "sbom_archive_changed") + require(time.monotonic() < reader.deadline, "sbom_archive_deadline") + return parse(raw, limit) + + +def descriptor(value, media_type: str, table: dict[str, dict], platform: str | None = None) -> str: + require(type(value) is dict and set(value) <= {"mediaType", "digest", "size", "platform"} + and value.get("mediaType") == media_type and is_digest(value.get("digest")) + and type(value.get("size")) is int and 0 < value["size"] <= ARCHIVE_LIMIT, + "sbom_archive_descriptor") + if platform is not None: + selected = value.get("platform") + require(type(selected) is dict and set(selected) <= {"os", "architecture", "variant"} + and selected.get("os") == "linux" and selected.get("architecture") == platform.split("/")[1] + and ("variant" not in selected or + (platform == "linux/arm64" and selected["variant"] == "v8")), + "sbom_archive_descriptor_platform") + else: + require("platform" not in value, "sbom_archive_descriptor_platform") + name = "blobs/sha256/" + value["digest"][7:] + require(name in table and table[name]["size"] == value["size"], "sbom_archive_descriptor_size") + return name + + +def oci_archive_identity(stream: BinaryIO, expected_config: str, platform: str, + seconds: float = 30) -> dict: + """Check a sealed seekable input against an independently smoked config. + + The future caller must own/seal the regular file and retain/recheck its + original identity through scanning. Passing an arbitrary expected digest + does not authenticate a smoke, provider, registry or publication subject. + """ + require(is_digest(expected_config) and platform in ("linux/amd64", "linux/arm64") + and type(seconds) in (int, float) and 0 < seconds <= 30, "sbom_archive_inputs") + require(stream.seekable() and stream.tell() == 0, "sbom_archive_stream") + reader = ArchiveReader(stream, seconds) + table = members(reader) + require(json_member(reader, table, "oci-layout", 4096) == {"imageLayoutVersion": "1.0.0"}, + "sbom_archive_layout") + index = json_member(reader, table, "index.json", 65536) + require(type(index) is dict and set(index) == {"schemaVersion", "mediaType", "manifests"} + and type(index["schemaVersion"]) is int and index["schemaVersion"] == 2 + and index["mediaType"] == "application/vnd.oci.image.index.v1+json" + and type(index["manifests"]) is list and len(index["manifests"]) == 1, "sbom_archive_index") + manifest_name = descriptor(index["manifests"][0], MANIFEST_TYPE, table, platform) + manifest = json_member(reader, table, manifest_name) + require(type(manifest) is dict and set(manifest) == {"schemaVersion", "mediaType", "config", "layers"} + and type(manifest["schemaVersion"]) is int and manifest["schemaVersion"] == 2 + and manifest["mediaType"] == MANIFEST_TYPE and type(manifest["layers"]) is list + and 0 < len(manifest["layers"]) <= LAYER_LIMIT, "sbom_archive_manifest") + config_name = descriptor(manifest["config"], CONFIG_TYPE, table) + require(config_name == "blobs/sha256/" + expected_config[7:], "sbom_archive_smoked_config") + config = json_member(reader, table, config_name) + require(type(config) is dict and config.get("os") == "linux" + and config.get("architecture") == platform.split("/")[1] + and ("variant" not in config or (platform == "linux/arm64" and config["variant"] == "v8")), + "sbom_archive_config_platform") + rootfs = config.get("rootfs") + require(type(rootfs) is dict and set(rootfs) == {"type", "diff_ids"} and rootfs["type"] == "layers" + and type(rootfs["diff_ids"]) is list and len(rootfs["diff_ids"]) == len(manifest["layers"]) + and all(is_digest(value) for value in rootfs["diff_ids"]), "sbom_archive_rootfs") + selected = {"oci-layout", "index.json", config_name, manifest_name} + for layer, diff_id in zip(manifest["layers"], rootfs["diff_ids"]): + name = descriptor(layer, LAYER_TYPE, table) + require(name == "blobs/sha256/" + diff_id[7:], "sbom_archive_layer_diff_id") + selected.add(name) + require(set(table) == selected, "sbom_archive_unreferenced_member") + return {"archive_sha256": reader.digest.hexdigest(), "archive_bytes": reader.count, + "image_config_digest": expected_config, "platform": platform, + "archive_manifest_digest": "sha256:" + table[manifest_name]["sha256"], + "layer_count": len(manifest["layers"])} + + +if __name__ == "__main__": + raise SystemExit("native_sbom_archive is a source component; producer/scan authority is not wired") diff --git a/tests/Unit/Support/native_sbom_archive_test.py b/tests/Unit/Support/native_sbom_archive_test.py new file mode 100644 index 000000000..3d1da24ee --- /dev/null +++ b/tests/Unit/Support/native_sbom_archive_test.py @@ -0,0 +1,167 @@ +"""Synthetic OCI tar graph controls; no Docker export, scan or extraction.""" +import copy +import hashlib +import io +import json +from pathlib import Path +import sys +import tarfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts/ci")) +import native_sbom_archive as A + + +def encoded(value): + return json.dumps(value, separators=(",", ":")).encode() + + +def digest(raw): + return "sha256:" + hashlib.sha256(raw).hexdigest() + + +def blob_name(raw): + return "blobs/sha256/" + digest(raw)[7:] + + +def descriptor(raw, media_type): + return {"mediaType": media_type, "digest": digest(raw), "size": len(raw)} + + +def fixture(platform="linux/amd64", mutate_manifest=None, mutate_index=None): + layers = [b"synthetic layer one", b"synthetic layer two"] + config = encoded({"os": "linux", "architecture": platform.split("/")[1], + "rootfs": {"type": "layers", "diff_ids": [digest(raw) for raw in layers]}}) + manifest = {"schemaVersion": 2, "mediaType": A.MANIFEST_TYPE, + "config": descriptor(config, A.CONFIG_TYPE), + "layers": [descriptor(raw, A.LAYER_TYPE) for raw in layers]} + if mutate_manifest: + mutate_manifest(manifest) + manifest_raw = encoded(manifest) + selected = {**descriptor(manifest_raw, A.MANIFEST_TYPE), + "platform": {"os": "linux", "architecture": platform.split("/")[1]}} + index = {"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [selected]} + if mutate_index: + mutate_index(index) + files = {"oci-layout": b'{"imageLayoutVersion":"1.0.0"}', "index.json": encoded(index), + blob_name(config): config, blob_name(manifest_raw): manifest_raw, + **{blob_name(raw): raw for raw in layers}} + return files, digest(config) + + +def archive(files, extras=()): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w", format=tarfile.USTAR_FORMAT) as tar: + for name, raw in files.items(): + info = tarfile.TarInfo(name) + info.size = len(raw) + tar.addfile(info, io.BytesIO(raw)) + for info, raw in extras: + tar.addfile(info, io.BytesIO(raw)) + return output.getvalue() + + +class SbomArchiveTest(unittest.TestCase): + def test_both_platforms_bind_complete_config_and_ordered_layers(self): + for platform in ("linux/amd64", "linux/arm64"): + files, config = fixture(platform) + raw = archive(files) + with self.subTest(platform=platform): + observed = A.oci_archive_identity(io.BytesIO(raw), config, platform) + self.assertEqual(observed["image_config_digest"], config) + self.assertEqual(observed["archive_bytes"], len(raw)) + self.assertEqual(observed["archive_sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(observed["layer_count"], 2) + self.assertNotEqual(observed["archive_manifest_digest"], config) + + def test_smoked_config_platform_and_layer_order_cannot_be_substituted(self): + files, config = fixture() + for expected, platform in (("sha256:" + "0" * 64, "linux/amd64"), (config, "linux/arm64")): + with self.subTest(expected=expected, platform=platform), self.assertRaises(A.Refusal): + A.oci_archive_identity(io.BytesIO(archive(files)), expected, platform) + files, config = fixture(mutate_manifest=lambda manifest: manifest["layers"].reverse()) + with self.assertRaisesRegex(A.Refusal, "layer_diff_id"): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + files, config = fixture(mutate_manifest=lambda manifest: manifest["layers"][0].update(mediaType="application/vnd.oci.image.layer.v1.tar+gzip")) + with self.assertRaisesRegex(A.Refusal, "descriptor"): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + + def test_matching_config_never_hides_changed_layer_bytes(self): + files, config = fixture() + name = blob_name(b"synthetic layer one") + files[name] = b"synthetic layer evil" + with self.assertRaisesRegex(A.Refusal, "blob_digest"): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + + def test_extra_images_members_and_wrong_descriptor_sizes_refuse(self): + changes = [lambda index: index["manifests"].append(copy.deepcopy(index["manifests"][0])), + lambda index: index["manifests"][0].update(size=1), + lambda index: index["manifests"][0].update(digest="sha256:" + "f" * 64), + lambda index: index["manifests"][0]["platform"].update(variant="v9")] + for change in changes: + files, config = fixture(mutate_index=change) + with self.subTest(change=change), self.assertRaises(A.Refusal): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + files, config = fixture() + files[blob_name(b"unreferenced")] = b"unreferenced" + with self.assertRaisesRegex(A.Refusal, "unreferenced_member"): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + + def test_duplicate_paths_links_traversal_and_extensions_refuse(self): + files, config = fixture() + duplicate = tarfile.TarInfo("oci-layout") + duplicate.size = 2 + extras = [(duplicate, b"{}")] + for name, kind in (("../outside", tarfile.REGTYPE), ("/outside", tarfile.REGTYPE), + ("blobs/sha256/" + "0" * 64, tarfile.SYMTYPE), + ("blobs/sha256/" + "0" * 64, tarfile.LNKTYPE), + ("pax-header", tarfile.XHDTYPE), ("sparse", tarfile.GNUTYPE_SPARSE)): + info = tarfile.TarInfo(name) + info.type = kind + extras.append((info, b"")) + for info, raw in extras: + with self.subTest(name=info.name, kind=info.type), self.assertRaises(A.Refusal): + A.oci_archive_identity(io.BytesIO(archive(files, [(info, raw)])), config, "linux/amd64") + + def test_truncation_nonzero_trailing_and_resource_bounds_refuse(self): + files, config = fixture() + raw = archive(files) + for changed in (raw[:100], raw[:1024], raw + b"not-padding"): + with self.subTest(size=len(changed)), self.assertRaises(A.Refusal): + A.oci_archive_identity(io.BytesIO(changed), config, "linux/amd64") + with patch.object(A, "ARCHIVE_LIMIT", 100), self.assertRaisesRegex(A.Refusal, "size"): + A.oci_archive_identity(io.BytesIO(raw), config, "linux/amd64") + with patch.object(A, "MEMBER_LIMIT", 2), self.assertRaisesRegex(A.Refusal, "count"): + A.oci_archive_identity(io.BytesIO(raw), config, "linux/amd64") + with patch.object(A.time, "monotonic", side_effect=[0, 31]), self.assertRaisesRegex(A.Refusal, "deadline"): + A.oci_archive_identity(io.BytesIO(raw), config, "linux/amd64") + + def test_archive_metadata_requires_original_strict_json(self): + files, config = fixture() + for changed in (b'{"imageLayoutVersion":"bad","imageLayoutVersion":"1.0.0"}', + b'{"imageLayoutVersion":"1.0.0"}{}', b'{"imageLayoutVersion":"\xff"}'): + files["oci-layout"] = changed + with self.subTest(changed=changed), self.assertRaises(A.Refusal): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + + def test_changed_metadata_between_hashing_and_graph_read_refuses(self): + files, config = fixture() + + class ChangedInput(io.BytesIO): + changed = False + + def seek(self, offset, whence=0): + result = super().seek(offset, whence) + if offset == 512 and not self.changed: + self.changed = True + super().write(b'{"imageLayoutVersion":"9.0.0"}') + super().seek(offset) + return result + + with self.assertRaisesRegex(A.Refusal, "archive_changed"): + A.oci_archive_identity(ChangedInput(archive(files)), config, "linux/amd64") + + +if __name__ == "__main__": + unittest.main() From ae11fbe694a39b489a0f52b998e7fae06c34d327 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:04:19 +0000 Subject: [PATCH 17/22] Bind the proposed OCI side output and known BuildKit metadata --- docs/contracts/native-sbom-generation.md | 44 +++++++++++++------ scripts/ci/native-sbom/README.md | 8 ++++ scripts/ci/native_sbom_archive.py | 29 +++++++++--- .../Unit/Support/native_sbom_archive_test.py | 26 +++++++++++ 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/docs/contracts/native-sbom-generation.md b/docs/contracts/native-sbom-generation.md index 60e1f067f..33eddf4dc 100644 --- a/docs/contracts/native-sbom-generation.md +++ b/docs/contracts/native-sbom-generation.md @@ -7,22 +7,40 @@ closure are reviewed. Frozen 3.1 artifacts keep their recorded SBOM-free status. ## Subject and export authority -Each native architecture job must first retain its actual successful smoke +Each native architecture job must retain its actual successful smoke observation, immutable local config ID, source commit, release, platform and -provider run/attempt/job. Export only that config ID, with an explicit platform; -never resolve a mutable name at export time. The independently verified +provider run/attempt/job. The preferred source candidate emits an additional +OCI archive from the same single-platform build result. It may be produced +before smoke, but cannot supply SBOM authority until its complete config/layer +graph matches the actual successfully smoked config. Never rebuild or resolve +a mutable name to obtain a supposedly equivalent input. The independently verified published index/child/config tuple remains the attestation authority. An archive hash or an exporter-generated manifest is not a replacement registry subject. -The proposed offline input is one uncompressed Docker-save archive. Docker's -[save interface](https://docs.docker.com/reference/cli/docker/image/save/) -can otherwise include multiple platforms and references. The actual selected -CLI/daemon/image-store implementation and its source/format must be pinned -before supporting its output. An illustrative -[Moby v28.5.2 exporter](https://github.com/moby/moby/blob/v28.5.2/image/tarexport/save.go) -writes both Docker and OCI metadata, references config bytes by their hash, and -orders archive layers from the image config's diff IDs. This source example is -not a selection or observation of a running daemon. +The proposed offline input is one unnamed OCI-layout tar with uncompressed +layers. [Multiple exporters](https://docs.docker.com/build/exporters/) +allow a second output without a second build. The candidate OCI parameters are +`type=oci,dest=native-sbom/input.oci.tar,compression=uncompressed,force-compression=true,oci-mediatypes=true`. +Force compression is explicit so cached compressed layers cannot silently +enter this input profile. The native Release's existing named registry output +would remain separate; the PR image's daemon output would carry its existing +owned smoke name. Do not use global tags to name the additional archive: +[Buildx 0.38.1](https://github.com/docker/buildx/blob/v0.38.1/build/opt.go) +copies those names to every image exporter. + +Read-only historical provider log `42056550973` / job `127346371667` records +Buildx 0.38.1, BuildKit 0.34.2 and Engine 29.4.2. This is existing-tool evidence, +not a new exporter invocation or future runtime qualification. The exact +[BuildKit OCI exporter](https://github.com/moby/buildkit/blob/v0.34.2/exporter/oci/export.go) +selects containerd's skip-Docker-manifest option and adds a UTC creation +annotation; a single manifest descriptor may omit its platform. Those known +presentation fields cannot replace config/platform authority. Unknown names, +annotations or additional graphs still refuse. Actual exporter identity and +produced format must be independently qualified before adoption. + +Docker-save hybrid input remains unsupported. Its possible additional image +references/metadata require a different reviewed adapter; it is not a fallback +when the proposed OCI export or guard fails. The archive guard must stream under a fixed total-byte/member/deadline bound, without extracting to the host. It must reject duplicate names, traversal, @@ -46,7 +64,7 @@ duplicate/path/link/truncation countercontrols are required before adoption. The proposed scanner input is exclusively the held archive. The pinned [Syft 1.55.0 source](https://github.com/anchore/syft/blob/f15c3343e29dfb20cf6d82c820e7090b49808c96/cmd/syft/internal/commands/scan.go) -supports explicit `--from docker-archive`; omitting the source selection can +supports explicit `--from oci-archive`; omitting the source selection can fall back to a daemon or registry. The future fixed invocation must select the archive source, exact platform, SPDX JSON and intended filesystem scope explicitly, with automatic update/config discovery disabled by reviewed inputs. diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 27aa9ea22..9ebfae353 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -182,3 +182,11 @@ adoption. The future caller must own/seal the original regular file, bind its config to actual smoke/provider authority and retain/recheck byte identity through the confined scan. Generation mode stays null and direct component execution refuses. Synthetic tar fixtures are not image exports or scan proof. + +The source-derived BuildKit profile additionally accepts only its known UTC +creation annotation on the root descriptor and an omitted optional root +platform, while still requiring the exact config's real platform. The annotation +supplies no source, registry, smoke or timestamp authority. Unknown annotations, +image-name/ref hints, malformed dates and explicit wrong platforms refuse. +This supports the proposed unnamed OCI side output from the same build without +relaxing blob/graph/layer integrity or claiming actual producer qualification. diff --git a/scripts/ci/native_sbom_archive.py b/scripts/ci/native_sbom_archive.py index 8041f3df5..757ed9282 100644 --- a/scripts/ci/native_sbom_archive.py +++ b/scripts/ci/native_sbom_archive.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +from datetime import datetime import re import tarfile import time @@ -107,19 +108,34 @@ def json_member(reader: ArchiveReader, table: dict[str, dict], name: str, limit: return parse(raw, limit) -def descriptor(value, media_type: str, table: dict[str, dict], platform: str | None = None) -> str: - require(type(value) is dict and set(value) <= {"mediaType", "digest", "size", "platform"} +def descriptor(value, media_type: str, table: dict[str, dict], platform: str | None = None, + exporter_root: bool = False) -> str: + allowed = {"mediaType", "digest", "size", "platform"} + if exporter_root: + allowed.add("annotations") + require(type(value) is dict and set(value) <= allowed and value.get("mediaType") == media_type and is_digest(value.get("digest")) and type(value.get("size")) is int and 0 < value["size"] <= ARCHIVE_LIMIT, "sbom_archive_descriptor") - if platform is not None: + if "annotations" in value: + annotations = value["annotations"] + require(type(annotations) is dict and set(annotations) == {"org.opencontainers.image.created"}, + "sbom_archive_export_annotation") + created = annotations["org.opencontainers.image.created"] + require(type(created) is str and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", created) + is not None, "sbom_archive_export_annotation") + try: + datetime.strptime(created, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + raise Refusal("sbom_archive_export_annotation") from None + if platform is not None and (not exporter_root or "platform" in value): selected = value.get("platform") require(type(selected) is dict and set(selected) <= {"os", "architecture", "variant"} and selected.get("os") == "linux" and selected.get("architecture") == platform.split("/")[1] and ("variant" not in selected or (platform == "linux/arm64" and selected["variant"] == "v8")), "sbom_archive_descriptor_platform") - else: + elif platform is None: require("platform" not in value, "sbom_archive_descriptor_platform") name = "blobs/sha256/" + value["digest"][7:] require(name in table and table[name]["size"] == value["size"], "sbom_archive_descriptor_size") @@ -146,7 +162,10 @@ def oci_archive_identity(stream: BinaryIO, expected_config: str, platform: str, and type(index["schemaVersion"]) is int and index["schemaVersion"] == 2 and index["mediaType"] == "application/vnd.oci.image.index.v1+json" and type(index["manifests"]) is list and len(index["manifests"]) == 1, "sbom_archive_index") - manifest_name = descriptor(index["manifests"][0], MANIFEST_TYPE, table, platform) + # BuildKit's OCI exporter adds only a presentation creation annotation and + # can omit this optional platform field. The exact config below remains the + # platform authority; neither annotation nor omission substitutes for it. + manifest_name = descriptor(index["manifests"][0], MANIFEST_TYPE, table, platform, exporter_root=True) manifest = json_member(reader, table, manifest_name) require(type(manifest) is dict and set(manifest) == {"schemaVersion", "mediaType", "config", "layers"} and type(manifest["schemaVersion"]) is int and manifest["schemaVersion"] == 2 diff --git a/tests/Unit/Support/native_sbom_archive_test.py b/tests/Unit/Support/native_sbom_archive_test.py index 3d1da24ee..e29291186 100644 --- a/tests/Unit/Support/native_sbom_archive_test.py +++ b/tests/Unit/Support/native_sbom_archive_test.py @@ -63,6 +63,32 @@ def archive(files, extras=()): class SbomArchiveTest(unittest.TestCase): + def test_buildkit_root_creation_metadata_does_not_replace_config_authority(self): + def buildkit_root(index): + item = index["manifests"][0] + del item["platform"] + item["annotations"] = {"org.opencontainers.image.created": "2026-11-05T00:00:00Z"} + + for platform in ("linux/amd64", "linux/arm64"): + files, config = fixture(platform, mutate_index=buildkit_root) + raw = archive(files) + with self.subTest(platform=platform): + self.assertEqual(A.oci_archive_identity(io.BytesIO(raw), config, platform)["platform"], platform) + foreign = "linux/arm64" if platform == "linux/amd64" else "linux/amd64" + with self.assertRaisesRegex(A.Refusal, "config_platform"): + A.oci_archive_identity(io.BytesIO(raw), config, foreign) + + def test_export_annotations_refuse_unknown_names_types_and_invalid_calendar(self): + bad = [{"org.opencontainers.image.ref.name": "mutable:latest"}, + {"org.opencontainers.image.created": None}, + {"org.opencontainers.image.created": "2026-02-30T00:00:00Z"}, + {"org.opencontainers.image.created": "2026-11-05T00:00:00Zsuffix"}, + {"org.opencontainers.image.created": "2026-11-05T00:00:00Z", "foreign": "value"}] + for annotations in bad: + files, config = fixture(mutate_index=lambda index: index["manifests"][0].update(annotations=annotations)) + with self.subTest(annotations=annotations), self.assertRaisesRegex(A.Refusal, "export_annotation"): + A.oci_archive_identity(io.BytesIO(archive(files)), config, "linux/amd64") + def test_both_platforms_bind_complete_config_and_ordered_layers(self): for platform in ("linux/amd64", "linux/arm64"): files, config = fixture(platform) From 90c7796defe40c2f911238eb1a854899d5ed6559 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:32:41 +0000 Subject: [PATCH 18/22] Preserve the publication digest across proposed multiple exporters --- docs/contracts/native-sbom-generation.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/contracts/native-sbom-generation.md b/docs/contracts/native-sbom-generation.md index 33eddf4dc..4f881df99 100644 --- a/docs/contracts/native-sbom-generation.md +++ b/docs/contracts/native-sbom-generation.md @@ -28,6 +28,18 @@ owned smoke name. Do not use global tags to name the additional archive: [Buildx 0.38.1](https://github.com/docker/buildx/blob/v0.38.1/build/opt.go) copies those names to every image exporter. +Output order is part of this proposal. Put the unnamed OCI exporter first and +the existing named registry (or PR daemon) exporter last. The exact +[BuildKit response merger](https://github.com/moby/buildkit/blob/v0.34.2/solver/llbsolver/export.go) +collects results by exporter position, then overwrites duplicate response keys +in that order. Appending OCI after the registry output could replace the +publisher's `containerimage.digest` with an archive-specific digest. Future +source controls must reject that order and independently bind the resulting +registry digest/config to the actual immutable registry and smoke observations. +An aggregate build response alone is not registry-subject authority. Preserve +the existing registry output arguments and never substitute the archive digest +when a publication or smoke read fails. + Read-only historical provider log `42056550973` / job `127346371667` records Buildx 0.38.1, BuildKit 0.34.2 and Engine 29.4.2. This is existing-tool evidence, not a new exporter invocation or future runtime qualification. The exact From fed5b11567ef75ca02e2bbb5cf28ad1c7d3bb3ed Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:18:59 +0000 Subject: [PATCH 19/22] Stage bounded SBOM source components for ordinary review [skip ci] From 1bb29ec93bdee9b36c56ad59dc5517cd6d4e096c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:31:42 +0000 Subject: [PATCH 20/22] Run bounded native SBOM component tests in ordinary source CI --- .github/workflows/phpunit-feature.yml | 26 +++++++++++++++++++ scripts/ci/native-sbom/README.md | 9 +++++++ .../native-sbom/source-test.requirements.txt | 6 +++++ tests/Unit/Support/native_sbom_policy_test.py | 16 ++++++++++++ tests/Unit/Support/pip-sinks.json | 22 ++++++++++++++-- 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 scripts/ci/native-sbom/source-test.requirements.txt diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1acf9216d..439e36f97 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -59,6 +59,32 @@ jobs: --target server --workflow-directory .github/workflows + sbom-source: + name: Native SBOM source components + needs: action-policy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Set up source-test Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + - name: Install hash-closed SBOM source-test tooling + run: >- + python -I -m pip install --no-deps --require-hashes --only-binary=:all: + --requirement scripts/ci/native-sbom/source-test.requirements.txt + + - name: Check bounded SBOM source components with synthetic inputs + run: | + python -I -B tests/Unit/Support/native_sbom_archive_test.py + python -I -B tests/Unit/Support/native_sbom_policy_test.py + preflight: name: Repository contracts runs-on: ubuntu-latest diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index 9ebfae353..b1ac1ed5c 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -166,6 +166,15 @@ The future transport must still execute/authenticate those fixed requests and bind their actual registry origins. Synthetic dictionaries exercise these requirements without supplying cryptographic or publication evidence. +Ordinary source CI runs these synthetic policy and archive tests in a bounded +source-test job after the existing central-action preflight and patched-pip +bootstrap. Its +Linux/amd64 CPython 3.13 test dependencies are the five hash-closed wheel URLs +in `source-test.requirements.txt`, derived from the validator metadata and +checked against it. This is development test tooling, separate from the future +confined image/offline validator execution. It supplies no generator, archive, +scan, installed dependency or publication qualification. + The separate source component `native_sbom_archive.py` implements a narrower initial input guard: one uncompressed canonical OCI archive, with all blob hashes/sizes and exactly one config/layer graph matching the independently diff --git a/scripts/ci/native-sbom/source-test.requirements.txt b/scripts/ci/native-sbom/source-test.requirements.txt new file mode 100644 index 000000000..4fe6e3c84 --- /dev/null +++ b/scripts/ci/native-sbom/source-test.requirements.txt @@ -0,0 +1,6 @@ +# Ordinary Linux/amd64 CPython 3.13 source-test tooling; not an installed generator receipt. +https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 +https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe +https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 +https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b diff --git a/tests/Unit/Support/native_sbom_policy_test.py b/tests/Unit/Support/native_sbom_policy_test.py index 2c4ae5995..e8f0a48b2 100644 --- a/tests/Unit/Support/native_sbom_policy_test.py +++ b/tests/Unit/Support/native_sbom_policy_test.py @@ -35,6 +35,22 @@ def encoded(value): class SbomPolicyTests(unittest.TestCase): + def test_ci_source_tooling_matches_the_hash_closed_dependency_inventory(self): + pins = ROOT / "scripts/ci/native-sbom" + metadata_raw = (pins / "validator-pins.v1.json").read_bytes() + tools = json.loads((pins / "tool-pins.v1.json").read_bytes()) + self.assertEqual(hashlib.sha256(metadata_raw).hexdigest(), + tools["admission"]["schema_validator_dependency_closure"]["sha256"]) + packages = json.loads(metadata_raw)["packages"] + requirements = (pins / "source-test.requirements.txt").read_text().splitlines() + entries = [line for line in requirements if line and not line.startswith("#")] + expected = [] + for _, package in sorted(packages.items()): + wheel = package["wheels"].get("any") or package["wheels"]["linux/amd64"] + expected.append(wheel["url"] + " --hash=sha256:" + wheel["sha256"]) + self.assertEqual(len(entries), 5) + self.assertEqual(entries, expected) + def certificate_fixture(self): source, tag = "c" * 40, "3.2.0-rc.1" return {"issuer": "https://token.actions.githubusercontent.com", diff --git a/tests/Unit/Support/pip-sinks.json b/tests/Unit/Support/pip-sinks.json index 6ab5d54c3..834574b2c 100644 --- a/tests/Unit/Support/pip-sinks.json +++ b/tests/Unit/Support/pip-sinks.json @@ -63,9 +63,14 @@ { "path": ".github/workflows/phpunit-feature.yml", "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", - "count": 1, - "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps. At the policy's reviewed preflight step.", + "count": 2, + "confinement": "The accepted central clean-trust action installs hash-pinned pip 26.2.2 and exports its checked bundle with an empty SSL_CERT_DIR as PIP_CERT and SSL_CERT_FILE. Both the policy preflight and bounded SBOM source-test job use this exact action.", "guards": [ + { + "type": "accepted-action", + "line": null, + "distance": null + }, { "type": "accepted-action", "line": null, @@ -516,6 +521,19 @@ "distance": 1 } ] + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "python -I -m pip install --no-deps --require-hashes --only-binary=:all:", + "count": 1, + "confinement": "The earlier central clean-trust action exports the checked CA pair. Isolated pip installs only five exact wheel URLs with required hashes and no dependency solve for synthetic source tests; this is not the confined generator validator or an execution receipt.", + "guards": [ + { + "type": "bootstrap", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", + "distance": 3 + } + ] } ], "isolation_exceptions": [ From d417a3cc23f14b84d361c9aa04995c903ee259a3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:11:18 +0000 Subject: [PATCH 21/22] Keep promotion tests independent of SBOM test dependencies --- .github/workflows/native-promotion-contracts.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml index c61550567..319db535c 100644 --- a/.github/workflows/native-promotion-contracts.yml +++ b/.github/workflows/native-promotion-contracts.yml @@ -10,7 +10,8 @@ on: - scripts/ci/select-published-compose-image.sh - scripts/smoke-published-compose.sh - scripts/conformance/server-parity/native-image-corpus.sh - - tests/Unit/Support/native_*test.py + - tests/Unit/Support/native_alias_*test.py + - tests/Unit/Support/native_qualification_test.py - .github/workflows/native-*.yml - .github/workflows/published-compose.yml permissions: @@ -24,6 +25,8 @@ jobs: with: persist-credentials: false - name: Countercheck provider receipts, cleanup and fail-before writes - run: python3 -m unittest discover -s tests/Unit/Support -p 'native_*test.py' -v + run: | + python3 -m unittest discover -s tests/Unit/Support -p 'native_qualification_test.py' -v + python3 -m unittest discover -s tests/Unit/Support -p 'native_alias_*test.py' -v - name: Preserve release, candidate and monotonic alias contracts run: bash scripts/ci/check-native-release-tags.sh From a516260529673ab98d9fc14d9dc3e9167fef57b1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:12:22 +0000 Subject: [PATCH 22/22] Clarify the SBOM development tooling and validator admission scopes --- scripts/ci/native-sbom/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/ci/native-sbom/README.md b/scripts/ci/native-sbom/README.md index b1ac1ed5c..0db9c9d2d 100644 --- a/scripts/ci/native-sbom/README.md +++ b/scripts/ci/native-sbom/README.md @@ -52,7 +52,10 @@ They do not authorize a download or install. Python's observed image user is unset/root; future confinement must explicitly enforce a nonroot UID, read-only filesystem, bounded scratch/output/resources, no capabilities, no-new-privileges, no network, daemon socket or host credential mounts. That execution source is -still incomplete. Neither image layers nor wheels were acquired or executed. +still incomplete. The original metadata checkpoint acquired neither image +layers nor wheels. The ordinary development tests described below may install +the hash-closed wheels as test tooling; the confined validator's installation +and execution admission fields remain false. The candidate also records its installer's source identity. The exact official Python build enables `ensurepip`; the signed CPython 3.13.18 tag's immutable