From ca0468d34679f81f15e9c407a52df196f344e079 Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Thu, 10 Sep 2026 20:24:06 +0300 Subject: [PATCH] feat(plan): add --only filter for bola/bfla/baseline --- CHANGELOG.md | 5 ++++- README.md | 1 + src/cli.ts | 4 ++++ src/engine/plan.ts | 28 ++++++++++++++++++++++++++-- src/engine/run.ts | 1 + tests/plan.test.ts | 36 +++++++++++++++++++++++++++++++++++- 6 files changed, 71 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8546185..486e2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_Nothing yet — add entries here as you merge PRs._ +### Added + +- `--only ` flag for `authzprobe scan` to plan just `bola`, `bfla`, or + `baseline` test families. ## [0.1.0] — 2026-09-09 diff --git a/README.md b/README.md index f150880..d275e9f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Key flags: | `--dry-run` | Plan the requests but send nothing | | `--include-unsafe` | Also probe mutating verbs (off by default) | | `--no-baseline` | Skip baseline self-access checks | +| `--only ` | Plan only one test family: `bola`, `bfla`, or `baseline` | | `--json ` | Write a machine-readable report | | `--no-fail-on-finding` | Report only; always exit 0 | | `--concurrency ` | Max concurrent requests (default 5) | diff --git a/src/cli.ts b/src/cli.ts index 86668a9..f7bff6c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'; import { Command } from 'commander'; import pc from 'picocolors'; import { scan } from './engine/run.js'; +import { parseOnlyFilter } from './engine/plan.js'; import { printReport, toJson } from './report/report.js'; import { summarize } from './engine/evaluate.js'; @@ -24,12 +25,14 @@ program .option('--dry-run', 'plan the requests but do not send them', false) .option('--include-unsafe', 'also probe mutating verbs (POST/PUT/PATCH/DELETE)', false) .option('--no-baseline', 'skip baseline self-access sanity checks') + .option('--only ', 'plan only one test family: bola, bfla, or baseline') .option('--concurrency ', 'max concurrent requests', (v) => parseInt(v, 10), 5) .option('--timeout ', 'per-request timeout in milliseconds', (v) => parseInt(v, 10), 10_000) .option('--json ', 'also write a machine-readable JSON report to this file') .option('--no-fail-on-finding', 'exit 0 even when findings are detected (report only)') .action(async (opts) => { try { + const only = parseOnlyFilter(opts.only); const report = await scan({ specPath: opts.spec, configPath: opts.config, @@ -37,6 +40,7 @@ program dryRun: opts.dryRun, includeUnsafe: opts.includeUnsafe, includeBaseline: opts.baseline, // commander maps --no-baseline -> baseline:false + only, concurrency: opts.concurrency, timeoutMs: opts.timeout, }); diff --git a/src/engine/plan.ts b/src/engine/plan.ts index 7f1027f..2bad4f2 100644 --- a/src/engine/plan.ts +++ b/src/engine/plan.ts @@ -1,11 +1,35 @@ import type { Config, Identity, PrivilegedRule } from '../config/schema.js'; -import type { SpecOperation, TestCase } from '../types.js'; +import type { SpecOperation, TestCase, TestKind } from '../types.js'; export interface PlanOptions { /** Include mutating verbs (POST/PUT/PATCH/DELETE). Off by default for safety. */ includeUnsafe: boolean; /** Emit baseline self-access checks that confirm auth actually works. */ includeBaseline: boolean; + /** Restrict the plan to a single test family: BOLA, BFLA, or BASELINE. */ + only?: TestKind; +} + +/** `--only` accepts these lowercase values, each mapping to one test family. */ +const ONLY_FILTERS: Record = { + bola: 'BOLA', + bfla: 'BFLA', + baseline: 'BASELINE', +}; + +/** + * Parse the CLI `--only ` value. Throws a clear error listing the + * allowed values when the input is not one of them. + */ +export function parseOnlyFilter(value: string | undefined): TestKind | undefined { + if (value === undefined) return undefined; + const kind = ONLY_FILTERS[value.toLowerCase()]; + if (!kind) { + throw new Error( + `Invalid value for --only: "${value}". Allowed values: bola, bfla, baseline.`, + ); + } + return kind; } const DEFAULT_PLAN_OPTIONS: PlanOptions = { @@ -39,7 +63,7 @@ export function planTests( cases.push(...planBfla(op, config)); } - return cases; + return opts.only ? cases.filter((c) => c.kind === opts.only) : cases; } function planBola(op: SpecOperation, config: Config, opts: PlanOptions): TestCase[] { diff --git a/src/engine/run.ts b/src/engine/run.ts index 0a6e13b..cd3ed69 100644 --- a/src/engine/run.ts +++ b/src/engine/run.ts @@ -39,6 +39,7 @@ export async function scan(options: ScanOptions): Promise { const cases = planTests(operations, config, { includeUnsafe: options.includeUnsafe, includeBaseline: options.includeBaseline, + only: options.only, }); const results = await replayAll(cases, config, { diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 3bbea5e..7f31c08 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { planTests, signature } from '../src/engine/plan.js'; +import { planTests, parseOnlyFilter, signature } from '../src/engine/plan.js'; import type { SpecOperation } from '../src/types.js'; import type { Config } from '../src/config/schema.js'; @@ -85,6 +85,40 @@ describe('planTests — safety', () => { }); }); +describe('planTests — only filter', () => { + it('restricts the plan to a single test family', () => { + // getUser yields BASELINE + BOLA, adminStats yields BFLA. + const ops = [getUser, adminStats]; + const all = planTests(ops, config); + expect(new Set(all.map((c) => c.kind))).toEqual(new Set(['BOLA', 'BFLA', 'BASELINE'])); + + const bola = planTests(ops, config, { only: 'BOLA' }); + expect(bola.length).toBeGreaterThan(0); + expect(bola.every((c) => c.kind === 'BOLA')).toBe(true); + + const bfla = planTests(ops, config, { only: 'BFLA' }); + expect(bfla.length).toBeGreaterThan(0); + expect(bfla.every((c) => c.kind === 'BFLA')).toBe(true); + + const baseline = planTests(ops, config, { only: 'BASELINE' }); + expect(baseline.length).toBeGreaterThan(0); + expect(baseline.every((c) => c.kind === 'BASELINE')).toBe(true); + }); +}); + +describe('parseOnlyFilter', () => { + it('accepts undefined and any casing of the three kinds', () => { + expect(parseOnlyFilter(undefined)).toBeUndefined(); + expect(parseOnlyFilter('bola')).toBe('BOLA'); + expect(parseOnlyFilter('BFLA')).toBe('BFLA'); + expect(parseOnlyFilter('Baseline')).toBe('BASELINE'); + }); + + it('rejects unknown values with an error listing the allowed kinds', () => { + expect(() => parseOnlyFilter('sql')).toThrow(/Allowed values: bola, bfla, baseline/); + }); +}); + describe('signature', () => { it('formats as METHOD /path', () => { expect(signature(getUser)).toBe('GET /users/{userId}');