From 2f72eb7b302533cead8494f6824eac8a1d54931e Mon Sep 17 00:00:00 2001 From: Alexey Novgorodov Date: Fri, 26 Dec 2025 02:34:08 +0100 Subject: [PATCH 1/2] 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 2/2] 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';