From 2f72eb7b302533cead8494f6824eac8a1d54931e Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Fri, 26 Dec 2025 02:34:08 +0100 Subject: [PATCH 01/13] feat(scan): add test metadata support --- packages/runner/src/lib/SecScanOptions.ts | 1 + packages/scan/src/ScanFactory.ts | 4 +++- packages/scan/src/ScanSettings.ts | 15 +++++++++++++++ packages/scan/src/models/ScanConfig.ts | 1 + 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/runner/src/lib/SecScanOptions.ts b/packages/runner/src/lib/SecScanOptions.ts index a94856c6..f88a2b44 100644 --- a/packages/runner/src/lib/SecScanOptions.ts +++ b/packages/runner/src/lib/SecScanOptions.ts @@ -9,4 +9,5 @@ export type SecScanOptions = Pick< | 'skipStaticParams' | 'attackParamLocations' | 'starMetadata' + | 'testMetadata' >; diff --git a/packages/scan/src/ScanFactory.ts b/packages/scan/src/ScanFactory.ts index ab70f7cc..321af354 100644 --- a/packages/scan/src/ScanFactory.ts +++ b/packages/scan/src/ScanFactory.ts @@ -43,7 +43,8 @@ export class ScanFactory { requestsRateLimit, skipStaticParams, attackParamLocations, - starMetadata + starMetadata, + testMetadata }: ScanSettings): Promise { const { id: entrypointId } = await this.discoveries.createEntrypoint( new Target(target), @@ -57,6 +58,7 @@ export class ScanFactory { requestsRateLimit, skipStaticParams, starMetadata, + testMetadata, projectId: this.configuration.projectId, entryPointIds: [entrypointId], attackParamLocations: [...attackParamLocations], diff --git a/packages/scan/src/ScanSettings.ts b/packages/scan/src/ScanSettings.ts index 71ac1f4a..6a292f45 100644 --- a/packages/scan/src/ScanSettings.ts +++ b/packages/scan/src/ScanSettings.ts @@ -26,10 +26,15 @@ export interface ScanSettingsOptions { * @internal */ starMetadata?: Record; + /** + * Additional metadata for specific tests (e.g. broken access control). + */ + testMetadata?: Record; } export class ScanSettings implements ScanSettingsOptions { private _starMetadata?: Record; + private _testMetadata?: Record; get starMetadata(): Record | undefined { return this._starMetadata; @@ -39,6 +44,14 @@ export class ScanSettings implements ScanSettingsOptions { this._starMetadata = value; } + get testMetadata(): Record | undefined { + return this._testMetadata; + } + + private set testMetadata(value: Record | undefined) { + this._testMetadata = value; + } + private _name!: string; get name(): string { @@ -157,6 +170,7 @@ export class ScanSettings implements ScanSettingsOptions { repeaterId, smart = true, starMetadata, + testMetadata, requestsRateLimit = 0, // automatic rate limiting poolSize = 50, // up to 2x more than default pool size skipStaticParams = true, @@ -173,6 +187,7 @@ export class ScanSettings implements ScanSettingsOptions { this.tests = tests; this.attackParamLocations = attackParamLocations; this.starMetadata = starMetadata; + this.testMetadata = testMetadata; } private resolveAttackParamLocations( diff --git a/packages/scan/src/models/ScanConfig.ts b/packages/scan/src/models/ScanConfig.ts index 67d4204d..a66d4d32 100644 --- a/packages/scan/src/models/ScanConfig.ts +++ b/packages/scan/src/models/ScanConfig.ts @@ -12,4 +12,5 @@ export interface ScanConfig { smart?: boolean; skipStaticParams?: boolean; starMetadata?: Record; + testMetadata?: Record; } From 0e2a3743178200127ded7e06de701454b3b31e19 Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 31 Dec 2025 20:32:31 +0100 Subject: [PATCH 02/13] refactor tests metadata --- packages/runner/src/lib/IssueFound.ts | 5 +- packages/scan/src/ScanFactory.ts | 4 +- packages/scan/src/ScanSettings.spec.ts | 102 ++++++++++++++++++++++ packages/scan/src/ScanSettings.ts | 40 ++++----- packages/scan/src/models/ScanConfig.ts | 4 +- packages/scan/src/models/Severity.spec.ts | 4 +- packages/scan/src/models/Tests.ts | 14 +++ packages/scan/src/models/index.ts | 1 + 8 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 packages/scan/src/models/Tests.ts diff --git a/packages/runner/src/lib/IssueFound.ts b/packages/runner/src/lib/IssueFound.ts index b68d07c6..e4ad9e63 100644 --- a/packages/runner/src/lib/IssueFound.ts +++ b/packages/runner/src/lib/IssueFound.ts @@ -3,7 +3,10 @@ import { SecTesterError } from '@sectester/core'; import { Issue } from '@sectester/scan'; export class IssueFound extends SecTesterError { - constructor(public readonly issue: Issue, formatter: Formatter) { + constructor( + public readonly issue: Issue, + formatter: Formatter + ) { super(`Target is vulnerable\n\n${formatter.format(issue)}`); } } diff --git a/packages/scan/src/ScanFactory.ts b/packages/scan/src/ScanFactory.ts index 321af354..ab70f7cc 100644 --- a/packages/scan/src/ScanFactory.ts +++ b/packages/scan/src/ScanFactory.ts @@ -43,8 +43,7 @@ export class ScanFactory { requestsRateLimit, skipStaticParams, attackParamLocations, - starMetadata, - testMetadata + starMetadata }: ScanSettings): Promise { const { id: entrypointId } = await this.discoveries.createEntrypoint( new Target(target), @@ -58,7 +57,6 @@ export class ScanFactory { requestsRateLimit, skipStaticParams, starMetadata, - testMetadata, projectId: this.configuration.projectId, entryPointIds: [entrypointId], attackParamLocations: [...attackParamLocations], diff --git a/packages/scan/src/ScanSettings.spec.ts b/packages/scan/src/ScanSettings.spec.ts index 31564665..42d8fb86 100644 --- a/packages/scan/src/ScanSettings.spec.ts +++ b/packages/scan/src/ScanSettings.spec.ts @@ -187,5 +187,107 @@ describe('ScanSettings', () => { name: expect.stringMatching(/^.{1,199}…$/) }); }); + + it('should handle broken access control test with string auth', () => { + // arrange + const testConfig = { + name: 'broken_access_control' as const, + options: { + auth: 'auth-object-id' + } + }; + const settings: ScanSettingsOptions = { + tests: [testConfig], + target: { url: 'https://example.com' } + }; + + // act + const result = new ScanSettings(settings); + + // assert + expect(result.tests).toEqual([testConfig]); + }); + + it('should handle broken access control test with tuple auth', () => { + // arrange + const testConfig = { + name: 'broken_access_control' as const, + options: { + auth: ['key', 'value'] as [string, string] + } + }; + const settings: ScanSettingsOptions = { + tests: [testConfig], + target: { url: 'https://example.com' } + }; + + // act + const result = new ScanSettings(settings); + + // assert + expect(result.tests).toEqual([testConfig]); + }); + + it('should deduplicate string tests', () => { + // arrange + const testName = 'xss'; + const settings: ScanSettingsOptions = { + tests: [testName, testName], + target: { url: 'https://example.com' } + }; + + // act + const result = new ScanSettings(settings); + + // assert + expect(result.tests).toEqual([testName]); + }); + + it('should not deduplicate object tests', () => { + // arrange + const testConfig1 = { + name: 'broken_access_control' as const, + options: { + auth: 'auth1' + } + }; + const testConfig2 = { + name: 'broken_access_control' as const, + options: { + auth: 'auth2' + } + }; + const settings: ScanSettingsOptions = { + tests: [testConfig1, testConfig2], + target: { url: 'https://example.com' } + }; + + // act + const result = new ScanSettings(settings); + + // assert + expect(result.tests).toEqual([testConfig1, testConfig2]); + }); + + it('should handle mixed string and object tests', () => { + // arrange + const testName = 'xss'; + const testConfig = { + name: 'broken_access_control' as const, + options: { + auth: 'auth-object-id' + } + }; + const settings: ScanSettingsOptions = { + tests: [testName, testConfig], + target: { url: 'https://example.com' } + }; + + // act + const result = new ScanSettings(settings); + + // assert + expect(result.tests).toEqual([testName, testConfig]); + }); }); }); diff --git a/packages/scan/src/ScanSettings.ts b/packages/scan/src/ScanSettings.ts index 6a292f45..5b423b18 100644 --- a/packages/scan/src/ScanSettings.ts +++ b/packages/scan/src/ScanSettings.ts @@ -1,10 +1,10 @@ -import { AttackParamLocation, HttpMethod } from './models'; +import { AttackParamLocation, HttpMethod, Test } from './models'; import { Target, TargetOptions } from './target'; import { checkBoundaries, contains, truncate } from '@sectester/core'; export interface ScanSettingsOptions { // The list of tests to be performed against the target application - tests: string[]; + tests: Test[]; // The target that will be attacked target: Target | TargetOptions; // The scan name @@ -26,15 +26,10 @@ export interface ScanSettingsOptions { * @internal */ starMetadata?: Record; - /** - * Additional metadata for specific tests (e.g. broken access control). - */ - testMetadata?: Record; } export class ScanSettings implements ScanSettingsOptions { private _starMetadata?: Record; - private _testMetadata?: Record; get starMetadata(): Record | undefined { return this._starMetadata; @@ -44,14 +39,6 @@ export class ScanSettings implements ScanSettingsOptions { this._starMetadata = value; } - get testMetadata(): Record | undefined { - return this._testMetadata; - } - - private set testMetadata(value: Record | undefined) { - this._testMetadata = value; - } - private _name!: string; get name(): string { @@ -133,20 +120,27 @@ export class ScanSettings implements ScanSettingsOptions { this._requestsRateLimit = value; } - private _tests!: string[]; + private _tests!: Test[]; - get tests(): string[] { + get tests(): Test[] { return this._tests; } - private set tests(value: string[]) { - const uniqueTestTypes = new Set(value); - - if (uniqueTestTypes.size < 1) { + private set tests(value: Test[]) { + if (value.length < 1) { throw new Error('Please provide at least one test.'); } - this._tests = [...uniqueTestTypes]; + // For string tests, ensure uniqueness + const stringTests = value.filter( + (test): test is string => typeof test === 'string' + ); + const uniqueStringTests = [...new Set(stringTests)]; + + // Preserve non-string tests (like BrokenAccessControlTest) + const nonStringTests = value.filter(test => typeof test !== 'string'); + + this._tests = [...uniqueStringTests, ...nonStringTests]; } private _attackParamLocations!: AttackParamLocation[]; @@ -170,7 +164,6 @@ export class ScanSettings implements ScanSettingsOptions { repeaterId, smart = true, starMetadata, - testMetadata, requestsRateLimit = 0, // automatic rate limiting poolSize = 50, // up to 2x more than default pool size skipStaticParams = true, @@ -187,7 +180,6 @@ export class ScanSettings implements ScanSettingsOptions { this.tests = tests; this.attackParamLocations = attackParamLocations; this.starMetadata = starMetadata; - this.testMetadata = testMetadata; } private resolveAttackParamLocations( diff --git a/packages/scan/src/models/ScanConfig.ts b/packages/scan/src/models/ScanConfig.ts index a66d4d32..641130fe 100644 --- a/packages/scan/src/models/ScanConfig.ts +++ b/packages/scan/src/models/ScanConfig.ts @@ -1,10 +1,11 @@ import { AttackParamLocation } from './AttackParamLocation'; +import { Test } from './Tests'; export interface ScanConfig { name: string; projectId: string; entryPointIds: string[]; - tests?: string[]; + tests?: Test[]; poolSize?: number; requestsRateLimit?: number; attackParamLocations?: AttackParamLocation[]; @@ -12,5 +13,4 @@ export interface ScanConfig { smart?: boolean; skipStaticParams?: boolean; starMetadata?: Record; - testMetadata?: Record; } diff --git a/packages/scan/src/models/Severity.spec.ts b/packages/scan/src/models/Severity.spec.ts index 7b829d21..ef696ebc 100644 --- a/packages/scan/src/models/Severity.spec.ts +++ b/packages/scan/src/models/Severity.spec.ts @@ -23,8 +23,8 @@ describe('Severity', () => { item.expected === 0 ? 'zero' : item.expected > 0 - ? 'positive' - : 'negative' + ? 'positive' + : 'negative' })) )( 'should return $expectedLabel comparing $input.a and $input.b', diff --git a/packages/scan/src/models/Tests.ts b/packages/scan/src/models/Tests.ts new file mode 100644 index 00000000..b16b664f --- /dev/null +++ b/packages/scan/src/models/Tests.ts @@ -0,0 +1,14 @@ +export type Test = string | BrokenAccessControlTest; + +export type BrokenAccessControlOptions = + | { + auth: string; // + anon + } + | { + auth: [string, string]; + }; + +export type BrokenAccessControlTest = { + name: 'broken_access_control'; + options: BrokenAccessControlOptions; +}; diff --git a/packages/scan/src/models/index.ts b/packages/scan/src/models/index.ts index 1174366e..1892df1e 100644 --- a/packages/scan/src/models/index.ts +++ b/packages/scan/src/models/index.ts @@ -6,3 +6,4 @@ export * from './IssueGroup'; export * from './ScanState'; export * from './ScanConfig'; export * from './HttpMethod'; +export * from './Tests'; From a89fc3514ca0f54b97b221941aa45c37f06509ba Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Tue, 13 Jan 2026 13:08:01 +0100 Subject: [PATCH 03/13] cleanup code --- packages/scan/src/models/Tests.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/scan/src/models/Tests.ts b/packages/scan/src/models/Tests.ts index b16b664f..52137344 100644 --- a/packages/scan/src/models/Tests.ts +++ b/packages/scan/src/models/Tests.ts @@ -2,10 +2,10 @@ export type Test = string | BrokenAccessControlTest; export type BrokenAccessControlOptions = | { - auth: string; // + anon + auth: string; // scan with axisting user and unauthorized access } | { - auth: [string, string]; + auth: [string, string]; // scan with different authorized users }; export type BrokenAccessControlTest = { From a490b73b61daf760d9d532326675749ec43bd798 Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Tue, 13 Jan 2026 16:39:20 +0100 Subject: [PATCH 04/13] refactor tests --- packages/scan/src/ScanSettings.spec.ts | 21 ++++++++++---------- packages/scan/src/ScanSettings.ts | 27 +++++++++++++++++--------- packages/scan/src/models/Tests.ts | 4 ++-- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/scan/src/ScanSettings.spec.ts b/packages/scan/src/ScanSettings.spec.ts index 42d8fb86..66347e67 100644 --- a/packages/scan/src/ScanSettings.spec.ts +++ b/packages/scan/src/ScanSettings.spec.ts @@ -243,7 +243,7 @@ describe('ScanSettings', () => { expect(result.tests).toEqual([testName]); }); - it('should not deduplicate object tests', () => { + it('should not allow duplicated tests with options', () => { // arrange const testConfig1 = { name: 'broken_access_control' as const, @@ -262,24 +262,23 @@ describe('ScanSettings', () => { target: { url: 'https://example.com' } }; - // act - const result = new ScanSettings(settings); - - // assert - expect(result.tests).toEqual([testConfig1, testConfig2]); + // act & assert + expect(() => new ScanSettings(settings)).toThrow( + 'Duplicate test configuration found: broken_access_control' + ); }); - it('should handle mixed string and object tests', () => { + it('should handle mixed string and configurable tests', () => { // arrange - const testName = 'xss'; - const testConfig = { + const stringTest = 'xss'; + const testWithOptions = { name: 'broken_access_control' as const, options: { auth: 'auth-object-id' } }; const settings: ScanSettingsOptions = { - tests: [testName, testConfig], + tests: [stringTest, testWithOptions], target: { url: 'https://example.com' } }; @@ -287,7 +286,7 @@ describe('ScanSettings', () => { const result = new ScanSettings(settings); // assert - expect(result.tests).toEqual([testName, testConfig]); + expect(result.tests).toEqual([stringTest, testWithOptions]); }); }); }); diff --git a/packages/scan/src/ScanSettings.ts b/packages/scan/src/ScanSettings.ts index 5b423b18..a3922a5a 100644 --- a/packages/scan/src/ScanSettings.ts +++ b/packages/scan/src/ScanSettings.ts @@ -131,16 +131,25 @@ export class ScanSettings implements ScanSettingsOptions { throw new Error('Please provide at least one test.'); } - // For string tests, ensure uniqueness - const stringTests = value.filter( - (test): test is string => typeof test === 'string' - ); - const uniqueStringTests = [...new Set(stringTests)]; - - // Preserve non-string tests (like BrokenAccessControlTest) - const nonStringTests = value.filter(test => typeof test !== 'string'); + const simpleTests = new Set(); + const configurableTests: Test[] = []; + const seenTestConfigurations = new Set(); + + for (const t of value) { + const testName = typeof t === 'string' ? t : t.name; + + if (typeof t === 'string') { + simpleTests.add(t); + } else { + if (seenTestConfigurations.has(testName)) { + throw new Error(`Duplicate test configuration found: ${testName}`); + } + seenTestConfigurations.add(testName); + configurableTests.push(t); + } + } - this._tests = [...uniqueStringTests, ...nonStringTests]; + this._tests = [...simpleTests, ...configurableTests]; } private _attackParamLocations!: AttackParamLocation[]; diff --git a/packages/scan/src/models/Tests.ts b/packages/scan/src/models/Tests.ts index 52137344..6c976ecc 100644 --- a/packages/scan/src/models/Tests.ts +++ b/packages/scan/src/models/Tests.ts @@ -2,10 +2,10 @@ export type Test = string | BrokenAccessControlTest; export type BrokenAccessControlOptions = | { - auth: string; // scan with axisting user and unauthorized access + auth: string; // auth_object_id to scan with existing user and unauthorized user } | { - auth: [string, string]; // scan with different authorized users + auth: [string, string]; // auth_object_ids to scan with authorized users with different privileges }; export type BrokenAccessControlTest = { From 6cbe1f190b3d8fcee166b9ffb1813a732c38abbe Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Tue, 13 Jan 2026 16:58:23 +0100 Subject: [PATCH 05/13] Add spec test for CreateScan execution --- packages/scan/src/DefaultScans.spec.ts | 73 +++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/scan/src/DefaultScans.spec.ts b/packages/scan/src/DefaultScans.spec.ts index a82dab4f..698542f5 100644 --- a/packages/scan/src/DefaultScans.spec.ts +++ b/packages/scan/src/DefaultScans.spec.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { DefaultScans } from './DefaultScans'; -import { HttpMethod, ScanStatus, Severity } from './models'; -import { deepEqual, instance, mock, reset, spy, when } from 'ts-mockito'; +import { AttackParamLocation, HttpMethod, ScanStatus, Severity } from './models'; +import { anything, capture, deepEqual, instance, mock, reset, spy, when } from 'ts-mockito'; import { ApiClient, Configuration } from '@sectester/core'; import ci from 'ci-info'; import { randomUUID } from 'crypto'; @@ -70,6 +70,75 @@ describe('DefaultScans', () => { expect(result).toEqual({ id }); }); + + it('should stringify config with all properties in expected by backend format', async () => { + const response = new Response(JSON.stringify({ id })); + const config = { + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + 'xss', + { + name: 'broken_access_control' as const, + options: { auth: 'auth-id-123' } + } + ], + poolSize: 10, + requestsRateLimit: 500, + attackParamLocations: [AttackParamLocation.QUERY, AttackParamLocation.BODY], + repeaters: ['repeater-id-456'], + smart: true, + skipStaticParams: true, + starMetadata: { customKey: 'customValue', nested: { prop: 123 } } + }; + + when(mockedConfiguration.name).thenReturn('sdk-test'); + when(mockedConfiguration.version).thenReturn('2.5.0'); + when(mockedApiClient.request('/api/v1/scans', anything())).thenResolve( + response + ); + + const result = await scans.createScan(config); + + expect(result).toEqual({ id }); + + const [, capturedOptions] = capture(mockedApiClient.request).last(); + expect(capturedOptions).toBeDefined(); + expect(capturedOptions).toMatchObject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + } + }); + const parsedBody = JSON.parse(capturedOptions!.body as string); + console.log('Parsed body:', JSON.stringify(parsedBody, null, 2)); + + expect(parsedBody).toEqual({ + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + 'xss', + { + name: 'broken_access_control', + options: { auth: 'auth-id-123' } + } + ], + poolSize: 10, + requestsRateLimit: 500, + attackParamLocations: ['query', 'body'], + repeaters: ['repeater-id-456'], + smart: true, + skipStaticParams: true, + starMetadata: { customKey: 'customValue', nested: { prop: 123 } }, + info: { + source: 'utlib', + provider: ci.name, + client: { name: 'sdk-test', version: '2.5.0' } + } + }); + }); }); describe('listIssues', () => { From 56433577e91849e9e5ef9445938d5220b71b7129 Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Tue, 13 Jan 2026 20:51:45 +0100 Subject: [PATCH 06/13] Add config transofrmation into backend format --- packages/scan/src/DefaultScans.spec.ts | 129 ++++++++++++++++++++++--- packages/scan/src/DefaultScans.ts | 64 +++++++++++- 2 files changed, 180 insertions(+), 13 deletions(-) diff --git a/packages/scan/src/DefaultScans.spec.ts b/packages/scan/src/DefaultScans.spec.ts index 698542f5..1ed1a220 100644 --- a/packages/scan/src/DefaultScans.spec.ts +++ b/packages/scan/src/DefaultScans.spec.ts @@ -1,7 +1,21 @@ import 'reflect-metadata'; import { DefaultScans } from './DefaultScans'; -import { AttackParamLocation, HttpMethod, ScanStatus, Severity } from './models'; -import { anything, capture, deepEqual, instance, mock, reset, spy, when } from 'ts-mockito'; +import { + AttackParamLocation, + HttpMethod, + ScanStatus, + Severity +} from './models'; +import { + anything, + capture, + deepEqual, + instance, + mock, + reset, + spy, + when +} from 'ts-mockito'; import { ApiClient, Configuration } from '@sectester/core'; import ci from 'ci-info'; import { randomUUID } from 'crypto'; @@ -71,7 +85,7 @@ describe('DefaultScans', () => { expect(result).toEqual({ id }); }); - it('should stringify config with all properties in expected by backend format', async () => { + it('should transform broken_access_control test with single auth object into backend compatible format', async () => { const response = new Response(JSON.stringify({ id })); const config = { projectId, @@ -86,7 +100,10 @@ describe('DefaultScans', () => { ], poolSize: 10, requestsRateLimit: 500, - attackParamLocations: [AttackParamLocation.QUERY, AttackParamLocation.BODY], + attackParamLocations: [ + AttackParamLocation.QUERY, + AttackParamLocation.BODY + ], repeaters: ['repeater-id-456'], smart: true, skipStaticParams: true, @@ -102,7 +119,7 @@ describe('DefaultScans', () => { const result = await scans.createScan(config); expect(result).toEqual({ id }); - + const [, capturedOptions] = capture(mockedApiClient.request).last(); expect(capturedOptions).toBeDefined(); expect(capturedOptions).toMatchObject({ @@ -118,13 +135,7 @@ describe('DefaultScans', () => { projectId, name: 'test scan', entryPointIds: [entryPointId], - tests: [ - 'xss', - { - name: 'broken_access_control', - options: { auth: 'auth-id-123' } - } - ], + tests: ['xss', 'broken_access_control'], poolSize: 10, requestsRateLimit: 500, attackParamLocations: ['query', 'body'], @@ -132,6 +143,11 @@ describe('DefaultScans', () => { smart: true, skipStaticParams: true, starMetadata: { customKey: 'customValue', nested: { prop: 123 } }, + testMetadata: { + broken_access_control: { + authObjectId: [null, 'auth-id-123'] + } + }, info: { source: 'utlib', provider: ci.name, @@ -139,6 +155,95 @@ describe('DefaultScans', () => { } }); }); + + it('should transform broken_access_control test with two auth objects into backend compatible format', async () => { + const response = new Response(JSON.stringify({ id })); + const config = { + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + { + name: 'broken_access_control' as const, + options: { auth: ['auth-id-1', 'auth-id-2'] as [string, string] } + } + ] + }; + + when(mockedConfiguration.name).thenReturn('sdk-test'); + when(mockedConfiguration.version).thenReturn('2.5.0'); + when(mockedApiClient.request('/api/v1/scans', anything())).thenResolve( + response + ); + + const result = await scans.createScan(config); + + expect(result).toEqual({ id }); + + const [, capturedOptions] = capture(mockedApiClient.request).last(); + const parsedBody = JSON.parse(capturedOptions!.body as string); + + expect(parsedBody.tests).toEqual(['broken_access_control']); + expect(parsedBody.testMetadata).toEqual({ + broken_access_control: { + authObjectId: ['auth-id-1', 'auth-id-2'] + } + }); + }); + + it('should throw error when broken_access_control test has no auth option', async () => { + const config = { + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + { + name: 'broken_access_control' as const, + options: {} as any + } + ] + }; + + await expect(scans.createScan(config)).rejects.toThrow( + 'Auth option is required for broken_access_control test' + ); + }); + + it('should throw error when broken_access_control test has empty auth array', async () => { + const config = { + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + { + name: 'broken_access_control' as const, + options: { auth: [] as any } + } + ] + }; + + await expect(scans.createScan(config)).rejects.toThrow( + 'broken_access_control test auth option must be either a string or a tuple of two strings' + ); + }); + + it('should throw error when broken_access_control test has auth array with 3 elements', async () => { + const config = { + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: [ + { + name: 'broken_access_control' as const, + options: { auth: ['auth-id-1', 'auth-id-2', 'auth-id-3'] as any } + } + ] + }; + + await expect(scans.createScan(config)).rejects.toThrow( + 'broken_access_control test auth option must be either a string or a tuple of two strings' + ); + }); }); describe('listIssues', () => { diff --git a/packages/scan/src/DefaultScans.ts b/packages/scan/src/DefaultScans.ts index ae24e095..aabe4520 100644 --- a/packages/scan/src/DefaultScans.ts +++ b/packages/scan/src/DefaultScans.ts @@ -19,7 +19,7 @@ export class DefaultScans implements Scans { 'content-type': 'application/json' }, body: JSON.stringify({ - ...config, + ...this.transformConfig(config), info: { source: 'utlib', provider: ci.name, @@ -80,4 +80,66 @@ export class DefaultScans implements Scans { return result; } + + private transformConfig(config: ScanConfig): Record { + if (!config.tests) { + return { ...config }; + } + + const { tests: mappedTests, testMetadata } = this.mapTests(config.tests); + const { tests: _, ...restConfig } = config; + + const result: Record = { + ...restConfig, + tests: mappedTests + }; + + if (Object.keys(testMetadata).length > 0) { + result.testMetadata = testMetadata; + } + + return result; + } + + private mapTests(input: ScanConfig['tests']) { + const tests: string[] = []; + const testMetadata: Record = {}; + + for (const item of input!) { + if (typeof item === 'string') { + tests.push(item); + continue; + } + + const { name, options } = item; + + if (name === 'broken_access_control') { + if (!options?.auth) { + throw new Error( + 'Auth option is required for broken_access_control test' + ); + } + + const { auth } = options; + if ( + typeof auth !== 'string' && + (!Array.isArray(auth) || auth.length !== 2) + ) { + throw new Error( + `${name} test auth option must be either a string or a tuple of two strings` + ); + } + + tests.push(name); + testMetadata[name] = { + authObjectId: + typeof auth === 'string' ? [null, auth] : [auth[0], auth[1]] + }; + } else { + throw new Error(`Unsupported configurable test: ${name}`); + } + } + + return { tests, testMetadata }; + } } From 3e9778c161c3e606ef2a083358cd71dbfaca69b0 Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Tue, 13 Jan 2026 21:27:03 +0100 Subject: [PATCH 07/13] fix SecScanOptions --- packages/runner/src/lib/SecScanOptions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/runner/src/lib/SecScanOptions.ts b/packages/runner/src/lib/SecScanOptions.ts index f88a2b44..a94856c6 100644 --- a/packages/runner/src/lib/SecScanOptions.ts +++ b/packages/runner/src/lib/SecScanOptions.ts @@ -9,5 +9,4 @@ export type SecScanOptions = Pick< | 'skipStaticParams' | 'attackParamLocations' | 'starMetadata' - | 'testMetadata' >; From bae32f6eca169d3cfa70419edf784e0c603c00ff Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 14 Jan 2026 00:40:23 +0100 Subject: [PATCH 08/13] fix linter errors --- packages/scan/src/DefaultScans.spec.ts | 1 - packages/scan/src/DefaultScans.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/scan/src/DefaultScans.spec.ts b/packages/scan/src/DefaultScans.spec.ts index 1ed1a220..a3ab0132 100644 --- a/packages/scan/src/DefaultScans.spec.ts +++ b/packages/scan/src/DefaultScans.spec.ts @@ -129,7 +129,6 @@ describe('DefaultScans', () => { } }); const parsedBody = JSON.parse(capturedOptions!.body as string); - console.log('Parsed body:', JSON.stringify(parsedBody, null, 2)); expect(parsedBody).toEqual({ projectId, diff --git a/packages/scan/src/DefaultScans.ts b/packages/scan/src/DefaultScans.ts index aabe4520..cf2565fa 100644 --- a/packages/scan/src/DefaultScans.ts +++ b/packages/scan/src/DefaultScans.ts @@ -87,7 +87,7 @@ export class DefaultScans implements Scans { } const { tests: mappedTests, testMetadata } = this.mapTests(config.tests); - const { tests: _, ...restConfig } = config; + const { tests: originalTests, ...restConfig } = config; const result: Record = { ...restConfig, From 75e239500d3598d0d1d56b7fde5cda0bf1f932ef Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 14 Jan 2026 00:59:21 +0100 Subject: [PATCH 09/13] add specs for broken access control test mapping --- packages/scan/src/DefaultScans.spec.ts | 169 +++++++++++-------------- 1 file changed, 77 insertions(+), 92 deletions(-) diff --git a/packages/scan/src/DefaultScans.spec.ts b/packages/scan/src/DefaultScans.spec.ts index a3ab0132..1cec1ffa 100644 --- a/packages/scan/src/DefaultScans.spec.ts +++ b/packages/scan/src/DefaultScans.spec.ts @@ -1,21 +1,7 @@ import 'reflect-metadata'; import { DefaultScans } from './DefaultScans'; -import { - AttackParamLocation, - HttpMethod, - ScanStatus, - Severity -} from './models'; -import { - anything, - capture, - deepEqual, - instance, - mock, - reset, - spy, - when -} from 'ts-mockito'; +import { HttpMethod, ScanStatus, Severity } from './models'; +import { deepEqual, instance, mock, reset, spy, when } from 'ts-mockito'; import { ApiClient, Configuration } from '@sectester/core'; import ci from 'ci-info'; import { randomUUID } from 'crypto'; @@ -39,11 +25,7 @@ describe('DefaultScans', () => { }); afterEach(() => - reset( - mockedApiClient, - mockedCi, - mockedConfiguration - ) + reset(mockedApiClient, mockedConfiguration) ); describe('createScan', () => { @@ -87,9 +69,45 @@ describe('DefaultScans', () => { it('should transform broken_access_control test with single auth object into backend compatible format', async () => { const response = new Response(JSON.stringify({ id })); - const config = { + + when(mockedConfiguration.name).thenReturn('test'); + when(mockedConfiguration.version).thenReturn('1.0'); + when(mockedCi.name).thenReturn('github'); + + when( + mockedApiClient.request( + '/api/v1/scans', + deepEqual({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + projectId, + name: 'test', + entryPointIds: [entryPointId], + tests: ['xss', 'broken_access_control'], + testMetadata: { + broken_access_control: { + authObjectId: [null, 'auth-id-123'] + } + }, + info: { + source: 'utlib', + provider: 'github', + client: { + name: 'test', + version: '1.0' + } + } + }) + }) + ) + ).thenResolve(response); + + const result = await scans.createScan({ projectId, - name: 'test scan', + name: 'test', entryPointIds: [entryPointId], tests: [ 'xss', @@ -97,62 +115,10 @@ describe('DefaultScans', () => { name: 'broken_access_control' as const, options: { auth: 'auth-id-123' } } - ], - poolSize: 10, - requestsRateLimit: 500, - attackParamLocations: [ - AttackParamLocation.QUERY, - AttackParamLocation.BODY - ], - repeaters: ['repeater-id-456'], - smart: true, - skipStaticParams: true, - starMetadata: { customKey: 'customValue', nested: { prop: 123 } } - }; - - when(mockedConfiguration.name).thenReturn('sdk-test'); - when(mockedConfiguration.version).thenReturn('2.5.0'); - when(mockedApiClient.request('/api/v1/scans', anything())).thenResolve( - response - ); - - const result = await scans.createScan(config); - - expect(result).toEqual({ id }); - - const [, capturedOptions] = capture(mockedApiClient.request).last(); - expect(capturedOptions).toBeDefined(); - expect(capturedOptions).toMatchObject({ - method: 'POST', - headers: { - 'content-type': 'application/json' - } + ] }); - const parsedBody = JSON.parse(capturedOptions!.body as string); - expect(parsedBody).toEqual({ - projectId, - name: 'test scan', - entryPointIds: [entryPointId], - tests: ['xss', 'broken_access_control'], - poolSize: 10, - requestsRateLimit: 500, - attackParamLocations: ['query', 'body'], - repeaters: ['repeater-id-456'], - smart: true, - skipStaticParams: true, - starMetadata: { customKey: 'customValue', nested: { prop: 123 } }, - testMetadata: { - broken_access_control: { - authObjectId: [null, 'auth-id-123'] - } - }, - info: { - source: 'utlib', - provider: ci.name, - client: { name: 'sdk-test', version: '2.5.0' } - } - }); + expect(result).toEqual({ id }); }); it('should transform broken_access_control test with two auth objects into backend compatible format', async () => { @@ -169,25 +135,44 @@ describe('DefaultScans', () => { ] }; - when(mockedConfiguration.name).thenReturn('sdk-test'); - when(mockedConfiguration.version).thenReturn('2.5.0'); - when(mockedApiClient.request('/api/v1/scans', anything())).thenResolve( - response - ); + when(mockedConfiguration.name).thenReturn('test'); + when(mockedConfiguration.version).thenReturn('1.0'); + when(mockedCi.name).thenReturn('github'); + + when( + mockedApiClient.request( + '/api/v1/scans', + deepEqual({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + projectId, + name: 'test scan', + entryPointIds: [entryPointId], + tests: ['broken_access_control'], + testMetadata: { + broken_access_control: { + authObjectId: ['auth-id-1', 'auth-id-2'] + } + }, + info: { + source: 'utlib', + provider: 'github', + client: { + name: 'test', + version: '1.0' + } + } + }) + }) + ) + ).thenResolve(response); const result = await scans.createScan(config); expect(result).toEqual({ id }); - - const [, capturedOptions] = capture(mockedApiClient.request).last(); - const parsedBody = JSON.parse(capturedOptions!.body as string); - - expect(parsedBody.tests).toEqual(['broken_access_control']); - expect(parsedBody.testMetadata).toEqual({ - broken_access_control: { - authObjectId: ['auth-id-1', 'auth-id-2'] - } - }); }); it('should throw error when broken_access_control test has no auth option', async () => { From cf2dd658acde2b5b30691e75fca124a358a8418d Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 14 Jan 2026 01:33:21 +0100 Subject: [PATCH 10/13] fix nesting linter errors --- packages/scan/src/DefaultScans.ts | 70 +++++++++++++++++-------------- packages/scan/src/ScanSettings.ts | 23 +++++----- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/packages/scan/src/DefaultScans.ts b/packages/scan/src/DefaultScans.ts index cf2565fa..19ee15b1 100644 --- a/packages/scan/src/DefaultScans.ts +++ b/packages/scan/src/DefaultScans.ts @@ -1,5 +1,6 @@ import { Scans } from './Scans'; import { Issue, ScanConfig, ScanState } from './models'; +import { BrokenAccessControlTest } from './models/Tests'; import { inject, injectable } from 'tsyringe'; import { ApiClient, ApiError, Configuration } from '@sectester/core'; import ci from 'ci-info'; @@ -86,7 +87,7 @@ export class DefaultScans implements Scans { return { ...config }; } - const { tests: mappedTests, testMetadata } = this.mapTests(config.tests); + const { mappedTests, testMetadata } = this.mapTests(config.tests); const { tests: originalTests, ...restConfig } = config; const result: Record = { @@ -101,45 +102,52 @@ export class DefaultScans implements Scans { return result; } - private mapTests(input: ScanConfig['tests']) { - const tests: string[] = []; + private mapTests(tests: ScanConfig['tests']) { + const mappedTests: string[] = []; const testMetadata: Record = {}; - for (const item of input!) { - if (typeof item === 'string') { - tests.push(item); + if (!tests) { + throw new Error('Scan config should have tests defined'); + } + + for (const test of tests) { + if (typeof test === 'string') { + mappedTests.push(test); continue; } - const { name, options } = item; + if (test.name === 'broken_access_control') { + this.mapBrokenAccessControlTest(test, mappedTests, testMetadata); + } else { + throw new Error(`Unsupported configurable test: ${test.name}`); + } + } - if (name === 'broken_access_control') { - if (!options?.auth) { - throw new Error( - 'Auth option is required for broken_access_control test' - ); - } + return { mappedTests, testMetadata }; + } - const { auth } = options; - if ( - typeof auth !== 'string' && - (!Array.isArray(auth) || auth.length !== 2) - ) { - throw new Error( - `${name} test auth option must be either a string or a tuple of two strings` - ); - } + private mapBrokenAccessControlTest( + test: BrokenAccessControlTest, + mappedTests: string[], + testMetadata: Record + ) { + if (!test.options?.auth) { + throw new Error('Auth option is required for broken_access_control test'); + } - tests.push(name); - testMetadata[name] = { - authObjectId: - typeof auth === 'string' ? [null, auth] : [auth[0], auth[1]] - }; - } else { - throw new Error(`Unsupported configurable test: ${name}`); - } + const { auth } = test.options; + if ( + typeof auth !== 'string' && + (!Array.isArray(auth) || auth.length !== 2) + ) { + throw new Error( + `${test.name} test auth option must be either a string or a tuple of two strings` + ); } - return { tests, testMetadata }; + mappedTests.push(test.name); + testMetadata[test.name] = { + authObjectId: typeof auth === 'string' ? [null, auth] : [auth[0], auth[1]] + }; } } diff --git a/packages/scan/src/ScanSettings.ts b/packages/scan/src/ScanSettings.ts index a3922a5a..8845b58e 100644 --- a/packages/scan/src/ScanSettings.ts +++ b/packages/scan/src/ScanSettings.ts @@ -135,18 +135,19 @@ export class ScanSettings implements ScanSettingsOptions { const configurableTests: Test[] = []; const seenTestConfigurations = new Set(); - for (const t of value) { - const testName = typeof t === 'string' ? t : t.name; - - if (typeof t === 'string') { - simpleTests.add(t); - } else { - if (seenTestConfigurations.has(testName)) { - throw new Error(`Duplicate test configuration found: ${testName}`); - } - seenTestConfigurations.add(testName); - configurableTests.push(t); + for (const test of value) { + const testName = typeof test === 'string' ? test : test.name; + + if (typeof test === 'string') { + simpleTests.add(test); + continue; + } + + if (seenTestConfigurations.has(testName)) { + throw new Error(`Duplicate test configuration found: ${testName}`); } + seenTestConfigurations.add(testName); + configurableTests.push(test); } this._tests = [...simpleTests, ...configurableTests]; From fbfc051575365db5b3d0fd242ea4b9e570241d3a Mon Sep 17 00:00:00 2001 From: Alexey Date: Wed, 14 Jan 2026 19:22:54 +0100 Subject: [PATCH 11/13] Update packages/scan/src/models/Tests.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/scan/src/models/Tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scan/src/models/Tests.ts b/packages/scan/src/models/Tests.ts index 6c976ecc..0919b071 100644 --- a/packages/scan/src/models/Tests.ts +++ b/packages/scan/src/models/Tests.ts @@ -2,7 +2,7 @@ export type Test = string | BrokenAccessControlTest; export type BrokenAccessControlOptions = | { - auth: string; // auth_object_id to scan with existing user and unauthorized user + auth: string; // auth_object_id of the authorized user; scan compares access as this user vs as an unauthorized user } | { auth: [string, string]; // auth_object_ids to scan with authorized users with different privileges From 2f905b0c14472463b3892538a98d7c6409481b8c Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 14 Jan 2026 19:30:44 +0100 Subject: [PATCH 12/13] copilot review --- packages/scan/src/DefaultScans.ts | 15 ++++++++------- packages/scan/src/ScanSettings.ts | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/scan/src/DefaultScans.ts b/packages/scan/src/DefaultScans.ts index 19ee15b1..071a5f46 100644 --- a/packages/scan/src/DefaultScans.ts +++ b/packages/scan/src/DefaultScans.ts @@ -90,16 +90,17 @@ export class DefaultScans implements Scans { const { mappedTests, testMetadata } = this.mapTests(config.tests); const { tests: originalTests, ...restConfig } = config; - const result: Record = { - ...restConfig, - tests: mappedTests - }; - if (Object.keys(testMetadata).length > 0) { - result.testMetadata = testMetadata; + const result: Record = { + ...restConfig, + tests: mappedTests, + testMetadata + }; + + return result; } - return result; + return { ...config }; } private mapTests(tests: ScanConfig['tests']) { diff --git a/packages/scan/src/ScanSettings.ts b/packages/scan/src/ScanSettings.ts index 8845b58e..d60bf7b1 100644 --- a/packages/scan/src/ScanSettings.ts +++ b/packages/scan/src/ScanSettings.ts @@ -143,7 +143,7 @@ export class ScanSettings implements ScanSettingsOptions { continue; } - if (seenTestConfigurations.has(testName)) { + if (seenTestConfigurations.has(testName) || simpleTests.has(testName)) { throw new Error(`Duplicate test configuration found: ${testName}`); } seenTestConfigurations.add(testName); From 94449823e9e2d43e5d61cdfd1e9fbbf53c197e9d Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Wed, 14 Jan 2026 19:46:38 +0100 Subject: [PATCH 13/13] format code --- packages/scan/src/DefaultScans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scan/src/DefaultScans.ts b/packages/scan/src/DefaultScans.ts index 071a5f46..67cd0b3a 100644 --- a/packages/scan/src/DefaultScans.ts +++ b/packages/scan/src/DefaultScans.ts @@ -96,7 +96,7 @@ export class DefaultScans implements Scans { tests: mappedTests, testMetadata }; - + return result; }