Skip to content

Commit ed3dbdd

Browse files
committed
Fix Catalog request wire and track exact unfixed builder header findings
1 parent e4869f4 commit ed3dbdd

10 files changed

Lines changed: 3268 additions & 18 deletions

File tree

‎.github/dapper-unfixed.json‎

Lines changed: 2871 additions & 0 deletions
Large diffs are not rendered by default.

‎.github/scripts/test_builder_vulnerability_policy.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib.util
22
import json
33
from copy import deepcopy
4+
from datetime import date, timedelta
45
import unittest
56
from pathlib import Path
67
spec = importlib.util.spec_from_file_location("gate", Path(__file__).with_name("enforce-builder-vulnerabilities.py"))
@@ -93,6 +94,56 @@ def test_refreshed_review_fixed_or_package_drift_rejected(self):
9394
self.headers = deepcopy(original)
9495
self.headers["mediumRefresh"]["entries"][0][key] = value
9596
with self.subTest(key=key), self.assertRaises(ValueError): self.check()
97+
def tracked_medium_headers(self):
98+
root = Path(__file__).resolve().parents[1]
99+
self.ledger = json.loads((root / "dapper-unfixed.json").read_text())
100+
self.headers = json.loads((root / "dapper-headers.json").read_text())
101+
return [entry for entry in self.ledger["unfixed"]
102+
if entry.get("scanRawSha256") == "e42870cc26d1fe7a9e6f212a13e38681db5d3227a6ec01a9188a4c69e5a95f4a"]
103+
def test_tracked_99_medium_headers_have_pinned_no_fix_evidence(self):
104+
entries = self.tracked_medium_headers()
105+
self.assertEqual(len(entries), 99)
106+
self.assertEqual(len({entry["id"] for entry in entries}), 99)
107+
snapshot = "8fdde882b47cb3972740ffef118fe74c628e993c"
108+
for entry in entries:
109+
with self.subTest(id=entry["id"]):
110+
self.assertNotIn(entry["id"], self.headers["ids"])
111+
self.assertEqual((entry["package"], entry["version"], entry["purl"], entry["severity"], entry["status"]),
112+
("linux-libc-dev", gate.HEADER_VERSION, gate.HEADER_PURL, "MEDIUM", "no_official_fix"))
113+
self.assertEqual(entry["officialSnapshot"], snapshot)
114+
self.assertEqual(entry["officialEvidence"], "https://raw.githubusercontent.com/canonical/ubuntu-security-notices/"
115+
+ snapshot + "/osv/cve/2026/UBUNTU-" + entry["id"] + ".json")
116+
self.assertRegex(entry["officialRawSha256"], r"^[0-9a-f]{64}$")
117+
self.assertTrue(entry["officialModified"])
118+
self.assertEqual(entry["officialPriority"], ["medium"])
119+
self.assertEqual(entry["officialRangeEvents"], [{"introduced": "0"}])
120+
self.assertEqual(entry["officialFixedVersions"], [])
121+
self.assertEqual(entry["checkedAt"], "2026-11-02")
122+
self.assertEqual(date.fromisoformat(entry["reviewAfter"]) - date.fromisoformat(entry["checkedAt"]),
123+
timedelta(days=7))
124+
self.assertEqual(entry["scanApplicableSha256"], "ab6de6f510e9f98640d3408e34acaa57cd668ce81e7f2898b07c4eac0a438240")
125+
self.assertEqual((entry["scanRunId"], entry["scanArtifactId"]), (41327670634, 13091519384))
126+
self.assertEqual(entry["scanArtifactSha256"], "cb34b687d30a498064e8144fcc293944ce964e4c605da21cbe8ba34570706847")
127+
def test_tracked_99_exact_medium_headers(self):
128+
entries = self.tracked_medium_headers()
129+
self.report["Results"][0]["Vulnerabilities"] = [
130+
{"VulnerabilityID": entry["id"], "PkgName": entry["package"], "InstalledVersion": entry["version"],
131+
"PkgIdentifier": {"PURL": entry["purl"]}, "Severity": entry["severity"], "FixedVersion": ""}
132+
for entry in entries]
133+
self.assertEqual(self.check(), [])
134+
def test_tracked_medium_headers_reject_fix_severity_or_identity_drift(self):
135+
entry = self.tracked_medium_headers()[0]
136+
baseline = {"VulnerabilityID": entry["id"], "PkgName": entry["package"], "InstalledVersion": entry["version"],
137+
"PkgIdentifier": {"PURL": entry["purl"]}, "Severity": "MEDIUM", "FixedVersion": ""}
138+
mutations = [("Severity", value) for value in ("LOW", "HIGH", "CRITICAL", "UNKNOWN")]
139+
mutations += [("FixedVersion", "7.0.0-42.42"), ("InstalledVersion", "7.0.0-42.42"),
140+
("PkgIdentifier", {"PURL": gate.HEADER_PURL + "&changed=1"}),
141+
("VulnerabilityID", "CVE-unreviewed"), ("PkgName", "linux-image-generic")]
142+
for key, value in mutations:
143+
finding = deepcopy(baseline)
144+
finding[key] = value
145+
self.report["Results"][0]["Vulnerabilities"] = [finding]
146+
with self.subTest(field=key, value=value): self.assertTrue(self.check())
96147
def test_header_version_or_purl_drift_rejected(self):
97148
for field in ("InstalledVersion", "PkgIdentifier"):
98149
f = self.header()

‎.github/workflows/security-release-gate.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
env:
2222
DAPPER_IMAGE: pasturestack/compose-cli-dapper:${{ github.sha }}
2323
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
24-
VERSION_OVERRIDE: 0.14.40
24+
VERSION_OVERRIDE: 0.14.41
2525
PLATFORM_COMPAT_JAR_URL: https://github.com/PastureStack/orchestration-engine/releases/download/v0.183.281/orchestration-engine-0.183.281.jar
2626
PLATFORM_COMPAT_JAR_SHA256: da2a8a51562ed16e296f7e29e99482bb44042ff0834cca679bbe01d951ba1682
2727

@@ -69,7 +69,7 @@ jobs:
6969
}
7070
7171
run_ci
72-
artifact="dist/artifacts/compose-executor-0.14.40-linux-amd64.gz"
72+
artifact="dist/artifacts/compose-executor-0.14.41-linux-amd64.gz"
7373
test -s "$artifact"
7474
cp "$artifact" /tmp/compose-executor-first.gz
7575
@@ -81,7 +81,7 @@ jobs:
8181
mkdir -p evidence/product
8282
gzip -cd "$artifact" > evidence/product/compose-executor
8383
chmod +x evidence/product/compose-executor
84-
evidence/product/compose-executor --version | grep -F '0.14.40' >/dev/null
84+
evidence/product/compose-executor --version | grep -F '0.14.41' >/dev/null
8585
cp "$artifact" evidence/
8686
(
8787
cd evidence

‎COMPATIBILITY.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Compatibility Contract
22

3+
## 0.14.41 source candidate: private Catalog request wire
4+
5+
The private Catalog preview producer emits business-only mutation inputs.
6+
`setServiceLinks` always includes an array, including `[]` for no links, and
7+
retains native target IDs and aliases. Service/Volume creation skeletons and
8+
finalized child request structs exclude the generic SDK Resource envelope,
9+
including nested launch configs. Business maps such as metadata, labels,
10+
environment and log-driver options retain their exact keys and values.
11+
12+
This changes only the private preview serialization. Format version `1`,
13+
normal parser/DTO owners, ordinary CLI/SDK serialization and Engine closed
14+
validation remain unchanged. Qualification, publication and matching Server
15+
runtime acceptance are pending. See the
16+
[candidate note](docs/releases/compose-executor-0.14.41.md).
17+
318
The migration preserves Docker Compose parsing, stack event names, generated stack fields, service labels, catalog question data, upgrade and rollback payloads, and compatibility API behavior.
419

520
The published v0.14.40 adds one server-private mode,

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ PastureStack is an independent community effort to preserve, audit, and moderniz
1010

1111
The maintained source uses Ubuntu 26.04, Go 1.27.3, and Python 3.14.9 for integration-source verification. Ubuntu build packages, the base image, and Go archives are fixed by snapshot, exact version, and SHA-256. Go dependencies are managed by `go.mod` and a reproducible module vendor tree. AWS access uses AWS SDK for Go v2; container structures use the maintained Moby API module; the command parser uses urfave/cli v3. The abandoned AWS SDK v1, Rancher generated-client modules, event-subscriber module, `docker/libcompose`, and other GOPATH-era dependencies have been removed. The small Rancher 1.6 protocol boundary retained by this compatibility CLI is maintained and tested locally. The Dapper builder does not embed a Docker CLI or mount a Docker socket; Docker is required only on the host to create and run the isolated builder. The maintained binaries are `pasturestack-compose` and `compose-executor`; `platform-compose.yml` is the preferred companion file. Releases are built and verified from the preserved source history; this repository does not enable automated deployment.
1212

13+
The [0.14.41 source candidate](docs/releases/compose-executor-0.14.41.md) fixes
14+
the private Catalog launch wire format: mutation DTOs contain business fields,
15+
and an empty link action explicitly carries `serviceLinks: []`. Native link
16+
targets and aliases are preserved. Generic API resource links/actions are
17+
excluded from these request structs; ordinary CLI and SDK requests retain
18+
their behavior. Candidate qualification, publication and matching Server
19+
runtime acceptance remain pending.
20+
1321
The current published component is
1422
[v0.14.40](https://github.com/PastureStack/compose-cli/releases/tag/v0.14.40),
1523
from producer `1835f8db645a8488e327b1f6e750bfdf3d1fb84d`. Its original CI archive

‎cataloglaunch/prepare.go‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,18 @@ type Inspect struct {
5252
}
5353

5454
type Service struct {
55-
Name string `json:"name"`
56-
PrimaryName string `json:"primaryName,omitempty"`
57-
ResourceType string `json:"resourceType,omitempty"`
58-
CreateSkeleton *platformapi.CompositeService `json:"createSkeleton,omitempty"`
59-
Config *config.ServiceConfig `json:"config"`
60-
Inspect Inspect `json:"dockerInspect"`
55+
Name string `json:"name"`
56+
PrimaryName string `json:"primaryName,omitempty"`
57+
ResourceType string `json:"resourceType,omitempty"`
58+
CreateSkeleton map[string]interface{} `json:"createSkeleton,omitempty"`
59+
Config *config.ServiceConfig `json:"config"`
60+
Inspect Inspect `json:"dockerInspect"`
6161
}
6262

6363
type VolumeRequest struct {
6464
Name string `json:"name"`
6565
ResourceType string `json:"resourceType"`
66-
CreateSkeleton *client.VolumeTemplate `json:"createSkeleton"`
66+
CreateSkeleton map[string]interface{} `json:"createSkeleton"`
6767
}
6868

6969
type Output struct {
@@ -83,10 +83,10 @@ type Output struct {
8383
}
8484

8585
type ServiceRequest struct {
86-
Name string `json:"name"`
87-
ResourceType string `json:"resourceType"`
88-
CreateBody interface{} `json:"createBody"`
89-
SetServiceLinks *client.SetServiceLinksInput `json:"setServiceLinks"`
86+
Name string `json:"name"`
87+
ResourceType string `json:"resourceType"`
88+
CreateBody map[string]interface{} `json:"createBody"`
89+
SetServiceLinks *serviceLinksWire `json:"setServiceLinks"`
9090
}
9191

9292
type catalogEnvironment struct {
@@ -221,7 +221,11 @@ func Prepare(input Input) (*Output, error) {
221221
if !namePattern.MatchString(name) {
222222
return nil, ErrUnsupported
223223
}
224-
result.VolumeRequests = append(result.VolumeRequests, VolumeRequest{Name: name, ResourceType: "volumeTemplate", CreateSkeleton: platformapi.CatalogVolumeCreateSkeleton(name, p.VolumeConfigs[name])})
224+
skeleton, err := catalogRequestWire(platformapi.CatalogVolumeCreateSkeleton(name, p.VolumeConfigs[name]))
225+
if err != nil {
226+
return nil, err
227+
}
228+
result.VolumeRequests = append(result.VolumeRequests, VolumeRequest{Name: name, ResourceType: "volumeTemplate", CreateSkeleton: skeleton})
225229
}
226230
for _, name := range names {
227231
if !namePattern.MatchString(name) {
@@ -275,7 +279,12 @@ func Prepare(input Input) (*Output, error) {
275279
h.NetworkMode = ""
276280
entry := Service{Name: name, PrimaryName: primaries[name], Config: canonical, Inspect: Inspect{Name: "/" + name, Config: c, HostConfig: h}}
277281
if entry.PrimaryName == "" {
278-
entry.ResourceType, entry.CreateSkeleton = platformapi.CatalogServiceCreateSkeleton(name, &copy)
282+
kind, skeleton := platformapi.CatalogServiceCreateSkeleton(name, &copy)
283+
wire, err := catalogRequestWire(skeleton)
284+
if err != nil {
285+
return nil, err
286+
}
287+
entry.ResourceType, entry.CreateSkeleton = kind, wire
279288
}
280289
result.Services = append(result.Services, entry)
281290
}
@@ -326,7 +335,11 @@ func Prepare(input Input) (*Output, error) {
326335
if kind == "externalService" {
327336
links = nil
328337
}
329-
result.ServiceRequests = append(result.ServiceRequests, ServiceRequest{Name: entry.Name, ResourceType: kind, CreateBody: platformapi.CatalogServiceRequestPayload(kind, body), SetServiceLinks: links})
338+
wire, err := catalogRequestWire(platformapi.CatalogServiceRequestPayload(kind, body))
339+
if err != nil {
340+
return nil, err
341+
}
342+
result.ServiceRequests = append(result.ServiceRequests, ServiceRequest{Name: entry.Name, ResourceType: kind, CreateBody: wire, SetServiceLinks: catalogLinksWire(links)})
330343
}
331344
result.Phase = "finalize"
332345
}

‎cataloglaunch/wire.go‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package cataloglaunch
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"reflect"
7+
"strings"
8+
9+
client "github.com/PastureStack/compose-cli/internal/rancherclient/v2"
10+
)
11+
12+
// These are mutation inputs, not generic API resources. Empty links remain an
13+
// explicit business value, and link aliases/targets retain the native parser's values.
14+
type serviceLinkWire struct {
15+
Name string `json:"name,omitempty"`
16+
ServiceID string `json:"serviceId"`
17+
}
18+
19+
type serviceLinksWire struct {
20+
ServiceLinks []serviceLinkWire `json:"serviceLinks"`
21+
}
22+
23+
func catalogLinksWire(input *client.SetServiceLinksInput) *serviceLinksWire {
24+
if input == nil {
25+
return nil
26+
}
27+
result := &serviceLinksWire{ServiceLinks: make([]serviceLinkWire, 0, len(input.ServiceLinks))}
28+
for _, link := range input.ServiceLinks {
29+
result.ServiceLinks = append(result.ServiceLinks, serviceLinkWire{Name: link.Name, ServiceID: link.ServiceId})
30+
}
31+
return result
32+
}
33+
34+
var apiResourceType = reflect.TypeOf(client.Resource{})
35+
36+
// Only this private preview producer projects SDK request structs. Preserve the
37+
// native JSON encoding, then omit the SDK's typed Resource envelope throughout
38+
// those structs. Maps are business data and are never traversed or key-stripped.
39+
// Ordinary SDK/legacy requests keep their original serialization.
40+
func catalogRequestWire(value interface{}) (map[string]interface{}, error) {
41+
raw, err := json.Marshal(value)
42+
if err != nil {
43+
return nil, ErrUnsupported
44+
}
45+
var result map[string]interface{}
46+
decoder := json.NewDecoder(bytes.NewReader(raw))
47+
decoder.UseNumber()
48+
if decoder.Decode(&result) != nil || result == nil {
49+
return nil, ErrUnsupported
50+
}
51+
projectRequestStruct(reflect.ValueOf(value), result)
52+
return result, nil
53+
}
54+
55+
func projectRequestStruct(value reflect.Value, encoded interface{}) {
56+
for value.IsValid() && (value.Kind() == reflect.Ptr || value.Kind() == reflect.Interface) {
57+
if value.IsNil() {
58+
return
59+
}
60+
value = value.Elem()
61+
}
62+
if !value.IsValid() {
63+
return
64+
}
65+
switch value.Kind() {
66+
case reflect.Struct:
67+
object, ok := encoded.(map[string]interface{})
68+
if !ok {
69+
return
70+
}
71+
if value.Type() == apiResourceType {
72+
// A named Resource property is data, not a promoted response envelope.
73+
return
74+
}
75+
for index := 0; index < value.NumField(); index++ {
76+
field := value.Type().Field(index)
77+
name := strings.Split(field.Tag.Get("json"), ",")[0]
78+
if field.PkgPath != "" || name == "-" {
79+
continue
80+
}
81+
if field.Anonymous && name == "" {
82+
embedded := field.Type
83+
if embedded.Kind() == reflect.Ptr {
84+
embedded = embedded.Elem()
85+
}
86+
if embedded == apiResourceType {
87+
for resourceIndex := 0; resourceIndex < apiResourceType.NumField(); resourceIndex++ {
88+
key := strings.Split(apiResourceType.Field(resourceIndex).Tag.Get("json"), ",")[0]
89+
delete(object, key)
90+
}
91+
} else {
92+
projectRequestStruct(value.Field(index), object)
93+
}
94+
} else {
95+
if name == "" {
96+
name = field.Name
97+
}
98+
projectRequestStruct(value.Field(index), object[name])
99+
}
100+
}
101+
case reflect.Array, reflect.Slice:
102+
items, ok := encoded.([]interface{})
103+
if ok {
104+
for index := 0; index < value.Len() && index < len(items); index++ {
105+
projectRequestStruct(value.Index(index), items[index])
106+
}
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)