Skip to content
Open
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind>` flag for `authzprobe scan` to plan just `bola`, `bfla`, or
`baseline` test families.

## [0.1.0] — 2026-09-09

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind>` | Plan only one test family: `bola`, `bfla`, or `baseline` |
| `--json <file>` | Write a machine-readable report |
| `--no-fail-on-finding` | Report only; always exit 0 |
| `--concurrency <n>` | Max concurrent requests (default 5) |
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,19 +25,22 @@ 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 <kind>', 'plan only one test family: bola, bfla, or baseline')
.option('--concurrency <n>', 'max concurrent requests', (v) => parseInt(v, 10), 5)
.option('--timeout <ms>', 'per-request timeout in milliseconds', (v) => parseInt(v, 10), 10_000)
.option('--json <file>', '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,
baseUrl: opts.baseUrl,
dryRun: opts.dryRun,
includeUnsafe: opts.includeUnsafe,
includeBaseline: opts.baseline, // commander maps --no-baseline -> baseline:false
only,
concurrency: opts.concurrency,
timeoutMs: opts.timeout,
});
Expand Down
28 changes: 26 additions & 2 deletions src/engine/plan.ts
Original file line number Diff line number Diff line change
@@ -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<string, TestKind> = {
bola: 'BOLA',
bfla: 'BFLA',
baseline: 'BASELINE',
};

/**
* Parse the CLI `--only <kind>` 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 = {
Expand Down Expand Up @@ -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[] {
Expand Down
1 change: 1 addition & 0 deletions src/engine/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function scan(options: ScanOptions): Promise<ScanReport> {
const cases = planTests(operations, config, {
includeUnsafe: options.includeUnsafe,
includeBaseline: options.includeBaseline,
only: options.only,
});

const results = await replayAll(cases, config, {
Expand Down
36 changes: 35 additions & 1 deletion tests/plan.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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}');
Expand Down