Skip to content
Merged
14 changes: 14 additions & 0 deletions .code-polishy.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
},
"documentation": {
"design": [
{
"path": "docs/design/artifact-identity.md",
"sourcePaths": [
"internal/artifactsecurity/archive.go",
"internal/artifactsecurity/archive_test.go",
"internal/artifactsecurity/artifactsecurity_test.go",
"internal/artifactsecurity/run.go"
]
},
{
"path": "docs/design/trivy-scanner-assessments.md",
"module": "artifactsecurity"
Expand Down Expand Up @@ -112,6 +121,7 @@
"internal/policy/cargo_release_age_test.go",
"internal/policy/cargo_vulnerability_test.go",
"internal/repository/cargo_manifest.go",
"internal/repository/cargo_patches.go",
"internal/repository/cargo_manifest_test.go",
"internal/repository/cargo_dependencies.go",
"internal/repository/cargo_dependencies_test.go",
Expand All @@ -133,6 +143,8 @@
"internal/quality/comments.go",
"internal/quality/wgsl_test.go",
"internal/supplychain/cargo.go",
"internal/supplychain/cargo_patches.go",
"internal/supplychain/cargo_patches_test.go",
"internal/supplychain/cargo_test.go",
"internal/supplychain/cargo_lock.go",
"internal/supplychain/cargo_lock_test.go",
Expand All @@ -150,6 +162,8 @@
"internal/supplychain/osv_cargo.go",
"internal/supplychain/osv_cargo_test.go",
"internal/supplychain/osv_cargo_runtime_test.go",
"internal/supplychain/osv_informational.go",
"internal/supplychain/osv_informational_test.go",
"internal/supplychain/release_age.go",
"internal/supplychain/online_profile.go",
"internal/supplychain/supplychain.go",
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## Unreleased

## 0.29.3 - 2026-10-18

- Resolve cyclic Rust glob exports and root `extern crate` aliases without
losing valid imports, and use bounded, distinct Cargo command identities in
gates while preserving their original manifest arguments.
- Govern explicit Cargo replacement sources through the actual consumer lock.
Verify exact source bindings and that every replacement is consumed, retain
root dependency pins, and expose local-source provenance limits as findings.
- Preserve explicitly classified RustSec unmaintained notices as warnings.
Vulnerabilities, severity-bearing records and incomplete classifications
remain subject to the existing security checks.
- Save built container images by immutable Docker identity and compare Trivy
reports with the saved configuration digest. Support Docker's legacy and
containerd image stores while rejecting missing or ambiguous archive identity
and mismatched scanner reports.

## 0.29.2 - 2026-10-18

- Preserve governed source counts and line-budget headroom when workspace-only
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.29.2
0.29.3
5 changes: 5 additions & 0 deletions docs/artifact-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ HIGH and CRITICAL vulnerabilities, secrets, image-config misconfigurations,
and end-of-life operating systems are blocking. Secret matches are never copied
into normalized findings or reports.

Built images are saved by immutable Docker identity. Trivy reports must match
the configuration digest from that exact saved archive; see
[Container Archive Identity](design/artifact-identity.md).

## Target modes

Use `dockerfile` when the shared engine can build the product directly:
Expand Down Expand Up @@ -99,6 +103,7 @@ beneath the supplied directory:

The producer owns only product construction. It must not run Trivy, download a
database, apply OpenVEX, or publish security reports.
Its optional `imageId` denotes the configuration digest reported by Trivy.

## OpenVEX

Expand Down
8 changes: 8 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@
"aliases": ["artifacts"],
"public": true
},
{
"id": "artifact-security-identity",
"path": "docs/design/artifact-identity.md",
"title": "Container Archive Identity",
"summary": "Bind scanner reports to the configuration in the immutable Docker image archive.",
"aliases": ["container-identity"],
"public": true
},
{
"id": "artifact-security-scanner",
"path": "docs/design/trivy-scanner-assessments.md",
Expand Down
21 changes: 21 additions & 0 deletions docs/design/artifact-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Container Archive Identity

For images built by the engine, Docker's inspected ID selects the immutable
image passed to `image save`. Docker's containerd image store may use a manifest
digest for this ID; Trivy's `Metadata.ImageID` identifies the image configuration.
They are different objects and cannot be compared directly.

The engine therefore reads the single configuration named by the saved Docker
archive's `manifest.json` and derives its content digest for the Trivy comparison.
Both legacy Docker and containerd exports provide this manifest. The archive
and metadata retain their release-owned size limits, and missing, duplicate,
linked or ambiguous identity records fail before scanning. The saved image is
never selected again through a mutable tag. A Trivy configuration mismatch still
fails, including when OpenVEX is applied.

The Docker 29 regression archive in `internal/artifactsecurity/testdata` was
exported from a dependency-free scratch image with one purpose label. Its
configuration ID is
`sha256:0cf74d99b5a97c3748ef2e9f91839752b96b00d2427cb57f18919e8ed7d01ad6`;
the distinct Docker manifest ID is
`sha256:13c9308faace219384c34d1108b928face27e7e1881b1d9b0dd0ec1e84569c68`.
31 changes: 31 additions & 0 deletions docs/design/cargo-facts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ architecture and dependency policy consume facts without acquiring their own
Cargo resolvers. Commands use the common structured runner so limits, cancellation
and command evidence have one owner.

Manifest-specific command names use a bounded digest of the operation and
repository-relative manifest path as their immutable execution identity. Raw
paths can contain separators, spaces, Unicode or more bytes than the gate's
identifier limit. Replacing separators would merge distinct paths. The exact
manifest remains in command arguments and finding paths; this identifier does
not detect file changes or authenticate manifest contents.

Workspace discovery runs `cargo metadata --format-version 1 --no-deps --frozen`.
It can discover members, targets, inherited metadata, declared dependencies and
features before dependencies are materialized. A null `resolve` is deliberately
Expand Down Expand Up @@ -44,6 +51,19 @@ Offline verification uses `--frozen` and requires that cache to be prepared.
Both modes require a contained, parsed workspace lock before resolution runs.
Neither repairs a missing or stale lockfile.

Explicitly governed `patch.crates-io` sources resolve through their consumer's
workspace lock. Selecting a contained replacement manifest adds its declared
consumer rather than inventing a dependency-owned lock. Independent projects
retain their own locks. For these consumers, data-only metadata enables all
consumer features so optional replacements have actual source evidence; this
does not compile those features or establish that every feature combination can
execute together. Every declared patch must occur in the locked graph with its
exact local manifest or Git repository and commit, outside workspace members.
Unused substitutions, missing locks and stale resolution remain findings.
Nested patch owners are rejected because Cargo applies only workspace-root
patches. No dependency development target or build script executes in this
operation, and the original manifests and locks remain untouched.

Resolved facts preserve opaque package IDs, source identities, renamed edges,
dependency kinds, target predicates and enabled features. Every graph node and
edge must refer to a real package, and resolved package coordinates must occur
Expand Down Expand Up @@ -190,6 +210,17 @@ re-exports. Function, block, closure, implementation and generic bindings retain
lexical scope; they cannot leak into sibling scopes or substitute for a module's
members. Prelude lookup respects edition, `no_std`, `no_implicit_prelude` and
local shadowing. Qualified module members never inherit the implicit prelude.
Crate-root `extern crate` aliases, including `extern crate self`, enter the
[extern prelude](https://doc.rust-lang.org/reference/names/preludes.html#extern-prelude)
for descendant modules. A child declaration does not become a sibling's implicit
binding, and local shadowing or `no_implicit_prelude` still takes precedence.

Local glob lookup visits each module/name pair once along the active search
path. Circular re-exports cannot introduce a binding by themselves; revisiting
that search contributes no candidate while other glob branches still resolve.
Distinct definitions remain ambiguous, missing names remain uncovered, and
recursive explicit aliases still fail. The search state is discarded on return,
so one lookup cannot hide a candidate from another.

Known standard macro arguments are parsed using the same upstream Rust grammar
with their original byte locations retained. This includes assertion/formatting
Expand Down
41 changes: 41 additions & 0 deletions docs/design/rust-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ explicit source governance. Static manifest checks neither install dependencies
nor run build scripts. They do not establish lock agreement, vulnerability, license
or release-age coverage; those remain separate dependency-policy operations.

The existing `dependencyOverridePolicies` contract governs Cargo's root
`patch.crates-io` table with ecosystem `cargo`. Its canonical JSON identity is
computed from the parsed TOML table, retaining every declaration field. Changed,
missing, expired and unused ownership remains visible. Supported substitutions
select contained local package manifests or exact Git revisions; other patch
registries and deprecated `replace` declarations remain unsupported.

A governed local replacement is consumed as a dependency of the declaring
application. Its registry requirements are transitive constraints resolved by
that application's lock, so copying a dependency into the repository does not
require rewriting its upstream constraints or creating a standalone lock.
Consumer declarations retain exact-version policy, and replacement Git and path
sources retain containment and immutable-pin checks. Cargo must prove each
replacement's actual source and package identity in the consumer graph; a
workspace member cannot obtain transitive treatment through a patch declaration.
This does not authorize the replacement's own development targets or establish
an independently buildable library project.

Local replacement source remains governed repository code. A visible source
coverage warning distinguishes review of its provenance and changes from the
public registry's age, advisory and license evidence. The override owner must
record that review and its maintenance plan; a matching table digest neither
authenticates the copied source nor proves it unaffected. Registry dependencies
in the complete consumer lock retain their ordinary age and security checks.
The [consumer resolution](cargo-facts.md#locked-resolution) supplies the actual
source binding without compiling the replacement or executing its build scripts.

Lock inventory supports Cargo's version-3 and version-4 records. A package
identity retains its name, complete version and exact source; different versions
or sources are not collapsed. Git-index and sparse registry records must carry
Expand Down Expand Up @@ -133,6 +160,20 @@ use the `crates.io` ecosystem and cannot approve a different lock or version.
The pinned runtime contract is exercised against a temporary synthetic offline
advisory database, without querying a live provider.

RustSec also publishes [informational advisory categories](https://github.com/RustSec/advisory-db/blob/main/README.md#advisory-format).
An `unmaintained` notice identifies a maintenance concern rather than an
unspecified exploit. The OSV reader retains affected-package metadata and emits
`supplyChain.osvUnmaintained` warnings with exact package, version, advisory,
aliases and original lock scope. Only RustSec records explicitly classifying
the matching crates.io package as unmaintained, without group severity,
advisory/affected severity or a CVSS score, qualify. Every grouped identifier
must be covered by such a record. Missing metadata, contradictory matching
records, CVE identities, security aliases, other ecosystems and `unsound` or other notice
categories remain blocking vulnerability findings. A maintenance notice cannot
mask a separate vulnerability for the same package or require an invented
non-exposure assessment. Complete warning-bearing scanner exits retain the same
protocol checks as vulnerability-bearing exits; operational failures still fail.

## Configured license policy

When `supplyChain.allowedLicenses` is configured, installed-license verification
Expand Down
18 changes: 17 additions & 1 deletion docs/policies/supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,13 @@ missing-coverage finding, never a workspace that declared nothing.
`lock-sync` provider.
- Policy-pinned OSV-Scanner runs as an independent source advisory lane whenever
a supported dependency graph is detected. Its JSON is parsed centrally and
all reported affected versions, including unknown-severity results, block.
all reported vulnerable versions, including unknown-severity results, block.
RustSec `unmaintained` notices remain visible maintenance warnings when every
advisory in the group explicitly classifies the matching crates.io package
that way and reports no severity or CVSS score. Missing or conflicting evidence,
`unsound` advisories and other notice categories remain security findings.
A maintenance warning does not establish non-exposure or waive dependency
pinning, age, source or vulnerability checks.
- Python/uv must still declare module-scoped lock consistency; OSV supplies its
default security coverage unless an exact governed module override applies.
- Scanner errors and unavailable advisory services fail.
Expand Down Expand Up @@ -468,6 +474,16 @@ canonical JSON override object plus reason, owner, review date, and expiry.
Run the static check once after introducing an ungoverned override to obtain the
observed canonical digest; adding governance does not suppress a vulnerability.

Cargo `patch.crates-io` uses the same ownership record with ecosystem `cargo`,
the exact `Cargo.toml` path and field `patch.crates-io`. The digest covers the
parsed table encoded as canonical JSON. Contained local replacements and exact
Git revisions require actual source binding through the consumer's frozen lock.
Replacement registry requirements remain transitive constraints; the consumer's
direct requirements stay exactly pinned. Local source provenance and changes
need review under the override owner and remain a visible coverage warning.
Other patch registries, `replace`, nested patch owners, unresolved replacements
and substitutions for workspace members are not admitted by this contract.

```json
{
"supplyChain": {
Expand Down
86 changes: 86 additions & 0 deletions internal/artifactsecurity/archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package artifactsecurity

import (
"archive/tar"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
)

func saveDockerImage(ctx context.Context, client dockerClient, operation, image, archive string, maximum, metadataMaximum int64) (string, error) {
identity, err := inspectImageID(ctx, client, operation+"-inspect", image)
if err != nil {
return "", err
}
if _, err := client.run(ctx, operation+"-save", "image", "save", "--output", archive, identity); err != nil {
return "", err
}
return dockerArchiveImageID(archive, maximum, metadataMaximum)
}

func dockerArchiveImageID(archive string, maximum, metadataMaximum int64) (string, error) {
payload, err := dockerArchiveEntry(archive, "manifest.json", maximum, metadataMaximum)
if err != nil {
return "", err
}
var manifest []struct {
Config string `json:"Config"`
}
if err := json.Unmarshal(payload, &manifest); err != nil {
return "", fmt.Errorf("decode Docker archive manifest: %w", err)
}
if len(manifest) != 1 || manifest[0].Config == "" {
return "", errors.New("saved Docker archive must identify exactly one image configuration")
}
config, err := dockerArchiveEntry(archive, manifest[0].Config, maximum, metadataMaximum)
if err != nil {
return "", err
}
return "sha256:" + bytesSHA256(config), nil
}

func dockerArchiveEntry(archive, name string, maximum, entryMaximum int64) ([]byte, error) {
info, err := os.Lstat(archive)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > maximum {
return nil, fmt.Errorf("saved Docker archive must be a regular file no larger than %d bytes", maximum)
}
file, err := os.Open(archive)
if err != nil {
return nil, err
}
defer file.Close()
return readDockerArchiveEntry(tar.NewReader(file), name, entryMaximum)
}

func readDockerArchiveEntry(reader *tar.Reader, name string, maximum int64) ([]byte, error) {
var payload []byte
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if header.Name != name {
continue
}
if payload != nil || header.Typeflag != tar.TypeReg || header.Size > maximum {
return nil, fmt.Errorf("saved Docker archive entry %q must be one bounded regular file", name)
}
payload, err = io.ReadAll(reader)
if err != nil {
return nil, err
}
}
if payload == nil {
return nil, fmt.Errorf("saved Docker archive omits %q", name)
}
return payload, nil
}
Loading
Loading