v1.91.27.0 fix(cso): runtime staging can finish and a qualified scanner catalog can ship (#3041)

* v1.91.27.0 fix(cso): package-level signed SBOMs and staged probes in the verifier container

* Qualified scanner catalogs persist scan outcomes; catalog-state tests hold for either state; SBOM tests live with their owner
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-05 10:00:47 -07:00
1 parent 857466ff8b
commit 10315cf44d
14 files changed
+98 -18

No files matched your search

+3 -2
View File
@@ -209,8 +209,9 @@ jobs:
CSO_IMAGE: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
run: |
set -eu
docker buildx imagetools inspect "$CSO_IMAGE" --format '{{ json .SBOM.SPDX }}' > sbom.spdx.json
jq -e '.spdxVersion == "SPDX-2.3" and (.packages | type == "array")' sbom.spdx.json
docker buildx imagetools inspect "$CSO_IMAGE" --format '{{ json .SBOM.SPDX }}' > sbom.full.spdx.json
jq -c -f scripts/cso-sbom-packages.jq sbom.full.spdx.json > sbom.spdx.json
jq -e -f scripts/cso-sbom-packages-check.jq sbom.spdx.json >/dev/null
- name: Sign build provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
+3 -2
View File
@@ -145,8 +145,9 @@ jobs:
CSO_IMAGE: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
run: |
set -eu
docker buildx imagetools inspect "$CSO_IMAGE" --format '{{ json .SBOM.SPDX }}' > sbom.spdx.json
jq -e '.spdxVersion == "SPDX-2.3" and (.packages | type == "array")' sbom.spdx.json
docker buildx imagetools inspect "$CSO_IMAGE" --format '{{ json .SBOM.SPDX }}' > sbom.full.spdx.json
jq -c -f scripts/cso-sbom-packages.jq sbom.full.spdx.json > sbom.spdx.json
jq -e -f scripts/cso-sbom-packages-check.jq sbom.spdx.json >/dev/null
- name: Sign build provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.91.27.0] - 2026-10-05
**The first protected-main runtime staging run can finish, and a qualified scanner catalog can ship.**
The first `cso-runtime-images.yml` dispatch on main staged all ten runtime images but four rows stopped before qualification. Both causes are fixed:
- **Rails SBOMs fit the attestation limit.** BuildKit's Rails SBOM lists about 19,600 files (20 MB), past `actions/attest`'s 16 MiB cap. The runtime and scanner release workflows now sign a package-level SPDX 2.3 document (`scripts/cso-sbom-packages.jq`: packages and package relationships kept, file entries and file relationships dropped, about 0.8 MB for Rails) and reject any document whose relationships name unknown elements.
- **A qualified scanner catalog no longer breaks every scan.** Persisted scanner outcomes record the catalog revision (`cso-scanners-<sha>-<runId>`) and each profile's workflow run URL. The 11-digit Actions run id read as a phone number to the redactor, so every `gstack-cso scan` against a real catalog failed with `REDACTION_FAILED`. Catalog revisions and exact Actions run URLs are now schema-bound helper metadata; the same digits anywhere else are still redacted. Three tests that assumed the shipped catalog is empty now hold for either state, so the scanner promotion job's own validation passes.
- **The staged-image test probes tools in a real role container.** It ran `npm --version` and the other version probes inside the group's network anchor, which is limited to 8 processes; Node's worker threads exceed that and abort (exit 134). The probes now run in the verifier container that the same test already starts. Production code never executes in the anchor.
## [1.91.25.0] - 2026-10-05
**gstack can grade a private, two-architecture `/cso` release run, and the runtime release gates check more for themselves.**
+1 -1
View File
@@ -1 +1 @@
1.91.25.0
1.91.27.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.25.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.27.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+10
View File
@@ -213,6 +213,16 @@ function safeMetadata(value: string, key: string): boolean {
if (key === 'signature' && /^[a-f0-9]{128}$/.test(value)) return true;
if (key === 'image' && /^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value)) return true;
if (key === 'integrity' && /^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) return true;
if (
['catalog', 'catalogRevision', 'scannerCatalog'].includes(key) &&
/^cso-(?:scanners|v3|eval)-[a-z0-9._-]{1,120}$/.test(value)
)
return true;
if (
key === 'workflow' &&
/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]{1,20}$/.test(value)
)
return true;
return false;
}
function sanitizeJson(value: unknown, key: string, seen: WeakSet<object>, trustedMetadata: boolean): unknown {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.25",
"version": "1.91.27",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+7
View File
@@ -0,0 +1,7 @@
# Accept only a package-level SPDX 2.3 document whose relationships name known elements.
.spdxVersion == "SPDX-2.3"
and (.packages | type == "array" and length > 0)
and (has("files") | not)
and (([.SPDXID, .packages[].SPDXID] | map({key: ., value: true}) | from_entries) as $known
| all(.relationships[]; ($known[.spdxElementId] // false)
and (($known[.relatedSpdxElement] // false) or (.relatedSpdxElement | test("^(NONE|NOASSERTION)$|^DocumentRef-")))))
+8
View File
@@ -0,0 +1,8 @@
# Reduce a BuildKit SPDX 2.3 SBOM to its package inventory before signing it.
# File entries (tens of thousands for Ruby or Python images) push the signed
# statement past actions/attest's 16 MiB limit; packages, their versions and
# package relationships are what CSO's release evidence relies on.
((.files // []) | map({key: .SPDXID, value: true}) | from_entries) as $files
| del(.files)
| .packages |= map(del(.hasFiles))
| .relationships |= map(select(($files[.spdxElementId] // false | not) and ($files[.relatedSpdxElement] // false | not)))
+6 -6
View File
@@ -65,8 +65,13 @@ suite('CSO Docker containment integration',()=>{
fs.writeFileSync(negativePath, JSON.stringify({...policy, legitimate: [{...policy.legitimate[0], expected: {status: 201, includes: 'CONTROL_OK'}}]}), {mode: 0o600});
const group = await DockerGroup.create(endpoint, `staged-${Date.now()}`, dir, Date.now() + 90_000, staged, watchdog);
try {
const verifier = await group.createContainer({
role: 'verifier', image: staged, command: ['/bin/sleep', '2147483647'],
readonlyFiles: [{host: positivePath, container: '/policy/positive.json'}, {host: negativePath, container: '/policy/broken-control.json'}],
});
await group.start(verifier);
for (const [tool, version] of Object.entries(versions)) {
const result = await group.execAttach(group.anchor, stack[tool]);
const result = await group.execAttach(verifier, stack[tool]);
expect(result.code).toBe(0);
const expected = `${prefixes[tool]}${version}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expect(result.output.trim()).toMatch(new RegExp(`^${expected}(?:$|\\s)`));
@@ -80,11 +85,6 @@ suite('CSO Docker containment integration',()=>{
}
const server = await group.createContainer({role: 'app', image, command: ['/http-server']});
await group.start(server);
const verifier = await group.createContainer({
role: 'verifier', image: staged, command: ['/bin/sleep', '2147483647'],
readonlyFiles: [{host: positivePath, container: '/policy/positive.json'}, {host: negativePath, container: '/policy/broken-control.json'}],
});
await group.start(verifier);
const positive = await group.execAttach(verifier, ['/opt/cso/verifier', '/policy/positive.json']);
expect(positive.code).toBe(0);
expect(JSON.parse(positive.output)).toMatchObject({booted: true, legitimate: true, security: 'pass'});
+3 -3
View File
@@ -24,12 +24,12 @@ beforeAll(() => {
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
describe('compiled scanner evidence persistence', () => {
test('scan uses the empty qualified catalog and records helper-owned coverage without host execution', () => {
test('scan without a locally present qualified image records helper-owned coverage without host execution', () => {
const run = start(), r = command(['scan', run.runId, 'gitleaks']); expect(r.status).toBe(0);
const out = JSON.parse(r.stdout); expect(out.status).toBe('not_assessed'); expect(out.gaps[0].message).toContain('No qualified gitleaks');
const out = JSON.parse(r.stdout); expect(out.status).toBe('not_assessed'); expect(out.gaps[0].message).toMatch(/^No qualified gitleaks|^Pinned runtime image is not already present locally: \S+@sha256:[a-f0-9]{64}$/);
expect(out.artifactId).toMatch(/^gitleaks-[a-f0-9]{16}-[a-f0-9]{16}$/);
expect(run.artifacts()).toEqual([`${out.artifactId}.json`]);
const stored = JSON.parse(fs.readFileSync(join(run.dir,out.artifact), 'utf8')); expect(stored.provenance.image).toBeNull(); expect(stored.outcome).not.toHaveProperty('repair');
const stored = JSON.parse(fs.readFileSync(join(run.dir,out.artifact), 'utf8')); const shipped = JSON.parse(fs.readFileSync(join(import.meta.dir, '../lib/cso/scanner-images/catalog.json'), 'utf8')).scanners.find((p: any) => p.scanner === 'gitleaks' && p.platform === (process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64')); expect(stored.provenance.image).toBe(shipped?.image ?? null); expect(stored.outcome).not.toHaveProperty('repair');
expect(run.report().coverage.find((x: any) => x.domain === 'scanner:gitleaks')).toMatchObject({ status: 'not_assessed' });
});
test('trivy SARIF whose ROOTPATH is the started checkout imports its findings (#3011)', () => {
+9 -2
View File
@@ -29,6 +29,7 @@ function catalog(..._ids: ScannerId[]): ScannerCatalog {
const scanners = SCANNER_IDS.flatMap(id => [profile(id), profile(id, 'linux/arm64')]);
return { schemaVersion: 1, helperAbi: 3, revision: 'unit-fixture-only', promotion: { sourceCommit: 'b'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', evidenceDigest: `sha256:${sha256(canonical(scanners))}` }, scanners };
}
const EMPTY_CATALOG = { schemaVersion: 1, revision: 'cso-scanners-v3-unqualified', previousRevision: null, helperAbi: 3, scanners: [] } as unknown as ScannerCatalog;
const scannerProfile = (c: ScannerCatalog, id: ScannerId, platform: 'linux/amd64' | 'linux/arm64' = 'linux/amd64') => c.scanners.find(item => item.scanner === id && item.platform === platform)!;
const runtimes = completeRuntimeCatalogFixture('scanner-runtime-fixture');
function request(): ScannerRequest {
@@ -60,7 +61,13 @@ function result(id: ScannerId): ScannerExecution {
}
describe('reviewed scanner catalog contract', () => {
test('the shipped catalog has no executable unqualified fallback', () => { expect(SCANNER_CATALOG.scanners).toEqual([]); expect(() => selectScanner('gitleaks', 'linux/amd64')).toThrow('No qualified'); });
test('the shipped catalog has no executable unqualified fallback', () => {
validateScannerCatalog(SCANNER_CATALOG);
for (const profile of SCANNER_CATALOG.scanners) expect([profile.state, profile.image]).toEqual(['qualified', expect.stringMatching(/@sha256:[a-f0-9]{64}$/)]);
for (const id of SCANNER_IDS) for (const platform of ['linux/amd64', 'linux/arm64'] as const)
if (!SCANNER_CATALOG.scanners.some(profile => profile.scanner === id && profile.platform === platform)) expect(() => selectScanner(id, platform)).toThrow('No qualified');
expect(() => selectScanner('gitleaks', 'linux/amd64', undefined, EMPTY_CATALOG)).toThrow('No qualified');
});
test.each(SCANNER_IDS)('selects a qualified immutable %s profile', id => { const c = catalog(id); validateScannerCatalog(c); expect(selectScanner(id, 'linux/amd64', undefined, c)).toEqual(scannerProfile(c, id)); });
test.each(['tag', 'entrypoint', 'policy', 'abi', 'qualification', 'executable', 'version', 'capabilities'])('rejects unreviewed %s', field => {
const c = catalog('gitleaks'), p = scannerProfile(c, 'gitleaks') as any;
@@ -110,7 +117,7 @@ describe('all six helper-owned scanner execution paths', () => {
if (id === 'osv' || id === 'trivy') expect(record.outcome.databaseUpdatedAt).toBe('2026-09-09T00:00:00.000Z');
});
test('missing catalog never creates a runner and records the exact prerequisite', async () => {
let calls = 0; const out = await executeScanner(input('gitleaks'), { runnerFactory: async () => { calls++; throw new Error('must not run'); } });
let calls = 0; const out = await executeScanner(input('gitleaks'), { catalog: EMPTY_CATALOG, runnerFactory: async () => { calls++; throw new Error('must not run'); } });
expect(calls).toBe(0); expect(out.coverage.status).toBe('not_assessed'); expect(out.outcome.gaps[0].message).toContain('No qualified gitleaks');
});
test.each(['semgrep', 'osv', 'trivy'] as ScannerId[])('%s missing baked assets cannot launch a scanner or download replacements', async id => {
+35
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -140,3 +141,37 @@ describe('CSO scanner qualification workflow', () => {
for (const value of ['FROM ${BASE_IMAGE} AS upstream', 'FROM scratch', 'COPY --from=upstream / /']) expect(postgres).toContain(value);
});
});
const SBOM_ROOT = path.resolve(import.meta.dir, '..');
const jq = Bun.which('jq');
const run = (filter: string, input: unknown, check = false) => spawnSync(jq!, [check ? '-e' : '-c', '-f', path.join(SBOM_ROOT, 'scripts', filter)], { input: JSON.stringify(input), encoding: 'utf8', timeout: 10_000 });
const document = {
spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', name: 'image',
packages: [{ SPDXID: 'SPDXRef-Package-rails', name: 'rails', versionInfo: '8.0.2', hasFiles: ['SPDXRef-File-a'] }, { SPDXID: 'SPDXRef-Package-rack', name: 'rack', versionInfo: '3.1.0' }],
files: [{ SPDXID: 'SPDXRef-File-a', fileName: '/usr/local/bundle/gems/rails/a.rb' }, { SPDXID: 'SPDXRef-File-b', fileName: '/b' }],
relationships: [
{ spdxElementId: 'SPDXRef-DOCUMENT', relationshipType: 'DESCRIBES', relatedSpdxElement: 'SPDXRef-Package-rails' },
{ spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'DEPENDS_ON', relatedSpdxElement: 'SPDXRef-Package-rack' },
{ spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'CONTAINS', relatedSpdxElement: 'SPDXRef-File-a' },
{ spdxElementId: 'SPDXRef-File-b', relationshipType: 'OTHER', relatedSpdxElement: 'SPDXRef-Package-rack' },
],
};
describe.skipIf(!jq)('CSO signed SBOMs carry the package inventory only', () => {
test('file entries and every relationship that names a file are removed; packages and package edges stay', () => {
const result = run('cso-sbom-packages.jq', document);
expect(result.status, result.stderr).toBe(0);
const compact = JSON.parse(result.stdout);
expect(compact.files).toBeUndefined();
expect(compact.packages.map((p: any) => [p.name, p.versionInfo, 'hasFiles' in p])).toEqual([['rails', '8.0.2', false], ['rack', '3.1.0', false]]);
expect(compact.relationships.map((r: any) => r.relationshipType)).toEqual(['DESCRIBES', 'DEPENDS_ON']);
expect(run('cso-sbom-packages-check.jq', compact, true).status).toBe(0);
});
test('the check rejects file-bearing, empty, wrong-version, or dangling documents', () => {
expect(run('cso-sbom-packages-check.jq', document, true).status).not.toBe(0);
const compact = JSON.parse(run('cso-sbom-packages.jq', document).stdout);
expect(run('cso-sbom-packages-check.jq', { ...compact, packages: [] }, true).status).not.toBe(0);
expect(run('cso-sbom-packages-check.jq', { ...compact, spdxVersion: 'SPDX-2.2' }, true).status).not.toBe(0);
expect(run('cso-sbom-packages-check.jq', { ...compact, relationships: [...compact.relationships, { spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'CONTAINS', relatedSpdxElement: 'SPDXRef-File-gone' }] }, true).status).not.toBe(0);
});
});
+1
View File
@@ -63,6 +63,7 @@ describe('private state and process output',()=>{
test('private state rejects a non-sticky group-writable ancestor',()=>{if(process.platform==='win32')return;const base=tmp();fs.chmodSync(base,0o770);expect(()=>secureDirectory(path.join(base,'private'))).toThrow('group- or world-writable ancestor');});
test('split process output is joined and redacted before return',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf 'ghp_abcdefghijklmnop'; printf 'qrstuvwxyz1234567890' >&2"],{cwd:dir,env:{PATH:'/usr/bin:/bin'}});expect(r.stdout+r.stderr).not.toContain('ghp_');expect(r.stdout.toLowerCase()).toContain('redacted');});
test('alternating stdout and stderr chunks cannot split a secret marker',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf g; sleep .02; printf hp_abcdefghijklmnop >&2; sleep .02; printf qrstuvwxyz1234567890"],{cwd:dir,env:{PATH:'/usr/bin:/bin'}});expect(r.stdout+r.stderr).not.toContain('abcdefghijklmnop');expect(r.stdout.toLowerCase()).toContain('redacted');});
test('qualified catalog workflow run URLs persist unchanged while the same digits elsewhere stay redacted',()=>{const url='https://github.com/garrytan/gstack/actions/runs/37249065172';expect(sanitizeHelperForJson({qualification:{workflow:url}})).toEqual({qualification:{workflow:url}});expect(JSON.stringify(sanitizeHelperForJson({note:url}))).not.toContain('37249065172');expect(JSON.stringify(sanitizeHelperForJson({workflow:`${url}?call=37249065172`}))).not.toContain('call=37249065172');for(const [key,revision] of [['scannerCatalog','cso-scanners-f97aefe654fb-37249065172'],['catalog','cso-v3-37249065172-f97aefe654fb'],['catalogRevision','cso-eval-37249065172-f97aefe654fb']])expect(sanitizeHelperForJson({[key]:revision})).toEqual({[key]:revision});expect(JSON.stringify(sanitizeHelperForJson({catalog:'call 37249065172'}))).not.toContain('37249065172');});
test('splice redaction keeps structured scanner output and marks only located spans',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf '{\"url\":\"https://security.netapp.com/advisory/ntap-20210312-0006\",\"ok\":true}'; printf 'log line' >&2"],{cwd:dir,env:{PATH:'/usr/bin:/bin'},redaction:'splice'});expect(r.stdout).not.toContain('20210312-0006');expect(JSON.parse(r.stdout).ok).toBe(true);expect(r.stdout).toContain('<REDACTED-');expect(r.stderr).toBe('log line');});
test('splice redaction still withholds a secret split across channels',async()=>{const dir=tmp();for(const script of ["printf 'ghp_abcdefghijklmnop'; printf 'qrstuvwxyz1234567890' >&2","printf g; sleep .02; printf hp_abcdefghijklmnop >&2; sleep .02; printf qrstuvwxyz1234567890"]){const r=await runProcess('/bin/sh',['-c',script],{cwd:dir,env:{PATH:'/usr/bin:/bin'},redaction:'splice'});expect(r.stdout+r.stderr).not.toContain('abcdefghijklmnop');expect(r.stdout).toBe('[sensitive process output redacted]');}});
test('splice redaction replaces a whole-channel secret instead of leaking it',async()=>{const dir=tmp(),token=['ghp','abcdefghijklmnopqrstuvwxyz1234567890'].join('_'),r=await runProcess('/bin/sh',['-c',`printf 'token=${token} done'`],{cwd:dir,env:{PATH:'/usr/bin:/bin'},redaction:'splice'});expect(r.stdout).not.toContain('abcdefghijklmnop');expect(r.stdout).toContain('done');});