Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,22 +117,20 @@ jobs:
if: steps.runtime.outputs.available == 'true'
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s integration_docker

# Advisory OSV-scanner sweep over the Python dependency lockfile (issue #34).
# Non-gating: CVE findings surface as an uploaded JSON artifact and never fail
# the build. The `osv_scan` nox session is kept out of the hermetic `verify`
# graph; genuine scanner/setup failures still fail this job's step (visible as
# a soft failure) so the advisory posture never hides a broken scan.
# Gating OSV-scanner sweep over the Python dependency lockfile (issue #1098).
# Kept out of the hermetic `verify` graph because the scanner is networked.
# Findings and scanner/setup errors both fail, with distinct diagnostics; the
# report upload remains unconditional so either failure is reviewable.
supply-chain:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8
- name: Run OSV-scanner (advisory)
- name: Run OSV-scanner (gating)
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s osv_scan
- name: Upload OSV-scanner report
if: always()
Expand Down
122 changes: 122 additions & 0 deletions docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Issue 1098 GOV-913 Supply-Chain Security Preflight

Date: 2026-08-11

Issue: #1098.

Requirement: GOV-913 (Trust And Integrity Of Reusable Assets).

This note records the review boundary and enforcement policy for repairing two
known vulnerabilities in the frozen Python dependency graph. It covers
repository dependencies and CI automation only. It does not change reusable
asset semantics, SDL, runtime trust policy, OCI publication, or release
automation.

## Findings And Fixed Floors

The audited `origin/dev` lock at commit
`5c210d520e884cca0c08ad201b033fae920ce0c2` resolved:

| Package | Locked finding | Affected surface | First fixed release |
| --- | --- | --- | --- |
| Click | `8.3.1`; PYSEC-2026-2132 / CVE-2026-7246 | `click.edit()` command launch could pass shell metacharacters to an OS shell | `8.3.3` |
| cryptography | `49.0.0`; GHSA-g6cj-pr64-35w5 / CVE-2026-69247 | PKCS#7 EnvelopedData RSA decryption exposed distinguishable errors and timing | `50.0.0` |

The project therefore declares `click>=8.3.3` and
`cryptography>=50.0.0`. The Click declaration is also dependency hygiene:
`raes_cli.semantic` imports Click directly, so relying only on Typer's
transitive declaration was insufficient.

Primary advisory and fix sources:

- <https://osv.dev/vulnerability/PYSEC-2026-2132>
- <https://github.com/pallets/click/releases/tag/8.3.3>
- <https://github.com/advisories/GHSA-g6cj-pr64-35w5>
- <https://github.com/pyca/cryptography/releases/tag/50.0.0>

## Reachability And Consumer Review

Reachability was reviewed to bound the incident, not to justify suppressing a
known-vulnerable package:

- OpenRAE's direct Click call is `click.get_binary_stream("stdin")`. No
`click.edit()` or pager call was found in production source. Typer and Uvicorn
are the other resolved Click consumers. Their published constraints accept
Click 8.3.3.
- OpenRAE directly uses cryptography for Ed25519 private-key loading, signing,
and verification. No `pkcs7_decrypt_der`, `pkcs7_decrypt_pem`,
`pkcs7_decrypt_smime`, or EnvelopedData call was found. AsyncSSH and the
PyJWT crypto extra are the other resolved consumers; their published
constraints accept cryptography 50.0.0.
- The cryptography advisory requires repeated decryption of attacker-supplied
EnvelopedData with observable outcomes. That route was not found in the
current OpenRAE call graph. This is an observed limit, not a universal
non-exploitability claim for every downstream deployment or future consumer.

The reviewed fixed releases support OpenRAE's Python `>=3.11` floor. The lock
refresh is intentionally limited to Click, cryptography, and resolver-required
metadata and artifacts, then checked with frozen reverse-dependency trees and a
live OSV scan.

## Existing Scanner Lineage

Issue #34 introduced the canonical scanner architecture:

- scan only `implementations/python/uv.lock`;
- acquire a pinned OSV-Scanner binary with release checksum verification;
- keep the networked scan outside the hermetic `verify` graph;
- write one ignored JSON report and upload it even after failure;
- distinguish vulnerability exit code 1 from scanner/setup errors.

Those decisions remain. Issue #34's initial advisory result policy is
superseded by issue #1098 because concrete vulnerable releases were able to
remain in the frozen lock without failing CI.

## Required Failure Contract

The scanner wrapper has one closed outcome classification:

| Exit result | Classification | CI behavior |
| --- | --- | --- |
| `0` | clean | pass |
| `1` | findings | fail with a vulnerability-specific diagnostic |
| any other value | scanner error | fail with the exact scanner/setup exit code |

The CI job must not use job-level or step-level `continue-on-error`. Its report
upload remains guarded by `if: always()` so both findings and tool failures
leave reviewable evidence. A scanner failure can never be interpreted as a
clean scan.

OSV remains a standalone CI gate rather than part of `verify`: the local proof
graph is designed to be hermetic, while OSV acquisition and advisory lookup are
network-dependent. See the official return-code contract at
<https://google.github.io/osv-scanner/output/#return-codes>.

## Alternatives Rejected

- **Suppress based on current reachability.** The vulnerable APIs are not
observed today, but the packages are shipped and remain callable by direct,
transitive, or future consumers. Suppression would make a clean-lock claim
false.
- **Refresh only the lock.** The old project constraints could select a
vulnerable release again, and Click's direct import would remain undeclared.
- **Raise floors but leave OSV advisory.** This repairs one snapshot without
preventing the same failure mode for later advisories.
- **Put OSV inside hermetic verification.** This would make ordinary local and
proof verification depend on external network availability. The dedicated
required CI job preserves the correct boundary.

## Verification And Nonclaims

Regression coverage checks the direct dependency floors, locked versions,
closed OSV exit classification, distinct nox failures, absence of CI soft-fail
configuration, and unconditional report upload. Focused CLI and registry
signing tests protect the two used dependency surfaces. The final review also
runs a live OSV scan, frozen dependency trees, Ruff, repository policy,
requirement governance, and the canonical verification graph.

This remediation does not claim that dependency scanning proves software
security, that unobserved vulnerable entry points are unreachable in every
downstream use, or that OSV availability is hermetic. It establishes the
narrower invariant that the reviewed frozen lock has no reported OSV findings
and that the required scan cannot fail silently.
38 changes: 38 additions & 0 deletions docs/decisions/issue-1106-gov-913-osv-cache-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Issue 1106 / GOV-913 OSV Scanner Cache Integrity

Date: 2026-08-11

Issue: #1106. Requirement: GOV-913. Related: #34 and #1098.

## Decision

The repository pins the official OSV-Scanner v2.4.0 SHA-256 for each admitted
Linux and macOS amd64/arm64 asset. Every `ensure_osv_scanner` call validates an
existing cache entry with `lstat`, accepts only a regular non-symlink executable,
opens the final component without following links where the host supports that
flag, verifies the opened file's identity with `fstat`, and hashes its complete
bounded bytes against the repository pin before returning it.
Tampered regular files and symlinks are unlinked and reacquired; directories or
an unsafe cache-parent shape fail closed.

Downloaded bytes are checked against the same repository pin and installed from
a uniquely named sibling temporary file with an atomic replace. No partial
download is ever published at the executable path. Remote checksum metadata is
not the root of trust for an already reviewed tool version.
Release-asset acquisition also has a finite 60-second request timeout so a
stalled endpoint cannot hold the verification lane indefinitely.

## Nonclaims

This local cache integrity boundary does not make OSV advisory availability
hermetic, establish host compromise resistance, or replace release upgrade
review. A process with authority to mutate executable bytes continuously can
still race any path-based execution; repository CI assumes its workspace is not
actively controlled by another principal.

## Verification

Tests cover valid hits without network access, tampered bytes, symlinks,
directories, missing platform pins, download mismatch, atomic replacement, and
concurrent acquisition. The live clean-lock scan, Ruff, repository policy, and
required supply-chain job remain mandatory.
12 changes: 12 additions & 0 deletions docs/requirements/GOV-913/requirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,15 @@ Requirement inventory expansion. Reusable ecosystem assets need explicit trust a
- IMPLEMENTS → ADR `docs/decisions/adrs/adr-071-reusable-asset-trust-and-integrity-policy.md` (ADR-071: Reusable Asset Trust and Integrity Policy)
- TESTS → TEST `implementations/python/tests/test_reusable_asset_trust_policy.py` (Reusable-asset trust policy contract tests)
- IMPLEMENTS → GITHUB_ISSUE `115` (Trust & integrity of reusable assets (GOV-913))
- IMPLEMENTS → GITHUB_ISSUE `1098` (Upgrade vulnerable Click and cryptography locks and gate OSV findings)
- IMPLEMENTS → CONFIG `implementations/python/pyproject.toml` (Fixed Click and cryptography dependency floors)
- IMPLEMENTS → CONFIG `implementations/python/uv.lock` (Reviewed frozen dependency resolution)
- IMPLEMENTS → CONFIG `.github/workflows/ci.yml` (Required OSV dependency-vulnerability gate)
- IMPLEMENTS → CONFIG `noxfile.py` (Explicit OSV findings and scanner-error enforcement)
- IMPLEMENTS → CODE_FILE `tools/osv_scanner_tool.py` (OSV result classification)
- IMPLEMENTS → DOCUMENTATION `docs/decisions/issue-1098-gov-913-supply-chain-security-preflight.md` (Dependency vulnerability and gating preflight)
- TESTS → TEST `implementations/python/tests/test_repo_policy_tools.py` (Dependency-floor and OSV gate regression tests)
- DOCUMENTS → GITHUB_ISSUE `1106` (Cached OSV-Scanner integrity validation)
- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1106-gov-913-osv-cache-integrity.md` (Repository pin and atomic cache decision)
- IMPLEMENTS → CODE_FILE `tools/osv_scanner_tool.py` (Per-use cache type, mode, and digest validation)
- TESTS → TEST `implementations/python/tests/test_repo_policy_tools.py` (Tampered, symlinked, and atomic OSV cache regressions)
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@

from __future__ import annotations

import re
import subprocess
from collections.abc import Callable
from dataclasses import dataclass

from raes_backend_protocols.naming import provider_resource_name
from raes_contracts.diagnostics import Diagnostic, Severity
Expand All @@ -38,6 +36,7 @@
NetworkHandle,
NetworkSpec,
)
from raes_reference_backend.drivers.oci_image_trust import ImageTrustPolicy
from raes_reference_backend.drivers.oci_observation import ownership_fields_match, substrate_observations

_DOMAIN = "runtime"
Expand Down Expand Up @@ -70,68 +69,6 @@ def _default_runner(argv: list[str], **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(argv, **kwargs)


# OCI/distribution reference grammar, restricted to the trust boundary's needs.
# The name is an optional ``registry[:port]`` domain plus one or more lowercase
# path components; character classes for separators and alphanumerics are
# disjoint, so matching is linear (no catastrophic backtracking).
_REF_DOMAIN_COMPONENT = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
# Docker and containerd also accept a bracketed IPv6 authority (``[2001:db8::1]``,
# optionally with a port), so a registry reachable only over IPv6 must not be
# rejected. Bounded and built from a disjoint character class, so still linear.
_REF_IPV6_AUTHORITY = r"\[[0-9A-Fa-f:.]{2,45}\]"
_REF_DOMAIN = rf"(?:{_REF_DOMAIN_COMPONENT}(?:\.{_REF_DOMAIN_COMPONENT})*|{_REF_IPV6_AUTHORITY})(?::[0-9]+)?"
_REF_PATH_COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*"
_REF_NAME = rf"(?:{_REF_DOMAIN}/)?{_REF_PATH_COMPONENT}(?:/{_REF_PATH_COMPONENT})*"
_REF_TAG = r"[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}"

# A digest-pinned reference is the driver's trust anchor: the content is bound
# to a specific manifest digest a plan author cannot swap. It is accepted only
# when a well-formed name (with optional tag) is terminated by a canonical
# ``sha256:`` digest of exactly 64 lowercase hex characters. ``fullmatch`` keeps
# the digest anchored at the very end, so an unanchored ``@sha256:`` substring
# that never actually pins content -- ``evil/img@sha256:x/pull-me:latest``,
# ``foo@sha256:short`` -- is rejected rather than trusted.
_DIGEST_PINNED_REF = re.compile(rf"{_REF_NAME}(?::{_REF_TAG})?@sha256:[0-9a-f]{{64}}")

# The interpreter synthesizes ``raes-reference/<os-family>`` (or
# ``raes-reference/base``) for a node that pins no image source. Match that
# exact placeholder shape -- a single lowercase path component -- so a
# ``default_image`` substitution can never be triggered by a plan-author ref
# that merely starts with the prefix while smuggling extra ``/``, ``:``, or
# ``@`` structure past it.
_PLACEHOLDER_REF = re.compile(rf"raes-reference/{_REF_PATH_COMPONENT}")


@dataclass(frozen=True)
class ImageTrustPolicy:
"""Operator policy deciding which container images may be realized.

A plan author controls ``spec.image_ref`` (via ``node.source``) and ``run``
pulls+executes it; fixed argv stops shell injection but is not an image
trust boundary. Only the operator ``default_image``, an explicit
``allowed_images`` entry, or a digest-pinned ref (``...@sha256:...``) is
permitted, so plan submission cannot become arbitrary-image code execution.
"""

default_image: str | None = None
allowed_images: tuple[str, ...] = ()
allow_digest_pinned: bool = True

def image_for(self, image_ref: str) -> str:
# A configured default overrides the synthesized ``raes-reference/*``
# placeholder so an image-less plan can still realize against a registry.
if self.default_image and _PLACEHOLDER_REF.fullmatch(image_ref):
return self.default_image
return image_ref

def permits(self, image: str) -> bool:
if self.default_image is not None and image == self.default_image:
return True
if image in self.allowed_images:
return True
return self.allow_digest_pinned and _DIGEST_PINNED_REF.fullmatch(image) is not None


_DEFAULT_IMAGE_POLICY = ImageTrustPolicy()


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Container-image trust policy for the reference OCI driver."""

from __future__ import annotations

import re
from dataclasses import dataclass

# OCI/distribution reference grammar, restricted to the trust boundary's needs.
# The name is an optional ``registry[:port]`` domain plus one or more lowercase
# path components; character classes for separators and alphanumerics are
# disjoint, so matching is linear (no catastrophic backtracking).
_REF_DOMAIN_COMPONENT = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
# Docker and containerd also accept a bracketed IPv6 authority (``[2001:db8::1]``,
# optionally with a port), so a registry reachable only over IPv6 must not be
# rejected. Bounded and built from a disjoint character class, so still linear.
_REF_IPV6_AUTHORITY = r"\[[0-9A-Fa-f:.]{2,45}\]"
_REF_DOMAIN = rf"(?:{_REF_DOMAIN_COMPONENT}(?:\.{_REF_DOMAIN_COMPONENT})*|{_REF_IPV6_AUTHORITY})(?::[0-9]+)?"
_REF_PATH_COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*"
_REF_NAME = rf"(?:{_REF_DOMAIN}/)?{_REF_PATH_COMPONENT}(?:/{_REF_PATH_COMPONENT})*"
_REF_TAG = r"[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}"

# A digest-pinned reference is the driver's trust anchor: the content is bound
# to a specific manifest digest a plan author cannot swap. It is accepted only
# when a well-formed name (with optional tag) is terminated by a canonical
# ``sha256:`` digest of exactly 64 lowercase hex characters. ``fullmatch`` keeps
# the digest anchored at the very end, so an unanchored ``@sha256:`` substring
# that never actually pins content -- ``evil/img@sha256:x/pull-me:latest``,
# ``foo@sha256:short`` -- is rejected rather than trusted.
_DIGEST_PINNED_REF = re.compile(rf"{_REF_NAME}(?::{_REF_TAG})?@sha256:[0-9a-f]{{64}}")

# The interpreter synthesizes ``raes-reference/<os-family>`` (or
# ``raes-reference/base``) for a node that pins no image source. Match that
# exact placeholder shape -- a single lowercase path component -- so a
# ``default_image`` substitution can never be triggered by a plan-author ref
# that merely starts with the prefix while smuggling extra ``/``, ``:``, or
# ``@`` structure past it.
_PLACEHOLDER_REF = re.compile(rf"raes-reference/{_REF_PATH_COMPONENT}")


@dataclass(frozen=True)
class ImageTrustPolicy:
"""Operator policy deciding which container images may be realized.

A plan author controls ``spec.image_ref`` (via ``node.source``) and ``run``
pulls+executes it; fixed argv stops shell injection but is not an image
trust boundary. Only the operator ``default_image``, an explicit
``allowed_images`` entry, or a digest-pinned ref (``...@sha256:...``) is
permitted, so plan submission cannot become arbitrary-image code execution.
"""

default_image: str | None = None
allowed_images: tuple[str, ...] = ()
allow_digest_pinned: bool = True

def image_for(self, image_ref: str) -> str:
# A configured default overrides the synthesized ``raes-reference/*``
# placeholder so an image-less plan can still realize against a registry.
if self.default_image and _PLACEHOLDER_REF.fullmatch(image_ref):
return self.default_image
return image_ref

def permits(self, image: str) -> bool:
if self.default_image is not None and image == self.default_image:
return True
if image in self.allowed_images:
return True
return self.allow_digest_pinned and _DIGEST_PINNED_REF.fullmatch(image) is not None


__all__ = ["ImageTrustPolicy"]
4 changes: 3 additions & 1 deletion implementations/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ classifiers = [
]
dependencies = [
"typer>=0.12.0",
"click>=8.3.3",
"pydantic>=2.0.0",
"rich>=13.0.0",
"PyYAML>=6.0",
"cryptography>=46.0.7",
"cryptography>=50.0.0",
"defusedxml>=0.7.1",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
Expand Down Expand Up @@ -186,6 +187,7 @@ ignore = [
"packages/raes_cli/**" = ["B008"] # typer requires function calls in defaults
"packages/raes_runtime/control_plane.py" = ["S112"] # intentional exception suppression
"packages/raes/module_registry/__init__.py" = ["S310", "S202", "F401"] # S310/S202: explicit OCI URL fetch + tar extract; F401: intentional facade re-exports
"tools/osv_scanner_tool.py" = ["S310"] # Exact GitHub HTTPS prefix and pinned asset digest are enforced before download
"packages/raes_runtime/control_plane_api/__init__.py" = ["F401"] # intentional facade re-exports (_receipt_response)
"packages/raes_mcp/tools/authoring/__init__.py" = ["F401"] # intentional facade re-export (register)
"packages/raes_mcp/tools/inspection/__init__.py" = ["F401"] # intentional facade re-export (register)
Expand Down
Loading