From 1c411f76941f2b48f3e8736f04107889f1790973 Mon Sep 17 00:00:00 2001 From: omermorad Date: Tue, 29 Sep 2026 09:06:53 +0300 Subject: [PATCH] fix(*): address Advanced Security findings on next --- .github/workflows/release-preview.yml | 18 ++---- .../governance/linters/json-schema-ajv.ts | 23 +++++-- tests/e2e/22-advanced-security.test.ts | 63 +++++++++++++++++++ 3 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 tests/e2e/22-advanced-security.test.ts diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 46b46ae..669cd2e 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -3,14 +3,6 @@ name: Release Preview on: workflow_dispatch: inputs: - target_branch: - description: 'Branch to preview release from' - type: choice - options: - - 'next' - - 'master' - required: true - default: 'next' release_type: description: 'Release Type' type: choice @@ -38,13 +30,15 @@ permissions: jobs: preview: name: Preview Release + if: github.ref == 'refs/heads/next' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.target_branch }} + ref: next fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: actions/setup-node@v4 @@ -82,7 +76,7 @@ jobs: --preid ${{ inputs.preid }} \ --no-git-tag-version \ --no-push \ - --allow-branch ${{ inputs.target_branch }} + --allow-branch next - name: Preview Versions (Graduate) if: ${{ inputs.release_type == 'graduate' }} @@ -91,7 +85,7 @@ jobs: --conventional-graduate \ --no-git-tag-version \ --no-push \ - --allow-branch ${{ inputs.target_branch }} + --allow-branch next - name: Preview Versions (Auto - Conventional Commits) if: ${{ inputs.release_type == 'auto' }} @@ -100,7 +94,7 @@ jobs: --conventional-commits \ --no-git-tag-version \ --no-push \ - --allow-branch ${{ inputs.target_branch }} + --allow-branch next - name: Generate Version Summary run: | diff --git a/packages/governance/linters/json-schema-ajv.ts b/packages/governance/linters/json-schema-ajv.ts index defa954..5c7d530 100644 --- a/packages/governance/linters/json-schema-ajv.ts +++ b/packages/governance/linters/json-schema-ajv.ts @@ -107,12 +107,25 @@ export async function lintJsonSchema( // Warn about unknown $schema versions if (schema.$schema && typeof schema.$schema === 'string') { const schemaUri = schema.$schema; - const supportedDrafts = ['draft-04', 'draft-06', 'draft-07', 'draft/2019-09', 'draft/2020-12']; - const isKnown = supportedDrafts.some( - (draft) => schemaUri.includes(draft) || schemaUri.includes(draft.replace('draft-', 'draft/')) - ); + let isKnown = false; + try { + const uri = new URL(schemaUri); + const paths = [ + '/draft-04/schema', + '/draft-06/schema', + '/draft-07/schema', + '/draft/2019-09/schema', + '/draft/2020-12/schema', + ]; + isKnown = + ['http:', 'https:'].includes(uri.protocol) && + uri.hostname === 'json-schema.org' && + paths.includes(uri.pathname); + } catch { + // Malformed identifiers are unrecognized drafts. + } - if (!isKnown && !schemaUri.includes('json-schema.org')) { + if (!isKnown) { warnings.push({ path: '/$schema', message: `Unknown schema draft: ${schemaUri}. Validation may be incomplete.`, diff --git a/tests/e2e/22-advanced-security.test.ts b/tests/e2e/22-advanced-security.test.ts new file mode 100644 index 0000000..1c8b44d --- /dev/null +++ b/tests/e2e/22-advanced-security.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parse } from 'yaml'; +import { lintJsonSchema } from '../../packages/governance/linters/json-schema-ajv.js'; + +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('schema draft URL recognition', () => { + test.each([ + ['http://json-schema.org/draft-04/schema#', false], + ['https://json-schema.org/draft-06/schema#', false], + ['https://json-schema.org/draft-07/schema#', false], + ['https://json-schema.org/draft/2019-09/schema', false], + ['https://json-schema.org/draft/2020-12/schema', false], + ['https://json-schema.org.evil.test/draft-07/schema', true], + ['https://evil-json-schema.org/draft-07/schema', true], + ['https://evil.test/json-schema.org/draft-07/schema', true], + ['https://evil.test/?draft-07=json-schema.org', true], + ['https://json-schema.org@evil.test/draft-07/schema', true], + ['https://json-schema.org/unknown/schema', true], + ['https://json-schema.org/draft-07/schema/extra', true], + ['ftp://json-schema.org/draft-07/schema', true], + ['not-a-url', true], + ])('recognizes only supported drafts on the official host: %s', async ($schema, unknown) => { + const directory = mkdtempSync(join(tmpdir(), 'contractual-schema-uri-')); + directories.push(directory); + const path = join(directory, 'schema.json'); + writeFileSync(path, JSON.stringify({ $schema, type: 'object' })); + const result = await lintJsonSchema(path, { skipMetaValidation: true, skipStyleRules: true }); + expect(result.warnings.some((issue) => issue.rule === 'unknown-draft')).toBe(unknown); + }); +}); + +describe('release-preview trust boundary', () => { + const workflow = parse( + readFileSync(new URL('../../.github/workflows/release-preview.yml', import.meta.url), 'utf8') + ); + + test('only executes trusted next code, without a caller-selected checkout', () => { + expect(workflow.on.workflow_dispatch.inputs).not.toHaveProperty('target_branch'); + expect(workflow.jobs.preview.if).toBe("github.ref == 'refs/heads/next'"); + const checkout = workflow.jobs.preview.steps.find((step: { uses?: string }) => + step.uses?.startsWith('actions/checkout@') + ); + expect(checkout.with.ref).toBe('next'); + expect(checkout.with['persist-credentials']).toBe(false); + }); + + test('keeps read-only permissions and does not restore or save caches', () => { + expect(workflow.permissions).toEqual({ contents: 'read' }); + for (const step of workflow.jobs.preview.steps) { + expect(step.uses || '').not.toMatch(/^actions\/cache(?:\/|@)/); + expect(step.with?.cache).toBeUndefined(); + } + }); +});