diff --git a/.github/workflows/cso-runtime-images.yml b/.github/workflows/cso-runtime-images.yml index 6505a1910..e9c3259f0 100644 --- a/.github/workflows/cso-runtime-images.yml +++ b/.github/workflows/cso-runtime-images.yml @@ -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: diff --git a/.github/workflows/cso-scanner-images.yml b/.github/workflows/cso-scanner-images.yml index 2d7a172e2..11613d586 100644 --- a/.github/workflows/cso-scanner-images.yml +++ b/.github/workflows/cso-scanner-images.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c917e58..ecf2db2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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--`) 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.** diff --git a/VERSION b/VERSION index aae93d7ed..816ffb756 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.25.0 +1.91.27.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d816a5c03..551c1eec7 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/lib/cso/process.ts b/lib/cso/process.ts index d450ca539..6f3b7a77f 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -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, trustedMetadata: boolean): unknown { diff --git a/package.json b/package.json index 2df04d323..1c8758aed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/cso-sbom-packages-check.jq b/scripts/cso-sbom-packages-check.jq new file mode 100644 index 000000000..ef5ec58c3 --- /dev/null +++ b/scripts/cso-sbom-packages-check.jq @@ -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-"))))) diff --git a/scripts/cso-sbom-packages.jq b/scripts/cso-sbom-packages.jq new file mode 100644 index 000000000..cd0ce2266 --- /dev/null +++ b/scripts/cso-sbom-packages.jq @@ -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))) diff --git a/test/cso-docker-integration.test.ts b/test/cso-docker-integration.test.ts index b09abea62..dfa8a6591 100644 --- a/test/cso-docker-integration.test.ts +++ b/test/cso-docker-integration.test.ts @@ -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'}); diff --git a/test/cso-scanner-cli.test.ts b/test/cso-scanner-cli.test.ts index 9766bc345..ab58b8774 100644 --- a/test/cso-scanner-cli.test.ts +++ b/test/cso-scanner-cli.test.ts @@ -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)', () => { diff --git a/test/cso-scanner-executor.test.ts b/test/cso-scanner-executor.test.ts index 86f97f2e6..6024462d8 100644 --- a/test/cso-scanner-executor.test.ts +++ b/test/cso-scanner-executor.test.ts @@ -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 => { diff --git a/test/cso-scanner-release.test.ts b/test/cso-scanner-release.test.ts index 9bee9e1e8..055ae7c75 100644 --- a/test/cso-scanner-release.test.ts +++ b/test/cso-scanner-release.test.ts @@ -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); + }); +}); diff --git a/test/cso-snapshot-state.test.ts b/test/cso-snapshot-state.test.ts index 20d866061..9b6d376d8 100644 --- a/test/cso-snapshot-state.test.ts +++ b/test/cso-snapshot-state.test.ts @@ -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('{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');});