mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 05:56:37 +01:00
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:
1 parent
857466ff8b
commit
10315cf44d
14 files changed
+98
-18
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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-")))))
|
||||
@@ -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)))
|
||||
@@ -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'});
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');});
|
||||
|
||||
Reference in new issue
Block a user