Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ on:
branches: [main, develop]
workflow_dispatch:

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
test:
name: Test
Expand Down Expand Up @@ -525,6 +530,10 @@ jobs:
governance-guards:
name: Governance guards (GT-578)
runs-on: ubuntu-latest
# Guard 50 lists the open pull requests and reads each one's diff.
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/docker-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ on:
tags:
- "v*"

# Scorecard Token-Permissions: read-only by default; the build job below is the
# one that pushes to GHCR and declares `packages: write` itself.
permissions:
contents: read
packages: write

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/docs-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ on:
- 'release/docs-*'
- 'hotfix/docs-*'

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
release-trigger-check:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Expand Down Expand Up @@ -59,6 +64,9 @@ jobs:
needs: release-trigger-check
if: needs.release-trigger-check.outputs.version != ''
runs-on: ubuntu-latest
# Commits the version log back to main.
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -93,6 +101,9 @@ jobs:
needs: [release-trigger-check, update-version-log]
if: needs.release-trigger-check.outputs.version != ''
runs-on: ubuntu-latest
# Pushes the tag and creates the GitHub Release.
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/enforce-root-cleanliness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
branches:
- main

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
validate-root:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/reliability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ defaults:
run:
shell: bash

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
k6-load:
name: k6 load profile (thresholds + published metrics)
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/sdk-cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ env:
# Bumping it is a reviewed change; guard 60 re-derives nothing from it.
GITLEAKS_VERSION: '8.30.1'

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
# ============================================
# JOB 1: Evolith Core Validation
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/sdk-cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ env:
NODE_VERSION: '20'
CLI_DIR: 'src/sdk/cli'

# Scorecard Token-Permissions: the default GITHUB_TOKEN is read-only for every job;
# a job that has to write declares exactly what it writes on itself.
permissions:
contents: read

jobs:
# ============================================
# GATE 0: Evolith Core Validation
Expand Down Expand Up @@ -629,6 +634,9 @@ jobs:
# a GitHub Release must not be cut when the npm publish failed.
if: ${{ needs.release-gate.outputs.release_created }}
runs-on: ubuntu-latest
# Creates the GitHub Release and uploads the binaries to it.
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,42 @@ export class WorkspaceReferenceResolverService {
return resolved;
}

/**
* Legacy `satellitePath` (pre-ADR-0101 callers still send a filesystem path).
* Accepted only when it resolves INSIDE `WORKSPACE_ROOT` — relative to it or
* absolute beneath it — so a caller cannot point the Core at `/etc` or at the
* Core's own checkout. Anything else is a 400 that names `workspaceRef` as the
* replacement.
*/
resolveLegacyPath(rawPath: string, field = 'satellitePath'): string {
if (typeof rawPath !== 'string' || rawPath.length === 0 || rawPath.length > 1024 || rawPath.includes('\0')) {
throw new BadRequestException(`${field} must be a non-empty path`);
}
const root = path.resolve(this.config.getOrThrow('WORKSPACE_ROOT'));
const resolved = path.resolve(root, rawPath);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new BadRequestException(
`${field} resolves outside the workspace root; send an opaque workspaceRef instead`,
);
}
return resolved;
}

/**
* A caller-supplied `corePath` override. The Core rules live at the configured
* `CORE_PATH`; the only other place a request may point at is a Core checkout
* inside its own workspace. Absent → the configured path.
*/
resolveCorePathOverride(rawPath: string | undefined): string {
const configured = path.resolve(this.corePath());
if (rawPath === undefined || rawPath === null || rawPath === '') return configured;
if (typeof rawPath !== 'string' || rawPath.includes('\0')) {
throw new BadRequestException('corePath must be a path');
}
if (path.resolve(rawPath) === configured) return configured;
return this.resolveLegacyPath(rawPath, 'corePath');
}

corePath(): string {
return this.config.getOrThrow('CORE_PATH');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,25 @@ describe('ArchitectureController', () => {
expect(validateUseCase.execute).toHaveBeenCalledWith({
satellitePath: '/workspaces/op_01',
corePath: '/core',
manifest: undefined,
});
expect(workspaceResolver.resolve).toHaveBeenCalledWith('op_01');
});

// CWE-22: the manifest wins inside the use case and carried its own paths,
// so a client could evaluate any directory by sending them in the manifest.
it('pins the manifest paths to the resolved workspace and the configured Core', async () => {
await controller.validateSatellite({
workspaceRef: 'op_01',
manifest: { satellitePath: '/etc', corePath: '/somewhere/else', topology: 'modular-monolith' } as any,
});
expect(validateUseCase.execute).toHaveBeenCalledWith({
satellitePath: '/workspaces/op_01',
corePath: '/core',
manifest: { satellitePath: '/workspaces/op_01', corePath: '/core', topology: 'modular-monolith' },
});
});

it('should propagate validation errors', async () => {
validateUseCase.execute.mockRejectedValue(new Error('Invalid satellite'));
await expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,15 @@ export class ArchitectureController {
@ApiBody({ type: ValidateSatelliteDto })
@ApiEnvelopeResponse(undefined, { description: 'Validation results or ADR-0073 envelope if manifest is provided' })
async validateSatellite(@Body() body: ValidateSatelliteDto) {
const satellitePath = this.workspaceResolver.resolve(body.workspaceRef);
const corePath = this.workspaceResolver.corePath();
const response = await this.validateSatelliteUseCase.execute({
satellitePath: this.workspaceResolver.resolve(body.workspaceRef),
corePath: this.workspaceResolver.corePath(),
manifest: body.manifest,
satellitePath,
corePath,
// CWE-22: a manifest takes precedence in the use case and carries its own
// `satellitePath`/`corePath`, so those are pinned to the resolved ones —
// the client's values never reach the filesystem.
manifest: body.manifest ? { ...body.manifest, satellitePath, corePath } : undefined,
});
return response.evaluationVerdict?.outputEnvelope ?? response.result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ const configParser = {
stringify: (data: unknown) => JSON.stringify(data),
};
const rulesetRepo = { getRuleset: async () => null, listRulesets: async () => [] } as any;
const workspaceResolver = { corePath: () => CORE_PATH } as any;
const workspaceResolver = {
corePath: () => CORE_PATH,
resolveCorePathOverride: (raw?: string) => raw ?? CORE_PATH,
} as any;

function buildController(fallback: IFileSystem) {
return new EvaluationController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ValidateSatelliteUseCase } from '@beyondnet/evolith-core-domain/applica
import { EvaluationOrchestrator } from '@beyondnet/evolith-core-domain/evaluation';
import type { DirEntry, IFileSystem } from '@beyondnet/evolith-core-domain/domain/interfaces';
import { EvaluateSatelliteDto, EvaluationContextDto } from '../dtos/evaluation.dto';
import { WorkspaceReferenceResolverService } from '../../application/services/workspace-reference-resolver.service';

const SAMPLE_GATE_EVAL = {
artifactEvaluations: [
Expand Down Expand Up @@ -62,6 +63,14 @@ describe('EvaluationController (GT-361)', () => {
providers: [
{ provide: EvaluationOrchestrator, useValue: orchestrator },
{ provide: ValidateSatelliteUseCase, useValue: mockUseCase },
// The real resolver over a fake config: the legacy branch must contain
// `satellitePath` under WORKSPACE_ROOT and `corePath` to CORE_PATH.
{
provide: WorkspaceReferenceResolverService,
useValue: new WorkspaceReferenceResolverService({
getOrThrow: (key: string) => ({ WORKSPACE_ROOT, CORE_PATH: CORE })[key],
} as any),
},
],
}).compile();

Expand All @@ -71,8 +80,11 @@ describe('EvaluationController (GT-361)', () => {

afterEach(() => jest.clearAllMocks());

const WORKSPACE_ROOT = '/workspaces';
const CORE = '/core';

const body: EvaluateSatelliteDto = {
satellitePath: '/satellite',
satellitePath: '/workspaces/satellite',
corePath: '/core',
topology: 'modular-monolith',
phase: 'f1',
Expand Down Expand Up @@ -132,39 +144,72 @@ describe('EvaluationController (GT-361)', () => {
await controller.evaluate(body);

expect(useCase.execute).toHaveBeenCalledWith({
satellitePath: '/satellite',
satellitePath: '/workspaces/satellite',
corePath: '/core',
manifest: {
satellitePath: '/satellite',
satellitePath: '/workspaces/satellite',
corePath: '/core',
topology: 'modular-monolith',
phase: 'f1',
},
});
});

it('works with minimal body (only satellitePath)', async () => {
it('works with minimal body (only satellitePath); corePath defaults to the configured Core', async () => {
const minimalEnvelope = { ...SAMPLE_ENVELOPE };
useCase.execute.mockResolvedValue({
result: {} as any,
evaluationVerdict: { ...({} as any), outputEnvelope: minimalEnvelope },
});

const minimalBody: EvaluateSatelliteDto = { satellitePath: '/satellite' };
// Relative legacy paths resolve against WORKSPACE_ROOT.
const minimalBody: EvaluateSatelliteDto = { satellitePath: 'satellite' };
const response = await controller.evaluate(minimalBody);

expect(useCase.execute).toHaveBeenCalledWith({
satellitePath: '/satellite',
corePath: undefined,
satellitePath: '/workspaces/satellite',
corePath: '/core',
manifest: {
satellitePath: '/satellite',
corePath: undefined,
satellitePath: '/workspaces/satellite',
corePath: '/core',
topology: undefined,
phase: undefined,
},
});
expect(response).toBe(minimalEnvelope);
});

// CWE-22: the legacy fields used to reach the filesystem verbatim, so an
// authenticated caller could evaluate (read) any directory on the host.
it('refuses a legacy satellitePath outside WORKSPACE_ROOT before touching the use case', async () => {
for (const satellitePath of ['/etc', '../outside', '/workspaces/../etc', '/workspacesX/sat']) {
await expect(controller.evaluate({ satellitePath })).rejects.toThrow(/outside the workspace root/);
}
expect(useCase.execute).not.toHaveBeenCalled();
});

it('refuses a legacy corePath that is neither the configured Core nor inside the workspace', async () => {
await expect(
controller.evaluate({ satellitePath: 'sat', corePath: '/somewhere/else' }),
).rejects.toThrow(/corePath resolves outside the workspace root/);
expect(useCase.execute).not.toHaveBeenCalled();

// A Core checkout inside the workspace is still an allowed override.
useCase.execute.mockResolvedValue({
result: {} as any,
evaluationVerdict: { ...({} as any), outputEnvelope: SAMPLE_ENVELOPE },
});
await controller.evaluate({ satellitePath: 'sat', corePath: '/workspaces/core-checkout' });
expect(useCase.execute).toHaveBeenCalledWith(
expect.objectContaining({ satellitePath: '/workspaces/sat', corePath: '/workspaces/core-checkout' }),
);
});

it('fails closed when the instance has no workspace resolver', async () => {
const bare = new EvaluationController({ evaluate: jest.fn() } as any, useCase);
await expect(bare.evaluate({ satellitePath: '/workspaces/satellite' })).rejects.toThrow(/not available on this Core instance/);
expect(useCase.execute).not.toHaveBeenCalled();
});
});

describe('canonical EvaluationContext path (GT-378)', () => {
Expand Down Expand Up @@ -268,7 +313,9 @@ describe('EvaluationController — inline evaluationInput path', () => {
stringify: (data: unknown) => JSON.stringify(data),
};
const rulesetRepo = { getRuleset: async () => null, listRulesets: async () => [] } as any;
const workspaceResolver = { corePath: () => CORE_PATH } as any;
const workspaceResolver = new WorkspaceReferenceResolverService({
getOrThrow: (key: string) => ({ CORE_PATH, WORKSPACE_ROOT: '/workspaces' })[key],
} as any);

function buildController(fallback: IFileSystem) {
return new EvaluationController(
Expand Down
Loading
Loading