From 6f2079f4c44948208d8c05b68a919f0174b2b340 Mon Sep 17 00:00:00 2001 From: Allen Byrd <125306425+allenfbyrd@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:51:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Chore(hygiene):=20Four=20v0.11=20H6=20defer?= =?UTF-8?q?rals=20=E2=80=94=20#18=20advisory=20diff,=20SI=20schema=20gate,?= =?UTF-8?q?=20demo=20hash-pin,=20cflite=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small deferred-queue items from the v0.10.17 close-out, each independently verified: 1. #18 changelog-vs-commits ADVISORY diff: scripts/check_changelog_vs_commits.py — at release-prep time (version bumped, not yet tagged) lists commits since the last release tag whose (#NNN) PR ref the new CHANGELOG block never mentions. Always exits 0 (a curated block folds/omits deliberately; entry-COMPLETE cannot be a hard gate without lying). Wired into verify-changelog.yml (checkout gains fetch-depth: 0 for the tag baseline; script fail-softs on shallow clones). Self-skips outside release-prep — verified live (v0.10.17 tagged -> skip). 13 unit tests. 2. security-insights.yml CI-validate gate: the spec publishes CUE (not JSON Schema) and si-tooling v2 is a Go library, so the gate is 'cue vet -d #SecurityInsights' with cue v0.17.0 (sha256- pinned install) against a vendored, byte-identical copy of spec/schema.cue @ ossf/security-insights-spec v2.2.0 (.github/schemas/, sha256 in the workflow comment). Wired into verify-osps-conformance.yml. OBSERVED FIRING first (lesson 9): the real manifest failed vet — security.tools[].rulesets is REQUIRED — fixed honestly (osv-scanner.toml allowlist / dependabot.yml named as the customized rulesets) + last-updated bumped; now vets clean. 3. Demo-image pyyaml hash-pin (alert #180, precedent: the main Dockerfile's --require-hashes closure): committed docker/demo/requirements.txt with PyPI-sourced sha256 hashes (cp313 manylinux x86_64 + aarch64 + sdist); Dockerfile.demo installs with --require-hashes. Smoke-built locally against python:3.13-slim: build green, sh + 'collect okta' refusals still exit 2 per the run contract. 4. cflite-batch.yml timeout-minutes: 60 (PR #172 rationale: 1800s fuzz budget + build time fits well under 60; a wedged daily run must not burn the 6-hour default). zizmor + audit_workflow_permissions --strict clean across all touched workflows. --- .github/schemas/security-insights-v2.2.0.cue | 351 ++++++++++++++++++ .github/workflows/cflite-batch.yml | 5 + .github/workflows/verify-changelog.yml | 18 + .github/workflows/verify-osps-conformance.yml | 27 ++ docker/Dockerfile.demo | 9 +- docker/demo/requirements.txt | 24 ++ scripts/check_changelog_vs_commits.py | 169 +++++++++ security-insights.yml | 8 +- tests/unit/test_check_changelog_vs_commits.py | 121 ++++++ 9 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 .github/schemas/security-insights-v2.2.0.cue create mode 100644 docker/demo/requirements.txt create mode 100644 scripts/check_changelog_vs_commits.py create mode 100644 tests/unit/test_check_changelog_vs_commits.py diff --git a/.github/schemas/security-insights-v2.2.0.cue b/.github/schemas/security-insights-v2.2.0.cue new file mode 100644 index 00000000..15b2815d --- /dev/null +++ b/.github/schemas/security-insights-v2.2.0.cue @@ -0,0 +1,351 @@ +package spec + +import ( + "time" +) + +@go("si") + +// URL is a TLS URL +#URL: =~"^https?://[^\\s]+$" + +// Email is a valid email address +#Email: =~"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + +// Date is a date in the format YYYY-MM-DD +#Date: time.Format("2006-01-02") + +// SchemaVersion is a version string in the format X.Y.Z +#SchemaVersion: =~"^[1-9]+\\.[0-9]+\\.[0-9]+$" + +// Assessment represents the results of a security assessment, including comments, evidence, and date. +#Assessment: { + + // Notes or commentary about the findings or purpose of the assessment. + comment: string + + // The name or identifier of the assessment artifact. + name?: string @go(Name,type=*string) + + // The URL where the assessment report or artifact is located. + evidence?: #URL @go(Evidence,type=*URL) + + // The date the assessment was published. + date?: #Date +} + +// Attestation describes an [in-toto attestation](https://github.com/in-toto/attestation/blob/main/spec/README.md#in-toto-attestation-framework-spec), including its name, location, predicate URI, and any additional comments. +#Attestation: { + + // The name or identifier of the attestation. + name: string + + // A web location where the attestation can be found. + location: #URL + + // A URI to a resource describing the attestation’s predicate or specification. + "predicate-uri": string @go(PredicateURI) + + // Additional context or instructions for using the attestation. + comment?: string @go(Comment,type=*string) +} + +// Contact represents a person or entity responsible for the project, including their name, affiliation, and contact details. +#Contact: { + + // The contact person's name. + name: string + + // Indicates whether this admin is the first point of contact for inquiries. Only one entry should be marked as primary. + primary: bool + + // The entity with which the contact is affiliated, such as a school or employer. + affiliation?: string @go(Affiliation,type=*string) + + // A preferred email address to reach the contact. + email?: #Email @go(Email,type=*Email) + + // A social media handle or profile for the contact. + social?: string @go(Social,type=*string) +} + +#License: { + + // A web address where the license can be found. + url: #URL + + // The SPDX license expression for the license. + expression: string +} + +#Link: { + + // A link to a resource, not restricted to http/s. + uri: string + + // Instructions or information about the link. + comment: string +} + +// The ProjectRepository object describes a repository that is part of a project, including its name, comment, and URL. +#ProjectRepository: { + + // The name or short label of the repository. + name: string + + // Explanation of the repository purpose or contents and its relation to the rest of the project. + comment: string + + // The URL where the repository is hosted. + url: #URL +} + +// SecurityInsights defines a schema that projects can use to report information about their security in a machine-processable way. The data tracked within this specification is intended to fill the gaps between simplified solutions such as SECURITY.md and comprehensive automated solutions such as SBOMs. In that gap lay elements that must be self-reported by projects to allow end-users to make informed security decisions. +#SecurityInsights: { + + // header captures high level metadata about the schema. + header: #Header + + // project describes the overall project, including basic info, documentation links, repositories, vulnerability reporting, and security details. This field is not required if `header.project-si-source` is supplied. + project?: #Project + + // repository describes repository-related configurations, including status, policies, team members, documentation, license, releases, and security posture. This field is not required if `header.project-si-source` is supplied. This field is required if the file is intended for use as a parent security insights file with project information to be inherited by multiple repositories via their respective `header.project-si-source`. + repository?: #Repository +} + +// The Header object captures high-level metadata about the schema. +#Header: { + + // The date when the document or data was last reviewed. + "last-reviewed": #Date @go(LastReviewed) + + // The date when the document or data was last updated. + "last-updated": #Date @go(LastUpdated) + + // The version of the Security Insights schema being used. + "schema-version": #SchemaVersion @go(SchemaVersion) + + // The original URL for the current Security Insights file. This should point to the canonical location where the file is hosted (e.g., a raw file URL in a version control system). This helps preserve context when the file is extracted from its source. This may also be referenced to help readers find the latest version of the security insights file, in the event that they are accessing it from an outdated source, such as a past release artifact. This is not to be confused with the URL for the project. Project URLs should be specified in `project.repositories` instead. + url: #URL @go(URL) + + // Additional information about the schema. + comment?: string @go(Comment,type=*string) + + // A URL to the security insights file that contains project information for this file to inherit. The URL provided here should respond to an unauthenticated GET request and return a valid security insights file using a content-type of "text/plain" or "application/yaml". This is useful for projects that are part of a larger organization or ecosystem, where much of the security insights data is shared across multiple projects. + "project-si-source"?: #URL @go(ProjectSISource,type=*URL) +} + +// ProjectDocumentation contains links to various documents related to the project, including detailed guides, code of conduct, quickstart guides, release processes, support policies, and signature verification. +#ProjectDocumentation: { + // URL to design documentation for this project. + design?: #URL @go(Design,type=*URL) + + // URL to more extensive or advanced documentation. + "detailed-guide"?: #URL @go(DetailedGuide,type=*URL) + + // URL to the document outlining contributor and user conduct guidelines. + "code-of-conduct"?: #URL @go(CodeOfConduct,type=*URL) + + // URL to a concise guide to basic functionality for new users. + "quickstart-guide"?: #URL @go(QuickstartGuide,type=*URL) + + // URL describing how releases are planned, prepared, and published. + "release-process"?: #URL @go(ReleaseProcess,type=*URL) + + // URL to documentation describing how releases are supported. See [Recommendations for publishing End-of-life dates and support timelines](https://endoflife.date/recommendations) for best practices. + "support-policy"?: #URL @go(SupportPolicy,type=*URL) + + // URL to documentation explaining how to verify digital signatures on assets. + "signature-verification"?: #URL @go(SignatureVerification,type=*URL) +} + +// VulnerabilityReporting describes how security vulnerabilities can be reported and how they are handled by the project. +#VulnerabilityReporting: { + // Indicates whether this project currently accepts vulnerability reports. + "reports-accepted": bool @go(ReportsAccepted) + + // Specifies whether a bug bounty program is offered. + "bug-bounty-available": bool @go(BugBountyAvailable) + + // Path to a page providing details about any bug bounty program. + "bug-bounty-program"?: #URL @go(BugBountyProgram,type=*URL) + + // Point of contact for reporting vulnerabilities. This may be a single person or a mailgroup. + contact?: #Contact @go(Contact,type=*Contact) + + // Additional comments or instructions about vulnerability reporting. + comment?: string @go(Comment,type=*string) + + // Path to a page containing rules for security-related disclosures. + "policy"?: #URL @go(SecurityPolicy,type=*URL) + + // The PGP public key for secure communication. + "pgp-key"?: string @go(PGPKey,type=*URL) + + // A list of issues or components that are covered by the vulnerability reporting process. + "in-scope"?: [...string] @go(InScope,type=*URL) + + // A list of issues or components not covered by the vulnerability reporting process. + "out-of-scope"?: [...string] @go(OutOfScope,type=*URL) +} + +// Project describes the overall project, including basic info, documentation links, repositories, vulnerability reporting, and security details. +#Project: { + + // The name of the project. + name: string + + // A path to the project’s landing page. This may be a project website, a version control system repository, or a project/organization page in the VCS. + homepage?: #URL @go(HomePage,type=*URL) + + // A URL pointing to a roadmap or schedule for planned features and releases. + roadmap?: #URL @go(Roadmap,type=*URL) + + // A URL to information about sponsorships, donations, or other funding topics. + funding?: #URL @go(Funding,type=*URL) + + // This field is to communicate the relationship between the project and "a legal person, other than a manufacturer, that has the purpose or objective of systematically providing support on a sustained basis for the development of specific products with digital elements, qualifying as free and open-source software and intended for commercial activities, and that ensures the viability of those products" This definition is drawn from the [European Union Cyber Resilience Act, Article 3](https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng#art_3). + steward?: #Link @go(Steward,type=*Link) + + // A list of 1 or more individuals who have administrative access to the project's resources. + administrators: [#Contact, ...] @go(,type=[]Contact) + + // A list of 1 or more repositories that are part of this project, including the repository this file is published in. + repositories: [#ProjectRepository, ...] @go(Repositories,type=[]ProjectRepository) + + // An object describing how security vulnerabilities can be reported and how they are handled by the project. + "vulnerability-reporting": #VulnerabilityReporting @go(VulnerabilityReporting) + + // the project's documentation resources + documentation?: #ProjectDocumentation @go(Documentation,type=*ProjectDocumentation) +} + +// SecurityToolIntegration describes how a security tool is integrated into the repository, including whether it is used in scheduled processes, continuous integration, or during the release process. +#SecurityToolIntegration: { + // Indicates whether the tool is used in a scheduled process or supports an on-demand. + adhoc: bool + + // Indicates whether the tool is used in the continuous integration process. + ci: bool + + // Indicates whether the tool is run before or during the release process. + release: bool +} + +// SecurityToolResults describes the results of security scans, including those run on-demand, in continuous integration, and during the release process. +#SecurityToolResults: { + // Results of scheduled or on-demand security scans. + adhoc?: #Attestation @go(Adhoc,type=*Attestation) + + // Results of security scans run in the continuous integration process. + ci?: #Attestation @go(CI,type=*Attestation) + + // Results of security scans run in the build and release process. + release?: #Attestation @go(Release,type=*Attestation) +} + +// SecurityTool describes a security-related tool used in the repository, including its name, type, version, rulesets, integration details, and results. +#SecurityTool: { + // The name of the tool. + name: string + + // The general category or type of the tool. + type: "fuzzing" | "container" | "secret" | "SCA" | "SAST" | "other" + + // The version of the tool that is used. + version?: string @go(Version,type=*string) + + // Additional notes about the tool’s usage or configuration. + comment?: string @go(Comment,type=*string) + + // The set of rules or configurations applied by the tool. If customization is not enabled, the only value here should be "default". + rulesets: ["default"] | [...string] @go(,type=[]string) + + // An object describing how the tool is integrated with the project. + integration: #SecurityToolIntegration @go(Integration) + + results: #SecurityToolResults @go(Results) +} + +// SecurityPosture describes the security posture of the repository, including assessments, champions, and tools. +#SecurityPosture: { + // An object describing security assessments for the repository. + assessments: { + // Results of the contributor team's assessment of software produced by this repository. + self: #Assessment + + // Results of third-party assessments of software produced by this repository. + "third-party"?: [...#Assessment] @go(ThirdPartyAssessment) + } + + // A list of core team members who advocate for continuous improvement of security practices. These individuals may take responsibility for security reviews, training, interfacing with stakeholders on security topics, or other similar activities. + champions?: [...#Contact] + + // A list of objects describing security-related tools used in the repository. + tools?: [...#SecurityTool] @go(Tools,type=[]SecurityTool) +} + +// RepositoryDocumentation contains links to various documents related to the repository, including contributing guides, dependency management policies, governance documents, and review policies. +#RepositoryDocumentation: { + // URL to a document outlining the process for contributing to the repository. + "contributing-guide"?: #URL @go(ContributingGuide,type=*URL) + // URL to a document outlining the process for managing dependencies in the repository. + "dependency-management-policy"?: #URL @go(DependencyManagementPolicy,type=*URL) + // URL to any governance documents regarding roles, responsibilities, processes, and decision-making. + governance?: #URL @go(Governance,type=*URL) + // URL to a document outlining the process for reviewing changes to the repository. + "review-policy"?: #URL @go(ReviewPolicy,type=*URL) + // URL with information about the repository's security, including the policy for reporting security vulnerabilities. + "security-policy"?: #URL @go(SecurityPolicy,type=*URL) +} + +// ReleaseDetails describes the release process for the repository, including automated pipelines, distribution points, changelogs, licenses, and attestations. +#ReleaseDetails: { + // Indicates if the repository uses an automated release pipeline. + "automated-pipeline": bool @go(AutomatedPipeline) + // A list of 1 or more links describing where the repository’s releases are distributed. This may be the VCS releases page, a package manager, or other distribution points. + "distribution-points": [#Link, ...] @go(DistributionPoints,type=[]Link) + // A URL to the repository’s release changelog. The URL value should include placeholders such as `{version}` if relevant. + changelog?: #URL @go(Changelog,type=*URL) + // Describes the license details specifically for releases. This should be used when the release license differs from the repository license. + license?: #License @go(License,type=*License) + // List of attestations for the repository’s releases. + attestations?: [...#Attestation] +} + +// The Repository object specifies repository-related configurations, including status, policies, team members, documentation, license, releases, and security posture. +#Repository: { + + // Indicates the repository’s current [Repo Status](https://repostatus.org). + status: "active" | "abandoned" | "concept" | "inactive" | "moved" | "suspended" | "unsupported" | "WIP" + + // The main URL for this repository. + url: #URL + + // Indicates whether the repository currently accepts any change requests. + "accepts-change-request": bool @go(AcceptsChangeRequest) + + // Indicates whether the repository accepts automated or machine-generated change requests. + "accepts-automated-change-request": bool @go(AcceptsAutomatedChangeRequest) + + // Specifies whether the repository only accepts bug-fixes and not feature work. + "bug-fixes-only"?: bool @go(BugFixesOnly) + + // Indicates whether the repository universally avoids package dependencies from outside of the project. + "no-third-party-packages"?: bool @go(NoThirdPartyPackages) + + // A list of 1 or more core team members for this repository, such as maintainers or approvers. + "core-team": [#Contact, ...] @go(CoreTeam,type=[]Contact) + + // The license information for this repository. + license: #License + + // An object describing security-related artifacts, champions, and tooling for the repository. + security: #SecurityPosture @go(SecurityPosture) + + // Documentation links for the repository, including links to contributing guides, dependency management policies, governance documents, and review policies. + documentation?: #RepositoryDocumentation @go(Documentation,type=*RepositoryDocumentation) + + // Release describes the release process for the repository. + release?: #ReleaseDetails @go(ReleaseDetails,type=*ReleaseDetails) +} diff --git a/.github/workflows/cflite-batch.yml b/.github/workflows/cflite-batch.yml index 95e8cb7c..21499f03 100644 --- a/.github/workflows/cflite-batch.yml +++ b/.github/workflows/cflite-batch.yml @@ -28,6 +28,11 @@ permissions: read-all jobs: batch-fuzzing: runs-on: ubuntu-latest + # Runtime cap (same rationale as the dast/codspeed caps, PR #172): the + # fuzz budget is fuzz-seconds=1800 (30 min) + fuzzer build time, so a + # healthy run fits well under 60 minutes — a hung build or a wedged + # fuzzer should not burn the 6-hour default ceiling on a DAILY cron. + timeout-minutes: 60 steps: - name: Build Fuzzers id: build diff --git a/.github/workflows/verify-changelog.yml b/.github/workflows/verify-changelog.yml index 9ececacf..e941723e 100644 --- a/.github/workflows/verify-changelog.yml +++ b/.github/workflows/verify-changelog.yml @@ -31,6 +31,7 @@ on: - "pyproject.toml" - "CHANGELOG.md" - "scripts/extract_changelog_block.py" + - "scripts/check_changelog_vs_commits.py" - ".github/workflows/verify-changelog.yml" pull_request: branches: [main] @@ -38,6 +39,7 @@ on: - "pyproject.toml" - "CHANGELOG.md" - "scripts/extract_changelog_block.py" + - "scripts/check_changelog_vs_commits.py" - ".github/workflows/verify-changelog.yml" # Allow manual re-runs from the Actions UI for diagnostic purposes. workflow_dispatch: @@ -53,6 +55,11 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + # Full history + tags for the ADVISORY commits-vs-CHANGELOG diff + # below (it needs the previous release tag as the log baseline). + # The presence gate itself needs no history; the advisory script + # fail-softs to a skip if tags are ever absent. + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -110,3 +117,14 @@ jobs: echo "::notice::CHANGELOG block for [$version] looks healthy ($body_size bytes)." env: STEPS_WORKSPACE_VERSION_OUTPUTS_VERSION: ${{ steps.workspace-version.outputs.version }} + + # v0.11 H6 (#18): purely ADVISORY diff — at release-prep time (version + # bumped, not yet tagged), list commits since the last release tag + # whose (#NNN) PR ref the new CHANGELOG block never mentions (the + # shape of a shipped-but-undocumented change). ALWAYS exits 0: a + # curated block may fold or omit deliberately (entry-COMPLETE cannot + # be a hard gate without lying); the value is the visible checklist + # in the release-prep PR's CI log. On non-release changes the script + # self-skips (version already tagged). + - name: Advisory — commits since last tag vs the CHANGELOG block + run: python scripts/check_changelog_vs_commits.py diff --git a/.github/workflows/verify-osps-conformance.yml b/.github/workflows/verify-osps-conformance.yml index 364d1594..1b438da7 100644 --- a/.github/workflows/verify-osps-conformance.yml +++ b/.github/workflows/verify-osps-conformance.yml @@ -91,3 +91,30 @@ jobs: else echo "::warning::.local/pre-release-review/osps-conformance.yaml not present (gitignored — won't exist on fresh clones). Skipping schema check." fi + + # v0.11 H6: gate security-insights.yml against the OFFICIAL OpenSSF + # Security Insights v2.2.0 schema, so a manifest edit (or a stale field) + # that stops conforming fails PR CI instead of silently degrading what + # the OSPS Baseline tooling / CLOMonitor / LFX Insights can read. The + # spec publishes CUE, not JSON Schema — the vendored copy at + # .github/schemas/security-insights-v2.2.0.cue is byte-identical to + # spec/schema.cue at ossf/security-insights-spec tag v2.2.0 + # (sha256 4e4b61470b5484fc8969f2a8a77ffbe0712a7c8a870c2079188f246c14cd58b1); + # bump the vendored file together with header.schema-version. First + # local vet fired RED on the real manifest (missing required + # security.tools[].rulesets) before the fix — the gate demonstrably + # checks something. + - name: Install cue (v0.17.0, checksum-verified) + run: | + curl -sSfL https://github.com/cue-lang/cue/releases/download/v0.17.0/cue_v0.17.0_linux_amd64.tar.gz \ + -o /tmp/cue.tar.gz + echo "e22219a1cb520ab3d660a486ed2222bb7b8e9cb9502a951ce6b8ac668695713f /tmp/cue.tar.gz" | sha256sum -c - + mkdir -p "$HOME/.local/bin" + tar -xzf /tmp/cue.tar.gz -C "$HOME/.local/bin" cue + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Validate security-insights.yml against the Security Insights v2.2.0 schema + run: | + cue vet -d '#SecurityInsights' \ + .github/schemas/security-insights-v2.2.0.cue security-insights.yml + echo "security-insights.yml conforms to Security Insights v2.2.0." diff --git a/docker/Dockerfile.demo b/docker/Dockerfile.demo index 311a4e19..42dac826 100644 --- a/docker/Dockerfile.demo +++ b/docker/Dockerfile.demo @@ -112,8 +112,13 @@ COPY docker/demo/launch.py /opt/evidentia-demo/launch.py # base-agnostic — the interpreter is Python 3.13 on both the slim and DHI bases, # and safe_load never touches the libyaml C extension. The runner's scrubbed # subprocess omits PYTHONPATH, so the real `evidentia` binary uses the base's own -# installed pyyaml, unaffected by this overlay copy. -RUN pip install --no-cache-dir --target /opt/evidentia-demo "pyyaml==6.0.2" +# installed pyyaml, unaffected by this overlay copy. Hash-pinned via the +# committed requirements file (alert #180): --require-hashes makes pip verify +# the downloaded artifact's sha256, so a registry-side substitution of the +# pinned version fails the build (same mechanism as the main Dockerfile). +COPY docker/demo/requirements.txt /tmp/demo-requirements.txt +RUN pip install --no-cache-dir --require-hashes --target /opt/evidentia-demo \ + -r /tmp/demo-requirements.txt # ── final: FROM the (distroless) signed release base — NO shell, no RUN ─────── # BASE is the global ARG declared above the assets stage (a non-resolvable diff --git a/docker/demo/requirements.txt b/docker/demo/requirements.txt new file mode 100644 index 00000000..88786dfa --- /dev/null +++ b/docker/demo/requirements.txt @@ -0,0 +1,24 @@ +# Hash-pinned requirement for the demo image's vendored pyyaml overlay +# (Dockerfile.demo `assets` stage). Closes the pinned-dependencies gap on +# the demo image's only network install (code-scanning alert #180, demo +# pip install pinned by version but not hash; same mechanism as +# .github/workflows/requirements/verify-osps-conformance.txt and the main +# Dockerfile's pip-compile --generate-hashes closure): with +# `--require-hashes`, pip verifies each downloaded artifact's sha256 +# before installation, so a registry-side substitution of the pinned +# version fails the build instead of landing in the image. +# +# Hashes sourced from pypi.org/pypi/pyyaml/6.0.2/json on 2026-07-10. +# Wheels cover the builder stage's python:3.13-slim on linux/amd64 and +# linux/arm64 (Docker Desktop on Apple silicon); the sdist hash is the +# fallback for any other platform. +# +# When bumping the pyyaml version, re-fetch hashes from PyPI: +# curl -s https://pypi.org/pypi/pyyaml//json | jq -r '.urls[] +# | select(.filename | endswith(".tar.gz") or +# (contains("cp313") and contains("manylinux_2_17"))) | +# "\(.filename) \(.digests.sha256)"' +pyyaml==6.0.2 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e diff --git a/scripts/check_changelog_vs_commits.py b/scripts/check_changelog_vs_commits.py new file mode 100644 index 00000000..b688ef68 --- /dev/null +++ b/scripts/check_changelog_vs_commits.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Advisory diff: commit subjects since the last tag vs the CHANGELOG block (#18). + +The changelog-presence gate (verify-changelog.yml / release.yml) proves a +``## [X.Y.Z]`` block EXISTS and is non-trivial; nothing checks it is +COMPLETE. This advisory closes that narrow gap at release-prep time: when +the workspace version X.Y.Z is bumped but not yet tagged, it lists every +commit landed since the previous release tag whose PR number (the +``(#NNN)`` suffix every merge-queue squash subject carries) is not +mentioned anywhere in the ``[X.Y.Z]`` CHANGELOG block — the shape of a +shipped-but-undocumented change. + +PURELY ADVISORY (elite-practice scan #18): always exits 0 on findings — +a hand-curated CHANGELOG legitimately folds several PRs into one bullet +or omits mechanical ones (Dependabot bumps), so a hard gate here would +either lie or nag. The value is the visible checklist in the release-prep +PR's CI log. Deliberately NOT a Conventional-Commits/git-cliff adoption: +release orchestration stays with the atomic tag-driven design. + +Fail-soft (lesson 2): no tags visible (shallow clone), no git, an +already-tagged version (nothing being prepped), or a missing block (the +presence gate's job) all mean "nothing to compare" — exit 0 with a note. +Exit 1 only on internal errors. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - py<3.11 unsupported anyway + sys.exit("python >= 3.11 required (tomllib)") + +REPO_ROOT = Path(__file__).resolve().parents[1] + +_PR_REF_RE = re.compile(r"\(#(\d+)\)") +_BLOCK_HEADING_RE = "## [" + + +def workspace_version(pyproject_path: Path) -> str | None: + """The root [project] version — what the next tag will publish.""" + try: + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + version = data["project"]["version"] + except (OSError, tomllib.TOMLDecodeError, KeyError): + return None + return version if isinstance(version, str) else None + + +def changelog_block(changelog_text: str, version: str) -> str | None: + """The ``## [version]`` block's text (to the next ``## [`` heading).""" + heading = f"## [{version}]" + start = changelog_text.find(heading) + if start == -1: + return None + end = changelog_text.find(_BLOCK_HEADING_RE, start + len(heading)) + return changelog_text[start : end if end != -1 else len(changelog_text)] + + +def pr_number(subject: str) -> str | None: + """The trailing ``(#NNN)`` PR ref of a squash-merge subject, if any.""" + matches = _PR_REF_RE.findall(subject) + return matches[-1] if matches else None + + +def compare(subjects: list[str], block: str) -> tuple[list[str], list[str]]: + """Return (subjects whose PR ref is absent from the block, + subjects carrying no PR ref at all).""" + missing: list[str] = [] + unreferenced: list[str] = [] + for subject in subjects: + pr = pr_number(subject) + if pr is None: + unreferenced.append(subject) + elif f"#{pr}" not in block: + missing.append(subject) + return missing, unreferenced + + +def _git(*args: str) -> str | None: + """Run git in the repo root; None on any failure (fail-soft).""" + try: + proc = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + return proc.stdout + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.parse_args(argv) + + version = workspace_version(REPO_ROOT / "pyproject.toml") + if version is None: + print("WARN: could not read the workspace version — skipping", file=sys.stderr) + return 0 + + if (_git("tag", "-l", f"v{version}") or "").strip(): + print( + f"check_changelog_vs_commits: v{version} is already tagged — " + "no release being prepped, nothing to compare." + ) + return 0 + + base = (_git("describe", "--tags", "--abbrev=0", "HEAD") or "").strip() + if not base: + print( + "WARN: no reachable release tag (shallow clone or no tags) — " + "skipping the advisory diff", + file=sys.stderr, + ) + return 0 + + log = _git("log", "--format=%s", f"{base}..HEAD") + if log is None: + print(f"WARN: git log {base}..HEAD failed — skipping", file=sys.stderr) + return 0 + subjects = [s for s in log.splitlines() if s.strip()] + + try: + changelog_text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + except OSError as exc: + print(f"WARN: cannot read CHANGELOG.md ({exc}) — skipping", file=sys.stderr) + return 0 + block = changelog_block(changelog_text, version) + if block is None: + print( + f"check_changelog_vs_commits: no [{version}] block yet — the " + "changelog-presence gate owns that failure; nothing to compare." + ) + return 0 + + missing, unreferenced = compare(subjects, block) + print( + f"check_changelog_vs_commits: {len(subjects)} commit(s) in " + f"{base}..HEAD vs the [{version}] block:" + ) + for subject in missing: + print(f" ADVISORY: PR not mentioned in [{version}]: {subject}") + for subject in unreferenced: + print(f" note (no PR ref, direct commit?): {subject}") + if not missing and not unreferenced: + print(" every commit's PR ref appears in the block.") + else: + print( + f" {len(missing)} PR(s) unmentioned, {len(unreferenced)} " + "subject(s) without a PR ref. ADVISORY ONLY — a curated block " + "may fold or omit deliberately; review, don't chase zero." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/security-insights.yml b/security-insights.yml index 89a64334..15dd95e2 100644 --- a/security-insights.yml +++ b/security-insights.yml @@ -1,7 +1,7 @@ header: schema-version: 2.2.0 - last-updated: '2026-06-26' - last-reviewed: '2026-06-26' + last-updated: '2026-07-10' + last-reviewed: '2026-07-10' url: https://github.com/Polycentric-Labs/evidentia/raw/main/security-insights.yml comment: | Single-repository OpenSSF Security Insights manifest for Evidentia, an @@ -83,6 +83,8 @@ repository: tools: - name: osv-scanner type: SCA + rulesets: + - osv-scanner.toml (repo-root allowlist; every IgnoredVulns entry carries a security-review-traceable reason + ignoreUntil expiry) integration: adhoc: false ci: true @@ -91,6 +93,8 @@ repository: SBOM-based SCA gate run in CI and at release time (release.yml). - name: Dependabot type: SCA + rulesets: + - .github/dependabot.yml (grouped updates, cooldown, python-framework isolation) integration: adhoc: false ci: true diff --git a/tests/unit/test_check_changelog_vs_commits.py b/tests/unit/test_check_changelog_vs_commits.py new file mode 100644 index 00000000..bf789db0 --- /dev/null +++ b/tests/unit/test_check_changelog_vs_commits.py @@ -0,0 +1,121 @@ +"""Tests for scripts/check_changelog_vs_commits.py (#18 advisory diff).""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import check_changelog_vs_commits as adv # noqa: E402 + +_CHANGELOG = """# Changelog + +## [0.11.0] - 2026-08-01 + +### Added + +- Doc-currency gate (#175) and the dependency-review PR check (#176). + +## [0.10.17] - 2026-07-09 + +- Older block referencing (#173). +""" + + +class TestChangelogBlock: + def test_slices_exactly_one_block(self) -> None: + block = adv.changelog_block(_CHANGELOG, "0.11.0") + assert block is not None + assert "#175" in block and "#176" in block + assert "#173" not in block # next block excluded + + def test_last_block_runs_to_eof(self) -> None: + block = adv.changelog_block(_CHANGELOG, "0.10.17") + assert block is not None and "#173" in block + + def test_missing_version_returns_none(self) -> None: + assert adv.changelog_block(_CHANGELOG, "9.9.9") is None + + +class TestPrNumber: + def test_squash_subject(self) -> None: + assert adv.pr_number("Feat(gates): Open the v0.11 cycle (#175)") == "175" + + def test_takes_the_last_ref(self) -> None: + assert adv.pr_number("Revert (#100) follow-up (#101)") == "101" + + def test_no_ref(self) -> None: + assert adv.pr_number("Direct commit without a PR") is None + + +class TestCompare: + def test_partitions_missing_and_unreferenced(self) -> None: + block = adv.changelog_block(_CHANGELOG, "0.11.0") + assert block is not None + subjects = [ + "Feat(gates): Open the v0.11 cycle (#175)", # mentioned + "Ci(deps): Add dependency-review gate (#176)", # mentioned + "Fix(api): Undocumented fix (#199)", # NOT mentioned + "Chore: direct commit", # no PR ref + ] + missing, unreferenced = adv.compare(subjects, block) + assert missing == ["Fix(api): Undocumented fix (#199)"] + assert unreferenced == ["Chore: direct commit"] + + def test_all_mentioned_is_clean(self) -> None: + block = adv.changelog_block(_CHANGELOG, "0.11.0") + assert block is not None + missing, unreferenced = adv.compare( + ["Feat(gates): Open the v0.11 cycle (#175)"], block + ) + assert missing == [] and unreferenced == [] + + +class TestWorkspaceVersion: + def test_reads_real_workspace_version(self) -> None: + version = adv.workspace_version(REPO_ROOT / "pyproject.toml") + assert version and version.count(".") == 2 + + def test_fail_soft_on_missing_file(self, tmp_path: Path) -> None: + assert adv.workspace_version(tmp_path / "nope.toml") is None + + +class TestMainFailSoft: + def test_already_tagged_version_skips(self, monkeypatch, capsys) -> None: + monkeypatch.setattr(adv, "workspace_version", lambda _p: "0.10.17") + monkeypatch.setattr(adv, "_git", lambda *a: "v0.10.17\n") + assert adv.main([]) == 0 + assert "already tagged" in capsys.readouterr().out + + def test_no_tags_visible_skips(self, monkeypatch, capsys) -> None: + monkeypatch.setattr(adv, "workspace_version", lambda _p: "0.11.0") + monkeypatch.setattr(adv, "_git", lambda *a: "") + assert adv.main([]) == 0 + assert "skipping" in capsys.readouterr().err + + def test_release_prep_reports_advisories(self, monkeypatch, capsys, tmp_path) -> None: + def fake_git(*args: str) -> str: + if args[0] == "tag": + return "" # v0.11.0 not tagged yet + if args[0] == "describe": + return "v0.10.17\n" + if args[0] == "log": + return ( + "Feat(gates): Open the v0.11 cycle (#175)\n" + "Fix(api): Undocumented fix (#199)\n" + ) + raise AssertionError(f"unexpected git call: {args}") + + monkeypatch.setattr(adv, "workspace_version", lambda _p: "0.11.0") + monkeypatch.setattr(adv, "_git", fake_git) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_CHANGELOG, encoding="utf-8") + monkeypatch.setattr(adv, "REPO_ROOT", tmp_path) + assert adv.main([]) == 0 # advisory NEVER fails + out = capsys.readouterr().out + assert "ADVISORY: PR not mentioned in [0.11.0]: Fix(api)" in out + advisory_line = next( + line for line in out.splitlines() if "ADVISORY" in line + ) + assert "#175" not in advisory_line From 5da3c01a6a4ee41d3a5f74c79aca70a45273a6ff Mon Sep 17 00:00:00 2001 From: Allen Byrd <125306425+allenfbyrd@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:56:01 -0400 Subject: [PATCH 2/2] Chore(hygiene): Waive check-workflow-tools false positive for cue The strict workflow-tools gate (CI-only) flagged cue as missing -- it is installed by the prior checksum-verified curl step in the same job; the release.yml-precedent '# tool-check: ok' waiver annotation records that. Checker re-run locally: 0 findings. --- .github/workflows/verify-osps-conformance.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/verify-osps-conformance.yml b/.github/workflows/verify-osps-conformance.yml index 1b438da7..cb7bfeef 100644 --- a/.github/workflows/verify-osps-conformance.yml +++ b/.github/workflows/verify-osps-conformance.yml @@ -115,6 +115,7 @@ jobs: - name: Validate security-insights.yml against the Security Insights v2.2.0 schema run: | + # tool-check: ok cue (installed by the prior curl step "Install cue" in this job) cue vet -d '#SecurityInsights' \ .github/schemas/security-insights-v2.2.0.cue security-insights.yml echo "security-insights.yml conforms to Security Insights v2.2.0."