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
6 changes: 6 additions & 0 deletions .changeset/drift-deepobject-query-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/cli': patch
---

Fixed the `drift` command's `schema-consistency` rule reporting false-positive "Undocumented query parameter" findings for `deepObject`-style query parameters.
Traffic keys like `namespace[id]=...&namespace[name]=...` are now matched to the documented `namespace` parameter, and the reconstructed object is validated against the parameter schema.
7 changes: 7 additions & 0 deletions .changeset/drift-ignore-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@redocly/cli': minor
---

Added an `--ignore-headers` option to the experimental `drift` and `proxy` commands.
It takes a comma-separated list of header names to skip in undocumented-header checks, and a trailing `*` matches by prefix (for example `x-consumer-*`).
Use it to silence headers a gateway or proxy adds that are not part of the API contract.
9 changes: 9 additions & 0 deletions .changeset/drift-skip-request-checks-on-4xx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@redocly/cli': patch
---

Fixed an issue where the `drift` command's `schema-consistency` rule reported false-positive request findings for exchanges the server rejected with a `4xx` client error.
For example: missing required parameter, missing required body, request-body schema mismatch.
A `4xx` response means the server never accepted the request.
Validating it against the operation's success-path contract flagged the server's own correct rejection as drift.
Response-side validation still runs, so a documented error response whose shape differs from reality is still reported.
6 changes: 6 additions & 0 deletions .changeset/loopback-drift-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/cli': patch
---

Fixed an issue where the `drift` command's `security-baseline` rule reported false-positive "credential exposure over insecure HTTP transport" warnings for traffic captured against loopback hosts, for example: `localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`.
Sandboxed recordings no longer produce transport warnings.
11 changes: 11 additions & 0 deletions docs/@v2/commands/drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ redocly drift <traffic> --api <api> [--match-mode=<option>]
| --match-mode | string | How requests are located via the description `servers`. `strict-host` also requires the host to match; `basepath` matches only the base path.<br/>**Possible values:** `strict-host`, `basepath`. Default value is `strict-host`. Mutually exclusive with `--server`. |
| --server | string | Server URL the traffic was captured against (host, host + base path, or a path-only prefix like `/api`). Only requests under it are considered, and the rest of their URL is treated as the API path. Replaces the description `servers`. Mutually exclusive with `--match-mode`. |
| --ignore-cookies | boolean | Ignore cookie-based checks (useful for logs exported without cookies). Default value is `false`. |
| --ignore-headers | string | Comma-separated header names to skip in undocumented-header checks. A trailing `*` matches by prefix, for example `x-consumer-*`. Useful for headers a gateway or proxy adds that are not part of the API contract. |
| --max-findings | number | Maximum findings shown in pretty output. Default value is `10`. |
| --min-severity | string | Discard findings below this severity from the report (all formats).<br/>**Possible values:** `info`, `warning`, `error`. Default value is `info`. |
| --rules | string | Comma-separated subset of builtin rules to run: `undocumented-endpoint`, `schema-consistency`, `security-baseline`, `owasp-api-top10`. |
Expand Down Expand Up @@ -89,6 +90,16 @@ Only requests under it are considered, and the remaining path is matched against
redocly drift ./traffic.har --api ./openapi.yaml --server localhost:9000
```

### Ignore headers added by a gateway or proxy

A gateway such as Caddy often injects headers that are not part of the API contract (for example authentication or consumer-identity headers).
Skip them so they don't show up as undocumented headers.
Use a trailing `*` to match a family of headers by prefix:

```bash
redocly drift ./traffic.har --api ./openapi.yaml --ignore-headers "x-caddy-auth-token,x-auth-intent,x-consumer-*"
```

### Write the report to a file

```bash
Expand Down
1 change: 1 addition & 0 deletions docs/@v2/commands/proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ redocly proxy --target <url> --har <path> [--port=<number>] [--host=<string>]
| --format | string | Output format for the validation report printed on shutdown.<br/>**Possible values:** `pretty`, `json`, `csv`, `sarif`. Default value is `pretty`. |
| --match-mode | string | Endpoint matching mode.<br/>**Possible values:** `strict-host`, `basepath`. Default value is `strict-host`. |
| --ignore-cookies | boolean | Ignore cookie-based checks. Default value is `false`. |
| --ignore-headers | string | Comma-separated header names to skip in undocumented-header checks. A trailing `*` matches by prefix, for example `x-consumer-*`. |
| --max-findings | number | Maximum findings shown in pretty output. Default value is `10`. |
| --rules | string | Comma-separated subset of built-in rules to run: `undocumented-endpoint`, `schema-consistency`, `security-baseline`, `owasp-api-top10`. |
| --config | string | Specify the path to the [configuration file](../configuration/index.md). |
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/src/__tests__/commands/drift/schema-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { SchemaConsistencyRule } from '../../../commands/drift/rules/builtins/schema.js';
import type {
MatchedOperation,
NormalizedExchange,
OpenApiParameter,
RuleContext,
} from '../../../commands/drift/types/index.js';
import { parseHeaderIgnoreList } from '../../../commands/drift/utils/http.js';

function createMatchedOperation(requestParameters?: OpenApiParameter[]): MatchedOperation {
return {
operation: {
operationId: 'listOrderItems',
method: 'get',
pathTemplate: '/order-items',
pathRegex: /^\/order-items$/,
pathParams: [],
pathScore: 1,
servers: [],
requestParameters: requestParameters ?? [{ name: 'filter', in: 'query', required: true }],
requestBodyContent: {},
requestBodyRequired: false,
responseBodyContent: {},
security: undefined,
securitySchemes: {},
specSource: 'openapi.yaml',
},
pathParams: {},
};
}

function createContext(
responseStatus: number,
options: {
requestHeaders?: Record<string, string>;
ignoreHeaders?: string[];
queryString?: string;
requestParameters?: OpenApiParameter[];
} = {}
): RuleContext {
const requestUrl = `https://api.example.com/order-items${
options.queryString ? `?${options.queryString}` : ''
}`;
const parsedUrl = new URL(requestUrl);
const exchange: NormalizedExchange = {
index: 0,
source: 'test',
request: {
method: 'GET',
url: requestUrl,
path: parsedUrl.pathname,
query: parsedUrl.searchParams,
protocol: parsedUrl.protocol,
protocolKnown: true,
host: parsedUrl.host,
headers: options.requestHeaders ?? {},
},
response: {
status: responseStatus,
headers: {},
},
};

return {
exchange,
matchedOperation: createMatchedOperation(options.requestParameters),
matchMode: 'strict-host',
hostCompatibleWithSpecServers: true,
ignoreHeaders: options.ignoreHeaders ? parseHeaderIgnoreList(options.ignoreHeaders) : undefined,
validateSchema: () => ({ valid: true, errors: [] }),
};
}

describe('schema-consistency required parameter check', () => {
const rule = new SchemaConsistencyRule();

function missingFilterFindings(context: RuleContext) {
return rule
.analyze(context)
.filter((finding) => finding.message === 'Missing required query parameter: "filter"');
}

it('flags a missing required query parameter when the server accepted the request', () => {
expect(missingFilterFindings(createContext(200))).toHaveLength(1);
});

it('does not flag a missing required query parameter when the server rejected the request with 4xx', () => {
expect(missingFilterFindings(createContext(400))).toHaveLength(0);
});
});

describe('schema-consistency undocumented header check', () => {
const rule = new SchemaConsistencyRule();

function undocumentedHeaderFindings(context: RuleContext) {
return rule
.analyze(context)
.filter((finding) => finding.message.startsWith('Undocumented header in traffic:'));
}

it('flags an undocumented header that is not in the ignore list', () => {
const context = createContext(200, { requestHeaders: { 'x-caddy-auth-token': 'secret' } });
expect(undocumentedHeaderFindings(context)).toHaveLength(1);
});

it('still flags undocumented headers when the server rejected the request with 4xx', () => {
const context = createContext(400, { requestHeaders: { 'x-caddy-auth-token': 'secret' } });
expect(undocumentedHeaderFindings(context)).toHaveLength(1);
});

it('skips headers matched by an exact name or a prefix pattern in --ignore-headers', () => {
const context = createContext(200, {
requestHeaders: {
'x-caddy-auth-token': 'secret',
'x-consumer-id': '42',
'x-consumer-teams': 'core',
},
ignoreHeaders: ['x-caddy-auth-token', 'x-consumer-*'],
});
expect(undocumentedHeaderFindings(context)).toHaveLength(0);
});
});

describe('schema-consistency deepObject query parameter check', () => {
const rule = new SchemaConsistencyRule();

it('maps bracketed query keys to a documented deepObject parameter and validates the object', () => {
const validatedValues: unknown[] = [];
const context = createContext(200, {
queryString: 'namespace[id]=acme&namespace[name]=acme&other[id]=1',
requestParameters: [
{
name: 'namespace',
in: 'query',
required: true,
style: 'deepObject',
schema: { type: 'object' },
},
],
});
context.validateSchema = (_schema, value) => {
validatedValues.push(value);
return { valid: true, errors: [] };
};

const findings = rule.analyze(context);

expect(
findings
.filter((finding) => finding.message.startsWith('Undocumented query parameter'))
.map((finding) => finding.message)
).toEqual(['Undocumented query parameter in traffic: "other[id]"']);
expect(
findings.filter((finding) => finding.message.startsWith('Missing required query parameter'))
).toHaveLength(0);
expect(validatedValues).toEqual([{ id: 'acme', name: 'acme' }]);
});
});
137 changes: 137 additions & 0 deletions packages/cli/src/__tests__/commands/drift/security-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { SecurityRule } from '../../../commands/drift/rules/builtins/security.js';
import type {
MatchedOperation,
NormalizedExchange,
RuleContext,
} from '../../../commands/drift/types/index.js';

function createMatchedOperation(): MatchedOperation {
return {
operation: {
operationId: 'listMenuItems',
method: 'get',
pathTemplate: '/menu',
pathRegex: /^\/menu$/,
pathParams: [],
pathScore: 1,
servers: [],
requestParameters: [],
requestBodyContent: {},
requestBodyRequired: false,
responseBodyContent: {},
security: undefined,
securitySchemes: {},
specSource: 'openapi.yaml',
},
pathParams: {},
};
}

function createContext(
requestUrl: string,
options?: {
headers?: Record<string, string>;
protocolKnown?: boolean;
}
): RuleContext {
const parsedUrl = new URL(requestUrl);
const exchange: NormalizedExchange = {
index: 0,
source: 'test',
request: {
method: 'GET',
url: requestUrl,
path: parsedUrl.pathname,
query: parsedUrl.searchParams,
protocol: parsedUrl.protocol,
protocolKnown: options?.protocolKnown ?? true,
host: parsedUrl.host,
headers: options?.headers ?? {},
},
response: {
status: 200,
headers: {},
},
};

return {
exchange,
matchedOperation: createMatchedOperation(),
matchMode: 'strict-host',
hostCompatibleWithSpecServers: true,
validateSchema: () => ({ valid: true, errors: [] }),
};
}

describe('security-baseline insecure transport check', () => {
const rule = new SecurityRule();
const authHeaders = { authorization: 'Bearer test-token' };

function transportFindings(context: RuleContext) {
return rule
.analyze(context)
.filter(
(finding) =>
finding.message === 'Potential credential exposure over insecure HTTP transport'
);
}

it('flags authenticated requests over plain HTTP to a public host', () => {
const findings = transportFindings(
createContext('http://api.example.com/menu', { headers: authHeaders })
);

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
ruleId: 'security-baseline',
severity: 'warning',
details: { hasAuthorizationHeader: true },
});
});

it('flags sensitive query parameters over plain HTTP to a public host', () => {
const findings = transportFindings(createContext('http://api.example.com/menu?api_key=12345'));

expect(findings).toHaveLength(1);
expect(findings[0].details).toMatchObject({
sensitiveQueryKeys: ['api_key'],
});
});

it.each([
'http://localhost:9000/menu',
'http://sub.localhost:9000/menu',
'http://127.0.0.1:4040/menu',
'http://127.1.2.3/menu',
'http://[::1]:8080/menu',
])('does not flag authenticated plain-HTTP requests to loopback host %s', (url) => {
expect(transportFindings(createContext(url, { headers: authHeaders }))).toHaveLength(0);
});

it('does not flag sensitive query parameters sent to a loopback host', () => {
expect(transportFindings(createContext('http://localhost:9000/menu?token=abc'))).toHaveLength(
0
);
});

it('does not flag unauthenticated plain-HTTP requests', () => {
expect(transportFindings(createContext('http://api.example.com/menu'))).toHaveLength(0);
});

it('does not flag authenticated HTTPS requests', () => {
expect(
transportFindings(createContext('https://api.example.com/menu', { headers: authHeaders }))
).toHaveLength(0);
});

it('does not flag when the capture did not record the scheme', () => {
expect(
transportFindings(
createContext('http://api.example.com/menu', {
headers: authHeaders,
protocolKnown: false,
})
)
).toHaveLength(0);
});
});
5 changes: 5 additions & 0 deletions packages/cli/src/commands/drift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ redocly drift ./traffic.har --api ./openapi.yaml --format json -o ./drift-report
- JSON-array traffic files (HAR/Kong/webserver-json) are read fully into memory.
For very large captures, prefer the NDJSON format.
- Builtin `owasp-api-top10` is opt-in via `--rules owasp-api-top10`.
- Builtin `security-baseline` does not report insecure-transport findings for loopback hosts (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`).
Sandboxed captures, for example, recorded with `redocly proxy` against a local target during e2e runs, stay warning-free.
- Builtin `schema-consistency` skips request-side checks (required parameters, required body, request-body schema) when the response is a `4xx` client error.
The server rejected the request, so validating it against the operation's success-path contract would report the server's own correct rejection as drift.
- Builtin `schema-consistency` understands `deepObject`-style query parameters: traffic keys like `name[property]=value` are matched to the documented parameter and validated against its object schema instead of being reported as undocumented.
1 change: 1 addition & 0 deletions packages/cli/src/commands/drift/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export async function runTrafficValidation(options: RunnerOptions): Promise<Runn
openApiIndex: options.openApiIndex,
matchMode: options.matchMode,
ignoreCookies: options.ignoreCookies,
ignoreHeaders: options.ignoreHeaders,
previewFindingsLimit: options.previewFindingsLimit,
activeRules: options.activeRules,
server: options.server,
Expand Down
Loading
Loading