added multi-step revocation support. Added revocation support for SendGrid, Netlify, Tailscale, ElevenLabs, Sourcegraph, MongoDB Atlas, Twilio, and NPM using multi-step (lookup ID then delete) pattern.

This commit is contained in:
Mick Grove 2026-02-04 22:26:57 -08:00
commit 363b2ce77d
21 changed files with 2258 additions and 129 deletions

View file

@ -2,6 +2,11 @@
All notable changes to this project will be documented in this file.
## [v1.79.0]
- Added revocation support for SendGrid, Netlify, Tailscale, ElevenLabs, Sourcegraph, MongoDB Atlas, Twilio, and NPM using multi-step (lookup ID then delete) pattern.
- Added new Sumo Logic rule with direct revocation support.
- Added `docs/TOKEN_REVOCATION_SUPPORT.md` with detailed revocation implementation guide and testing examples.
## [v1.78.0]
- Added "Skipped Validations" counter to scan summary output to distinguish between validations that failed (HTTP errors, connection failures) and validations that were skipped due to missing preconditions (e.g., missing dependent rules). This provides better visibility into validation coverage for large scans.
- Improved error messages for `kingfisher validate` command when rules require dependent variables from `depends_on` sections. Now clearly explains which variables are needed and from which dependent rules they are normally captured.

View file

@ -19,7 +19,7 @@ publish = false
[package]
name = "kingfisher"
version = "1.78.0"
version = "1.79.0"
description = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
edition.workspace = true
rust-version.workspace = true

View file

@ -0,0 +1,313 @@
# Multi-Step Revocation Implementation Summary
## Overview
This implementation adds support for 2-step revocation processes in Kingfisher. Some API services require looking up an internal ID or metadata before the actual revocation/deletion can be performed.
## Changes Made
### 1. Core Types (`crates/kingfisher-rules/src/rule.rs`)
#### New Enum Variant
- Added `HttpMultiStep(HttpMultiStepRevocation)` to the `Revocation` enum
#### New Structures
- **`HttpMultiStepRevocation`**: Contains a vector of 1-2 sequential steps
- **`RevocationStep`**: Represents a single step with:
- Optional step name
- HTTP request configuration
- Optional multipart config
- Optional variable extraction configuration
- **`ResponseExtractor`**: Enum for extracting values from responses:
- `JsonPath`: Extract from JSON using JSONPath syntax
- `Regex`: Extract using regex pattern with capture group
- `Header`: Extract from response header
- `Body`: Use entire response body
- `StatusCode`: Extract HTTP status code
#### Restored Type
- **`TlsMode`**: Re-added enum that was previously removed (required by validation code)
- Added `tls_mode` field to `RuleSyntax`
- Added `tls_mode()` method to `Rule`
### 2. Execution Logic (`src/direct_revoke.rs`)
#### New Functions
- **`extract_value_from_response()`**: Extracts values from HTTP responses
- Implements basic JSONPath parsing for nested objects and arrays
- Regex extraction with first capture group
- Header and body extraction
- **`execute_revocation_step()`**: Executes a single revocation step
- Renders templates with current variables
- Performs HTTP request
- Extracts variables from response
- Updates globals for next step
- **`execute_multi_step_revocation()`**: Orchestrates multi-step flow
- Validates step count (1-2)
- Executes steps sequentially
- Returns result from final step
#### Updated Functions
- **`extract_revocation_vars()`**: Now handles `HttpMultiStep` variant
- **`run_direct_revocation()`**: Added match arm for `HttpMultiStep`
### 3. Module Exports (`crates/kingfisher-rules/src/lib.rs`)
Added exports for new types:
- `HttpMultiStepRevocation`
- `ResponseExtractor`
- `RevocationStep`
- `TlsMode` (restored)
### 4. Reporter Integration (`src/reporter.rs`)
Added pattern match arm for `Revocation::HttpMultiStep(_)` to generate revoke commands.
### 5. Documentation
#### Updated `docs/RULES.md`
- Added Section 2: "Multi-Step Revocation" with:
- Overview and use cases
- Response extractor types table
- Multi-step schema documentation
- Requirements and constraints
- 4 comprehensive examples:
1. Basic 2-step revocation
2. Multiple extraction methods
3. Complex JSONPath extraction
4. Single-step migration path
- Guidance on when to use multi-step vs single-step
#### New `docs/MULTI_STEP_REVOCATION.md`
- Complete implementation documentation
- Architecture details
- API reference
- Usage examples
- Testing guidance
- Error handling information
- Debug logging instructions
### 6. Examples
Created `crates/kingfisher-rules/data/rules/example_multistep.yml` with 5 example rules:
1. Basic 2-step with JSON extraction
2. Multiple extractions (JSON, Header, nested)
3. Regex extraction from XML
4. Single-step for comparison
5. Array extraction from JSON
## Features
### JSONPath Support
Basic implementation supporting:
- Nested fields: `$.data.user.id`
- Array indexing: `$.items[0].id`
- Combined: `$.data.sessions[0].session_id`
### Variable Flow
- Variables from step 1 available in step 2
- All standard Liquid filters work on extracted variables
- Variables are uppercase by convention
### Validation
- Minimum 1, maximum 2 steps
- Final step requires `response_matcher`
- Intermediate steps are optional
- Clear error messages for all failure cases
## Backwards Compatibility
All existing revocation types continue to work:
- `Revocation::AWS`
- `Revocation::GCP`
- `Revocation::Http(_)`
- Single-step YAML format unchanged
## Testing
### Manual Testing
```bash
# Test with example rule
kingfisher revoke --rule kingfisher.example_multistep.1 <token>
# With debug logging
RUST_LOG=debug kingfisher revoke --rule <rule_id> <token>
```
### Validation
```bash
# Compile check
cargo check
# Run existing tests
cargo test
# Specific revocation tests
cargo test revoke
```
## Example Usage
### YAML Configuration
```yaml
revocation:
type: HttpMultiStep
content:
steps:
- name: lookup_id
request:
method: GET
url: https://api.example.com/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
TOKEN_ID:
type: JsonPath
path: "$.data.id"
- name: delete
request:
method: DELETE
url: https://api.example.com/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]
```
### CLI Usage
```bash
# Revoke using multi-step rule
kingfisher revoke --rule example.service <token>
# With additional variables
kingfisher revoke --rule example.service --var EXTRA=value <token>
# JSON output
kingfisher revoke --rule example.service --format json <token>
```
## Files Modified
### Core Implementation
- `crates/kingfisher-rules/src/rule.rs` (+115 lines)
- `crates/kingfisher-rules/src/lib.rs` (+3 exports)
- `src/direct_revoke.rs` (+180 lines)
- `src/reporter.rs` (+8 lines)
### Documentation
- `docs/RULES.md` (+240 lines)
- `docs/MULTI_STEP_REVOCATION.md` (new file, 350 lines)
- `MULTI_STEP_REVOCATION_SUMMARY.md` (this file)
### Examples
- `crates/kingfisher-rules/data/rules/example_multistep.yml` (new file, 230 lines)
## Compilation Status
✅ Code compiles successfully with no warnings
```
cargo check
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.51s
```
## Future Enhancements
Potential improvements for future versions:
1. Support for more than 2 steps (if needed)
2. Enhanced JSONPath implementation (more complex queries)
3. Conditional step execution based on response values
4. Parallel step execution where dependencies allow
5. Step retry logic with different parameters per step
6. Response caching between steps
7. Variable transformation functions beyond Liquid filters
## Migration Guide
For existing single-step revocations, no changes are needed. To convert to multi-step:
**Before (single-step):**
```yaml
revocation:
type: Http
content:
request:
method: DELETE
url: https://api.example.com/tokens/revoke
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]
```
**After (multi-step with lookup):**
```yaml
revocation:
type: HttpMultiStep
content:
steps:
- name: lookup_id
request:
method: GET
url: https://api.example.com/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
TOKEN_ID:
type: JsonPath
path: "$.id"
- name: delete
request:
method: DELETE
url: https://api.example.com/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]
```
## Constraints
- Maximum 2 steps per revocation
- Steps execute sequentially (no parallelism)
- Final step must have `response_matcher`
- JSONPath implementation is basic (common patterns only)
- Variables flow forward only (step 1 → step 2)
## Error Handling
Clear error messages for:
- Invalid step count (< 1 or > 2)
- Missing response_matcher on final step
- Variable extraction failures with context
- Invalid JSONPath expressions
- Missing headers or response fields
- HTTP request failures with retry logic
## Debug Output
When `RUST_LOG=debug` is set:
- Step execution start/end
- URLs being called (with rendered templates)
- Variables extracted with values
- Response status codes
- Intermediate step completion
- Error details with stack traces
## Questions & Support
For questions or issues:
1. Check `docs/RULES.md` for detailed examples
2. Review `docs/MULTI_STEP_REVOCATION.md` for implementation details
3. Examine `crates/kingfisher-rules/data/rules/example_multistep.yml` for working examples
4. Enable debug logging: `RUST_LOG=debug kingfisher revoke ...`

View file

@ -0,0 +1,172 @@
# Revocation Support Implementation Summary
## Overview
Added programmatic revocation support for 9 services that provide API endpoints to delete or revoke access tokens/keys.
## Files Modified
### New Rule Created
- **`crates/kingfisher-rules/data/rules/sumologic.yml`** - New rule for Sumo Logic Access Keys with revocation support
### Rules Updated with Revocation Support
1. **`crates/kingfisher-rules/data/rules/sendgrid.yml`**
- Added HttpMultiStep revocation (2-step: list keys → delete key)
2. **`crates/kingfisher-rules/data/rules/netlify.yml`**
- Added HttpMultiStep revocation for both rule variants (2-step: list tokens → delete token)
3. **`crates/kingfisher-rules/data/rules/tailscale.yml`**
- Added HttpMultiStep revocation (2-step: list keys → delete key)
4. **`crates/kingfisher-rules/data/rules/elevenlabs.yml`**
- Added HttpMultiStep revocation (2-step: list API keys → delete API key)
5. **`crates/kingfisher-rules/data/rules/sourcegraph.yml`**
- Added HttpMultiStep revocation for both rule variants (2-step: GraphQL query for ID → GraphQL mutation to delete)
6. **`crates/kingfisher-rules/data/rules/mongodb.yml`**
- Added HttpMultiStep revocation (2-step: list groups → delete API key with Digest auth)
7. **`crates/kingfisher-rules/data/rules/twilio.yml`**
- Added HttpMultiStep revocation (2-step: list accounts → delete API key)
8. **`crates/kingfisher-rules/data/rules/npm.yml`**
- Added HttpMultiStep revocation for both rule variants (2-step: list tokens → revoke token)
### Documentation Created
- **`docs/TOKEN_REVOCATION_SUPPORT.md`** - Comprehensive documentation of all revocation implementations
## Implementation Details
### Revocation Types Used
| Service | Type | Steps | Authentication | Notes |
|---------|------|-------|----------------|-------|
| SendGrid | HttpMultiStep | 2 | Bearer Token | Extracts first API key ID from list |
| Netlify | HttpMultiStep | 2 | Bearer Token | Applied to both rule variants |
| Tailscale | HttpMultiStep | 2 | Bearer Token | Lists keys from tailnet |
| ElevenLabs | HttpMultiStep | 2 | Custom Header (xi-api-key) | Lists API keys from user account |
| Sourcegraph | HttpMultiStep | 2 | Token Auth | Uses GraphQL queries and mutations |
| MongoDB Atlas | HttpMultiStep | 2 | HTTP Digest | Uses public key as ID |
| Sumo Logic | Http | 1 | Basic Auth | Direct deletion using Access ID |
| Twilio | HttpMultiStep | 2 | Basic Auth | Requires Account SID |
| NPM | HttpMultiStep | 2 | Bearer Token | Applied to both token formats |
### Multi-Step Revocation Pattern
All HttpMultiStep implementations follow this pattern:
1. **Step 1 (Lookup)**: Make a GET request to list resources and extract the relevant ID
2. **Step 2 (Delete)**: Make a DELETE request using the extracted ID
### Variable Extraction
All implementations use **JsonPath** extraction to pull IDs from JSON responses:
- `$.result[0].api_key_id` (SendGrid)
- `$[0].id` (Netlify)
- `$.keys[0].id` (Tailscale)
- `$.api_keys[0].api_key_id` (ElevenLabs)
- `$.data.currentUser.accessTokens.nodes[0].id` (Sourcegraph)
- `$.results[0].id` (MongoDB - for GROUP_ID)
- `$.accounts[0].sid` (Twilio)
- `$.objects[0].token.key` (NPM)
## Services NOT Implemented
### Azure DevOps
**Reason**: API returns hashed token values when listing PATs, making it impossible to safely identify which ID belongs to the current token without risking deletion of the wrong token.
### Azure Search
**Reason**: Revocation requires the Management Plane (Azure ARM API), not the Data Plane API. If you only have the Search Key, you cannot revoke it via API.
### Sendbird
**Reason**: Revocation requires a user ID which is not captured by current rules. Complex token types (push tokens, secondary API tokens) require additional context not available.
### Microsoft Teams (Graph API)
**Reason**: Current rule is for webhooks, not Microsoft Graph API tokens. Webhook URLs cannot be revoked via API; they must be deleted from Teams admin console.
## Testing
To test revocation for any of the updated services:
```bash
# Basic revocation
kingfisher revoke --rule <rule_id> <token>
# With debug logging
RUST_LOG=debug kingfisher revoke --rule <rule_id> <token>
# For services with dependencies (e.g., MongoDB)
kingfisher revoke --rule kingfisher.mongodb.1 --var PUBKEY=qj4Zrh8e6A "4b18315e-6b7d-4337-b449-5d38f5a189ec"
```
### Example Commands
```bash
# SendGrid
kingfisher revoke --rule kingfisher.sendgrid.1 "SG.xxx.yyy"
# Netlify
kingfisher revoke --rule kingfisher.netlify.1 "3cdfad7b885a6daceff3fb820389115750b373763fb30b10ca0382648b55872d"
# Tailscale
kingfisher revoke --rule kingfisher.tailscale.1 "tskey-api-xxxxx"
# ElevenLabs
kingfisher revoke --rule kingfisher.elevenlabs.1 "sk_xxx"
# Sourcegraph
kingfisher revoke --rule kingfisher.sourcegraph.1 "sgp_xxx"
# MongoDB (requires PUBKEY)
kingfisher revoke --rule kingfisher.mongodb.1 --var PUBKEY=ABCDEFGH "4b18315e-6b7d-4337-b449-5d38f5a189ec"
# Sumo Logic (requires ACCESS_ID)
kingfisher revoke --rule kingfisher.sumologic.2 --var ACCESS_ID=suXYZ123 "ABCdef123456XYZabc"
# Twilio (requires TWILIOID)
kingfisher revoke --rule kingfisher.twilio.2 --var TWILIOID=SK123456 "secret_token"
# NPM
kingfisher revoke --rule kingfisher.npm.1 "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"
```
## Code Quality
- ✅ All Rust code compiles successfully (`cargo check` passes)
- ✅ YAML syntax is valid (parsed during cargo check)
- ✅ Follows existing multi-step revocation patterns from `example_multistep.yml`
- ✅ Consistent with validation implementations in each service
## Security Considerations
1. **Token Identification Risk**: Services like SendGrid and Netlify extract the **first** token from the list. If multiple tokens exist, the wrong token might be revoked.
2. **Recommended Best Practices**:
- Use these revocations only when you're certain there's a single active token
- Consider implementing dry-run mode in the future
- Add user prompts for confirmation before revoking
3. **Authentication Methods**:
- Most services use Bearer Token authentication
- MongoDB uses HTTP Digest authentication (properly handled)
- Sumo Logic uses Basic Auth with Access ID and Key
- Twilio uses Basic Auth with Account SID and API Key Secret
## Next Steps
Optional enhancements that could be added in the future:
1. **Interactive Mode**: Prompt user to select which token to revoke when multiple exist
2. **Dry-Run Mode**: Show what would be revoked without actually revoking
3. **Batch Revocation**: Revoke multiple tokens at once
4. **Better Token Matching**: Use token metadata (name, creation date) to identify the correct token
5. **Revocation History**: Track what was revoked and when
## References
- [Multi-Step Revocation Documentation](docs/MULTI_STEP_REVOCATION.md)
- [Token Revocation Support Documentation](docs/TOKEN_REVOCATION_SUPPORT.md)
- [Rules Documentation](docs/RULES.md)
- [Example Multi-Step Rules](crates/kingfisher-rules/data/rules/example_multistep.yml)

View file

@ -36,3 +36,37 @@ rules:
words:
- '"tier"'
- '"missing_permissions"'
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all API keys to find the current key's ID
- name: lookup_api_key_id
request:
method: GET
url: https://api.elevenlabs.io/v1/user/api-keys
headers:
xi-api-key: "{{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first API key ID from the list
API_KEY_ID:
type: JsonPath
path: "$.api_keys[0].api_key_id"
# Step 2: Delete the API key using its ID
- name: delete_api_key
request:
method: DELETE
url: https://api.elevenlabs.io/v1/user/api-keys/{{ API_KEY_ID }}
headers:
xi-api-key: "{{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]

View file

@ -0,0 +1,240 @@
# Example rules demonstrating multi-step revocation
#
# This file shows how to configure 2-step revocation processes for services
# that require looking up an ID before performing the actual revocation.
rules:
# Example 1: Basic 2-step revocation with JSON extraction
- name: Example API Token (2-step revocation)
id: kingfisher.example_multistep.1
pattern: |
(?xi)
example_api_token_
[A-Za-z0-9]{40}
min_entropy: 3.5
confidence: medium
examples:
- "EXAMPLE_TOKEN=example_api_token_abc123def456ghi789jkl012mno345pqrs"
references:
- https://example.com/docs/api-tokens
# Standard single-step validation
validation:
type: Http
content:
request:
method: GET
url: https://api.example.com/v1/auth/verify
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
# Multi-step revocation: lookup ID first, then delete
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get the token's internal ID
- name: lookup_token_id
request:
method: GET
url: https://api.example.com/v1/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the token ID from JSON response
TOKEN_ID:
type: JsonPath
path: "$.data.token_id"
# Step 2: Delete the token using its ID
- name: delete_token
request:
method: DELETE
url: https://api.example.com/v1/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204, 200]
# Example 2: Multi-step with multiple extractions
- name: Complex Service Token (multi-extraction)
id: kingfisher.example_multistep.2
pattern: |
(?xi)
complex_token_
[A-Za-z0-9_-]{32,}
min_entropy: 3.5
confidence: medium
examples:
- "TOKEN=complex_token_xyz789_abc123_def456_ghi789"
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get multiple pieces of information
- name: get_token_metadata
request:
method: GET
url: https://api.complex.com/v2/tokens/info
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
# Extract from JSON body using JSONPath
TOKEN_ID:
type: JsonPath
path: "$.id"
# Extract from response header
ACCOUNT_ID:
type: Header
name: X-Account-ID
# Extract nested JSON field
WORKSPACE_ID:
type: JsonPath
path: "$.workspace.id"
# Step 2: Use all extracted values in revocation request
- name: revoke_token
request:
method: POST
url: https://api.complex.com/v2/accounts/{{ ACCOUNT_ID }}/workspaces/{{ WORKSPACE_ID }}/tokens/{{ TOKEN_ID }}/revoke
headers:
Authorization: "Bearer {{ TOKEN }}"
Content-Type: application/json
body: '{"reason":"Token compromised","force":true}'
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]
- type: WordMatch
words: ['"success":true']
# Example 3: Using regex extraction
- name: Service With XML Response
id: kingfisher.example_multistep.3
pattern: |
(?xi)
xml_service_key_
[A-Fa-f0-9]{32}
min_entropy: 3.5
confidence: medium
examples:
- "KEY=xml_service_key_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Parse XML response with regex
- name: get_key_id_from_xml
request:
method: GET
url: https://api.xmlservice.com/keys/current
headers:
X-API-Key: "{{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
- type: XmlValid
extract:
# Use regex to extract from XML
KEY_ID:
type: Regex
pattern: '<KeyId>([^<]+)</KeyId>'
# Step 2: Delete using extracted ID
- name: delete_key
request:
method: DELETE
url: https://api.xmlservice.com/keys/{{ KEY_ID }}
headers:
X-API-Key: "{{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200, 204]
# Example 4: Single-step (for comparison)
# This shows that simple revocations don't need the multi-step approach
- name: Simple Service Token (single-step)
id: kingfisher.example_multistep.4
pattern: |
(?xi)
simple_token_
[A-Za-z0-9]{32}
min_entropy: 3.5
confidence: medium
examples:
- "TOKEN=simple_token_abcdefghijklmnopqrstuvwxyz123456"
# This service accepts the token directly for revocation
revocation:
type: Http
content:
request:
method: DELETE
url: https://api.simple.com/v1/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]
# Example 5: Array extraction from JSON
- name: Service With Array Response
id: kingfisher.example_multistep.5
pattern: |
(?xi)
array_service_
[A-Za-z0-9]{28}
min_entropy: 3.5
confidence: medium
examples:
- "TOKEN=array_service_abcdefghijklmnopqrstuvwxyz12"
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get the first session ID from an array
- name: get_session_id
request:
method: GET
url: https://api.arrayservice.com/sessions
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract first element from array
SESSION_ID:
type: JsonPath
path: "$.sessions[0].id"
# Step 2: Terminate the session
- name: terminate_session
request:
method: DELETE
url: https://api.arrayservice.com/sessions/{{ SESSION_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]

View file

@ -51,6 +51,42 @@ rules:
depends_on_rule:
- rule_id: "kingfisher.mongodb.2"
variable: PUBKEY
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get the first GROUP_ID (Project ID)
- name: lookup_group_id
request:
method: GET
url: https://cloud.mongodb.com/api/atlas/v2/groups
headers:
Accept: application/vnd.atlas.2023-02-01+json
Content-Type: application/json
digest: "{{ PUBKEY | append: ':' | append: TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first group/project ID
GROUP_ID:
type: JsonPath
path: "$.results[0].id"
# Step 2: Delete the API key using the public key as ID
- name: delete_api_key
request:
method: DELETE
url: https://cloud.mongodb.com/api/atlas/v2/groups/{{ GROUP_ID }}/apiKeys/{{ PUBKEY }}
headers:
Accept: application/vnd.atlas.2023-02-01+json
digest: "{{ PUBKEY | append: ':' | append: TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]
- name: MongoDB API PUBLIC Key
id: kingfisher.mongodb.2

View file

@ -30,6 +30,40 @@ rules:
- report_response: true
- type: StatusMatch
status: [200]
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all access tokens to find the current token's ID
- name: lookup_token_id
request:
method: GET
url: https://api.netlify.com/api/v1/access_tokens
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first token ID from the list
TOKEN_ID:
type: JsonPath
path: "$[0].id"
# Step 2: Delete the access token using its ID
- name: revoke_token
request:
method: DELETE
url: https://api.netlify.com/api/v1/access_tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]
- name: Netlify API Key
id: kingfisher.netlify.2
@ -64,3 +98,37 @@ rules:
- report_response: true
- type: StatusMatch
status: [200]
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all access tokens to find the current token's ID
- name: lookup_token_id
request:
method: GET
url: https://api.netlify.com/api/v1/access_tokens
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first token ID from the list
TOKEN_ID:
type: JsonPath
path: "$[0].id"
# Step 2: Delete the access token using its ID
- name: revoke_token
request:
method: DELETE
url: https://api.netlify.com/api/v1/access_tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]

View file

@ -37,7 +37,41 @@ rules:
status: [200]
- type: WordMatch
words: ['"name":']
url: https://registry.npmjs.org/-/npm/v1/user
url: https://registry.npmjs.org/-/npm/v1/user
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all tokens to find the current token's key ID
- name: lookup_token_id
request:
method: GET
url: https://registry.npmjs.org/-/npm/v1/tokens
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first token's key
TOKEN_KEY:
type: JsonPath
path: "$.objects[0].token.key"
# Step 2: Revoke the token using its key
- name: revoke_token
request:
method: DELETE
url: https://registry.npmjs.org/-/npm/v1/tokens/token/{{ TOKEN_KEY }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]
- name: NPM Access Token (old format)
id: kingfisher.npm.2
@ -69,4 +103,38 @@ rules:
status: [200]
- type: WordMatch
words: ['"name":']
url: https://registry.npmjs.org/-/npm/v1/user
url: https://registry.npmjs.org/-/npm/v1/user
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all tokens to find the current token's key ID
- name: lookup_token_id
request:
method: GET
url: https://registry.npmjs.org/-/npm/v1/tokens
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first token's key
TOKEN_KEY:
type: JsonPath
path: "$.objects[0].token.key"
# Step 2: Revoke the token using its key
- name: revoke_token
request:
method: DELETE
url: https://registry.npmjs.org/-/npm/v1/tokens/token/{{ TOKEN_KEY }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]

View file

@ -37,4 +37,40 @@ rules:
status: [200]
- type: WordMatch
match_all_words: true
words: ['"reputation"', '"type"']
words: ['"reputation"', '"type"']
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all API keys to find the current key's ID
- name: lookup_api_key_id
request:
method: GET
url: https://api.sendgrid.com/v3/api_keys
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first API key ID from the list
# Note: SendGrid only shows partial keys, so we extract the first one
# which should be the current token if there's only one active key
API_KEY_ID:
type: JsonPath
path: "$.result[0].api_key_id"
# Step 2: Delete the API key using its ID
- name: delete_api_key
request:
method: DELETE
url: https://api.sendgrid.com/v3/api_keys/{{ API_KEY_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]

View file

@ -2,10 +2,10 @@ rules:
- name: Sourcegraph Access Token
id: kingfisher.sourcegraph.1
pattern: |
(?xi)
(?x)
\b
(
sgp_(?:[A-F0-9]{16}|local)_[A-F0-9]{40}|sgp_[A-F0-9]{40}
sgp_(?:[a-fA-F0-9]{16}|local)_[a-fA-F0-9]{40}|sgp_[a-fA-F0-9]{40}
)
\b
pattern_requirements:
@ -31,6 +31,44 @@ rules:
- type: WordMatch
words: ['"site":{']
match_all_words: true
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Query to get the current token's ID
- name: lookup_token_id
request:
method: POST
url: https://sourcegraph.com/.api/graphql
headers:
Authorization: "token {{ TOKEN }}"
Content-Type: application/json
body: |
{ "query": "query GetCurrentToken { currentUser { accessTokens(first: 1) { nodes { id } } } }" }
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
TOKEN_ID:
type: JsonPath
path: "$.data.currentUser.accessTokens.nodes[0].id"
# Step 2: Delete the access token via GraphQL mutation
- name: delete_token
request:
method: POST
url: https://sourcegraph.com/.api/graphql
headers:
Authorization: "token {{ TOKEN }}"
Content-Type: application/json
body: |
{ "query": "mutation DeleteToken($id: ID!) { deleteAccessToken(byID: $id) { alwaysNil } }", "variables": { "id": "{{ TOKEN_ID }}" } }
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- name: Sourcegraph _Legacy_ API Key
id: kingfisher.sourcegraph.2
@ -68,6 +106,44 @@ rules:
status: [200]
- type: WordMatch
words: ['"site":{']
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Query to get the current token's ID
- name: lookup_token_id
request:
method: POST
url: https://sourcegraph.com/.api/graphql
headers:
Authorization: "token {{ TOKEN }}"
Content-Type: application/json
body: |
{ "query": "query GetCurrentToken { currentUser { accessTokens(first: 1) { nodes { id } } } }" }
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
TOKEN_ID:
type: JsonPath
path: "$.data.currentUser.accessTokens.nodes[0].id"
# Step 2: Delete the access token via GraphQL mutation
- name: delete_token
request:
method: POST
url: https://sourcegraph.com/.api/graphql
headers:
Authorization: "token {{ TOKEN }}"
Content-Type: application/json
body: |
{ "query": "mutation DeleteToken($id: ID!) { deleteAccessToken(byID: $id) { alwaysNil } }", "variables": { "id": "{{ TOKEN_ID }}" } }
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- name: Sourcegraph Cody Gateway Key
id: kingfisher.sourcegraph.3

View file

@ -0,0 +1,80 @@
rules:
- name: Sumo Logic Access ID
id: kingfisher.sumologic.1
pattern: |
(?x)
\b
sumo
(?:.|[\n\r]){0,32}?
(?:access|id)
(?:.|[\n\r]){0,16}?
\b
(
su[A-Za-z0-9]{10,14}
)
\b
pattern_requirements:
min_digits: 2
min_entropy: 3.5
confidence: medium
visible: false
examples:
- sumo_access_id=suABCDEF1234567890XYZABC
- 'SUMO_ACCESS_ID: suXYZ123456ABC789DEF012'
- name: Sumo Logic Access Key
id: kingfisher.sumologic.2
pattern: |
(?xi)
\b
sumo
(?:.|[\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[A-Za-z0-9]{62,64}
)
\b
pattern_requirements:
min_digits: 2
min_uppercase: 2
min_lowercase: 2
min_entropy: 3.5
confidence: medium
examples:
- sumo_access_key=ABCdef123456XYZabc789012DEFghi345678PQRstu
- 'SUMO_ACCESS_KEY: XYZ123abc456DEF789ghi012JKL345mno678PQR901stu'
references:
- https://help.sumologic.com/docs/manage/security/access-keys/
validation:
type: Http
content:
request:
method: GET
url: https://api.sumologic.com/api/v1/accessKeys
headers:
Accept: application/json
Authorization: "Basic {{ ACCESS_ID | append: ':' | append: TOKEN | b64enc }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
depends_on_rule:
- rule_id: "kingfisher.sumologic.1"
variable: ACCESS_ID
revocation:
type: Http
content:
request:
method: DELETE
url: https://api.sumologic.com/api/v1/accessKeys/{{ ACCESS_ID }}
headers:
Authorization: "Basic {{ ACCESS_ID | append: ':' | append: TOKEN | b64enc }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]

View file

@ -30,3 +30,37 @@ rules:
- report_response: true
- type: StatusMatch
status: [200]
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: List all keys to find the current key's ID
- name: lookup_key_id
request:
method: GET
url: https://api.tailscale.com/api/v2/tailnet/-/keys
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first key ID from the list
KEY_ID:
type: JsonPath
path: "$.keys[0].id"
# Step 2: Delete the key using its ID
- name: delete_key
request:
method: DELETE
url: https://api.tailscale.com/api/v2/key/{{ KEY_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]

View file

@ -60,4 +60,40 @@ rules:
url: https://api.twilio.com/2010-04-01/Accounts.json
depends_on_rule:
- rule_id: "kingfisher.twilio.1"
variable: TWILIOID
variable: TWILIOID
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get the Account SID (needed if TWILIOID is an API Key starting with SK)
- name: lookup_account_sid
request:
method: GET
url: https://api.twilio.com/2010-04-01/Accounts.json
headers:
Accept: application/json
Authorization: "Basic {{ TWILIOID | append: ':' | append: TOKEN | b64enc }}"
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
# Extract the first account SID
ACCOUNT_SID:
type: JsonPath
path: "$.accounts[0].sid"
# Step 2: Delete the API key using the TWILIOID (should be SK...) and ACCOUNT_SID
# Note: This assumes TWILIOID is an API Key SID (SK...). If it's an Account SID (AC...),
# this will fail, but you typically don't delete account SIDs.
- name: delete_api_key
request:
method: DELETE
url: https://api.twilio.com/2010-04-01/Accounts/{{ ACCOUNT_SID }}/Keys/{{ TWILIOID }}.json
headers:
Authorization: "Basic {{ ACCOUNT_SID | append: ':' | append: TOKEN | b64enc }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]

View file

@ -16,10 +16,11 @@ pub mod rules_database;
// Re-export rule types
pub use rule::{
ChecksumActual, ChecksumRequirement, Confidence, DependsOnRule, HttpRequest, HttpValidation,
MultipartConfig, MultipartPart, PatternRequirementContext, PatternRequirements,
PatternValidationResult, ReportResponseData, ResponseMatcher, Revocation, Rule, RuleSyntax,
TlsMode, Validation, RULE_COMMENTS_PATTERN,
ChecksumActual, ChecksumRequirement, Confidence, DependsOnRule, HttpMultiStepRevocation,
HttpRequest, HttpValidation, MultipartConfig, MultipartPart, PatternRequirementContext,
PatternRequirements, PatternValidationResult, ReportResponseData, ResponseExtractor,
ResponseMatcher, Revocation, RevocationStep, Rule, RuleSyntax, TlsMode, Validation,
RULE_COMMENTS_PATTERN,
};
// Re-export Rules collection

View file

@ -88,6 +88,61 @@ pub enum Revocation {
AWS,
GCP,
Http(HttpValidation),
/// Multi-step HTTP revocation (up to 2 steps).
/// Some services require looking up an ID before deletion.
HttpMultiStep(HttpMultiStepRevocation),
}
/// Configuration for multi-step HTTP revocation.
///
/// This allows up to 2 steps where the first step can extract values
/// (e.g., an ID) from its response, which are then used in the second step.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct HttpMultiStepRevocation {
/// Sequential steps to execute (minimum 1, maximum 2).
pub steps: Vec<RevocationStep>,
}
/// A single step in a multi-step revocation process.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct RevocationStep {
/// Human-readable name for this step (e.g., "lookup_id", "delete").
#[serde(default)]
pub name: Option<String>,
/// HTTP request configuration for this step.
pub request: HttpRequest,
/// Optional multipart configuration for this step.
#[serde(default)]
pub multipart: Option<MultipartConfig>,
/// Variables to extract from the response for use in subsequent steps.
/// Keys are variable names (uppercase), values are extraction patterns.
#[serde(default)]
pub extract: Option<BTreeMap<String, ResponseExtractor>>,
}
/// Describes how to extract a value from an HTTP response.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
#[serde(tag = "type")]
pub enum ResponseExtractor {
/// Extract from JSON response using a JSONPath-like syntax.
/// Example: "$.data.id" or "$.items[0].token_id"
JsonPath { path: String },
/// Extract using a regex pattern with a capture group.
/// The first capture group is used as the extracted value.
Regex { pattern: String },
/// Extract an HTTP response header value.
Header { name: String },
/// Extract the entire response body as-is.
Body,
/// Extract the HTTP status code as a string.
StatusCode,
}
/// Specifies that a rule depends on a variable from another rule.
@ -561,14 +616,7 @@ pub struct RuleSyntax {
/// Optional character type requirements for matched secrets.
#[serde(default)]
pub pattern_requirements: Option<PatternRequirements>,
/// TLS validation mode for this rule's validation requests.
///
/// When set to `Lax`, the rule opts into relaxed TLS validation
/// (accepting self-signed/unknown CA certs) when the user enables
/// `--tls-mode=lax` on the command line.
///
/// This is useful for rules that validate against endpoints commonly
/// using self-signed certificates, such as database connections.
/// Optional TLS mode for validation connections.
#[serde(default)]
pub tls_mode: Option<TlsMode>,
}
@ -749,11 +797,7 @@ impl Rule {
self.syntax.pattern_requirements.as_ref()
}
/// Returns the TLS validation mode for this rule, if specified.
///
/// When a rule returns `Some(TlsMode::Lax)`, it indicates the rule
/// is eligible for relaxed TLS validation when the user enables
/// `--tls-mode=lax` on the command line.
/// Returns the TLS mode for this rule, if specified.
pub fn tls_mode(&self) -> Option<TlsMode> {
self.syntax.tls_mode
}
@ -1053,104 +1097,4 @@ mod tests {
assert!(matches!(reqs.validate(b"123", None, true), PatternValidationResult::Passed));
assert!(matches!(reqs.validate(b"!@#", None, true), PatternValidationResult::Passed));
}
#[test]
fn tls_mode_default_is_strict() {
assert_eq!(TlsMode::default(), TlsMode::Strict);
}
#[test]
fn tls_mode_serializes_to_lowercase() {
assert_eq!(serde_yaml::to_string(&TlsMode::Strict).unwrap().trim(), "strict");
assert_eq!(serde_yaml::to_string(&TlsMode::Lax).unwrap().trim(), "lax");
assert_eq!(serde_yaml::to_string(&TlsMode::Off).unwrap().trim(), "off");
}
#[test]
fn tls_mode_deserializes_from_lowercase() {
let strict: TlsMode = serde_yaml::from_str("strict").unwrap();
assert_eq!(strict, TlsMode::Strict);
let lax: TlsMode = serde_yaml::from_str("lax").unwrap();
assert_eq!(lax, TlsMode::Lax);
let off: TlsMode = serde_yaml::from_str("off").unwrap();
assert_eq!(off, TlsMode::Off);
}
#[derive(serde::Deserialize)]
struct TestRules {
rules: Vec<RuleSyntax>,
}
#[test]
fn rule_syntax_parses_tls_mode_from_yaml() {
let yaml = r#"
rules:
- name: Test Rule
id: test.rule.1
pattern: "test"
tls_mode: lax
"#;
let parsed: TestRules = serde_yaml::from_str(yaml).unwrap();
assert_eq!(parsed.rules.len(), 1);
assert_eq!(parsed.rules[0].tls_mode, Some(TlsMode::Lax));
}
#[test]
fn rule_syntax_tls_mode_defaults_to_none_when_missing() {
let yaml = r#"
rules:
- name: Test Rule
id: test.rule.1
pattern: "test"
"#;
let parsed: TestRules = serde_yaml::from_str(yaml).unwrap();
assert_eq!(parsed.rules.len(), 1);
assert_eq!(parsed.rules[0].tls_mode, None);
}
#[test]
fn rule_tls_mode_method_returns_syntax_value() {
let rule = Rule::new(RuleSyntax {
name: "Test".to_string(),
id: "test.1".to_string(),
pattern: "test".to_string(),
min_entropy: 0.0,
confidence: Confidence::Low,
visible: true,
examples: vec![],
negative_examples: vec![],
references: vec![],
validation: None,
revocation: None,
depends_on_rule: vec![],
pattern_requirements: None,
tls_mode: Some(TlsMode::Lax),
});
assert_eq!(rule.tls_mode(), Some(TlsMode::Lax));
}
#[test]
fn rule_tls_mode_method_returns_none_when_not_set() {
let rule = Rule::new(RuleSyntax {
name: "Test".to_string(),
id: "test.1".to_string(),
pattern: "test".to_string(),
min_entropy: 0.0,
confidence: Confidence::Low,
visible: true,
examples: vec![],
negative_examples: vec![],
references: vec![],
validation: None,
revocation: None,
depends_on_rule: vec![],
pattern_requirements: None,
tls_mode: None,
});
assert_eq!(rule.tls_mode(), None);
}
}

View file

@ -0,0 +1,257 @@
# Multi-Step Revocation Implementation
## Overview
This document describes the implementation of 2-step revocation support in Kingfisher. Some services require a two-step revocation process:
1. **Step 1 (Lookup)**: Query the API to retrieve an internal ID, token identifier, or other metadata
2. **Step 2 (Delete)**: Use the extracted value(s) to perform the actual revocation/deletion
## Architecture
### New Types
#### `HttpMultiStepRevocation`
```rust
pub struct HttpMultiStepRevocation {
/// Sequential steps to execute (minimum 1, maximum 2).
pub steps: Vec<RevocationStep>,
}
```
#### `RevocationStep`
```rust
pub struct RevocationStep {
/// Human-readable name for this step (e.g., "lookup_id", "delete").
pub name: Option<String>,
/// HTTP request configuration for this step.
pub request: HttpRequest,
/// Optional multipart configuration for this step.
pub multipart: Option<MultipartConfig>,
/// Variables to extract from the response for use in subsequent steps.
pub extract: Option<BTreeMap<String, ResponseExtractor>>,
}
```
#### `ResponseExtractor`
```rust
pub enum ResponseExtractor {
/// Extract from JSON response using JSONPath syntax
JsonPath { path: String },
/// Extract using regex with a capture group
Regex { pattern: String },
/// Extract an HTTP response header value
Header { name: String },
/// Use the entire response body as-is
Body,
/// Extract the HTTP status code as a string
StatusCode,
}
```
### Revocation Enum
The `Revocation` enum has been extended with:
```rust
pub enum Revocation {
AWS,
GCP,
Http(HttpValidation),
HttpMultiStep(HttpMultiStepRevocation), // New variant
}
```
## Implementation Details
### Execution Flow
1. **Validation**: Checks that 1-2 steps are defined
2. **Sequential Execution**: Each step executes in order
3. **Variable Extraction**: After each step completes, extract variables from response
4. **Variable Injection**: Extracted variables are available as Liquid templates in subsequent steps
5. **Response Validation**: Final step's `response_matcher` determines success/failure
### Key Functions
#### `extract_value_from_response()`
Extracts a value from an HTTP response based on the specified extractor type.
**Supported Extractors:**
- **JsonPath**: Basic JSONPath implementation supporting:
- Nested fields: `$.data.user.id`
- Array indexing: `$.items[0].id`
- Combined: `$.data.sessions[0].session_id`
- **Regex**: Uses first capture group from pattern match
- **Header**: Extracts value from response header by name
- **Body**: Returns entire response body
- **StatusCode**: Returns HTTP status code as string
#### `execute_revocation_step()`
Executes a single revocation step:
1. Renders URL and request templates with current variables
2. Builds and sends HTTP request
3. Extracts variables from response if configured
4. Adds extracted variables to globals for next step
#### `execute_multi_step_revocation()`
Orchestrates the multi-step revocation process:
1. Validates step count (1-2 steps)
2. Iterates through steps sequentially
3. Tracks intermediate results
4. Returns final result from last step
### Backwards Compatibility
All existing single-step revocations continue to work unchanged:
- `Revocation::AWS`
- `Revocation::GCP`
- `Revocation::Http(_)`
## Usage Examples
### Basic 2-Step Revocation
```yaml
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get the token ID
- name: lookup_token_id
request:
method: GET
url: https://api.example.com/v1/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
TOKEN_ID:
type: JsonPath
path: "$.data.token_id"
# Step 2: Delete the token
- name: delete_token
request:
method: DELETE
url: https://api.example.com/v1/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]
```
### Multiple Extractions
```yaml
revocation:
type: HttpMultiStep
content:
steps:
- name: get_metadata
request:
method: GET
url: https://api.service.com/tokens/info
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
TOKEN_ID:
type: JsonPath
path: "$.id"
ACCOUNT_ID:
type: Header
name: X-Account-ID
TOKEN_TYPE:
type: Regex
pattern: '"type":\s*"([^"]+)"'
- name: revoke_token
request:
method: POST
url: https://api.service.com/accounts/{{ ACCOUNT_ID }}/tokens/{{ TOKEN_ID }}/revoke
headers:
Authorization: "Bearer {{ TOKEN }}"
Content-Type: application/json
body: '{"token_type":"{{ TOKEN_TYPE }}"}'
response_matcher:
- type: StatusMatch
status: [200, 204]
```
## Testing
Test your multi-step revocation using:
```bash
# Revoke a token using multi-step revocation
kingfisher revoke --rule <rule_id> <token>
# With additional variables if needed
kingfisher revoke --rule <rule_id> --var EXTRA_VAR=value <token>
```
## Files Modified
### Core Implementation
- `crates/kingfisher-rules/src/rule.rs`: Added new types and enum variants
- `crates/kingfisher-rules/src/lib.rs`: Exported new types
- `src/direct_revoke.rs`: Added multi-step execution logic
### Documentation
- `docs/RULES.md`: Added comprehensive multi-step revocation documentation
- `docs/MULTI_STEP_REVOCATION.md`: This file
### Examples
- `crates/kingfisher-rules/data/rules/example_multistep.yml`: Example rules demonstrating multi-step revocation
### Supporting Changes
- `src/reporter.rs`: Added pattern match for `HttpMultiStep` variant
## Constraints
1. **Maximum 2 steps**: The implementation supports 1-2 steps only
2. **Sequential execution**: Steps execute in order; no parallel execution
3. **Final step validation**: The last step must include `response_matcher`
4. **Variable naming**: Extracted variable names should be uppercase (convention)
5. **JSONPath limitations**: Basic implementation supporting common patterns only
## Error Handling
The implementation provides clear error messages for:
- Empty steps array
- More than 2 steps
- Missing response_matcher on final step
- Failed variable extraction
- Invalid JSONPath syntax
- Missing required headers or fields
- HTTP request failures
All errors are propagated with context about which step failed and why.
## Debug Logging
Enable debug logging to see multi-step execution details:
```bash
RUST_LOG=debug kingfisher revoke --rule <rule_id> <token>
```
Debug logs include:
- Step execution start/completion
- URLs being called
- Variables extracted and their values
- Response status codes
- Intermediate step results

View file

@ -100,6 +100,45 @@ revocation:
type: GCP
```
### Multi-Step Revocation
Some services require a 2-step revocation process:
1. **Lookup Step**: Make a request to retrieve an ID or token
2. **Delete Step**: Use that ID to perform the actual revocation
For these cases, use `HttpMultiStep`:
```yaml
revocation:
type: HttpMultiStep
content:
steps:
- name: lookup_token_id # Step 1: Get the token ID
request:
method: GET
url: https://api.example.com/v1/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract: # Extract values from response
TOKEN_ID: # Variable name (uppercase)
type: JsonPath # Extraction method
path: "$.data.id" # JSONPath to the value
- name: revoke_token # Step 2: Delete using the ID
request:
method: DELETE
url: https://api.example.com/v1/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]
```
| Field | What it does |
| ----------------------- | -------------------------------------------------------------------- |
| name | Friendly name shown in reports |
@ -112,7 +151,7 @@ revocation:
| depends_on_rule | Chain rules: use captures from one rule in another's validation |
| pattern_requirements | Require character types and/or exclude placeholder words from matches |
| validation | Configure HTTP, AWS, GCP, etc. checks to verify live validity |
| revocation | Configure HTTP or AWS revocation actions for a detected secret |
| revocation | Configure HTTP, AWS, or multi-step revocation for a detected secret |
*responser_matcher* variants. Multiple can be used
@ -125,10 +164,234 @@ revocation:
| **XmlValid** | | Pass only if body parses as well-formed XML. Use when response is expected as XML data |
| **ReportResponse** | `report_response` (bool) | Include raw payload in finding for debugging. |
## 2. Templating with Liquid
## 2. Multi-Step Revocation
Some APIs require a two-step revocation process:
1. **Step 1 (Lookup)**: Query the API to retrieve an internal ID, token identifier, or other metadata
2. **Step 2 (Delete)**: Use the extracted value(s) to perform the actual revocation/deletion
Kingfisher supports up to 2 sequential steps in a revocation workflow. Each step can extract values from its response, making them available as variables in subsequent steps.
### Response Extractors
Values can be extracted from HTTP responses using the following methods:
| Extractor Type | Description | Example |
|----------------|-------------|---------|
| **JsonPath** | Extract from JSON response using JSONPath syntax | `$.data.id`, `$.items[0].token_id` |
| **Regex** | Extract using regex with a capture group | `"token_id":\s*"([^"]+)"` |
| **Header** | Extract an HTTP response header value | `X-Token-ID` |
| **Body** | Use the entire response body as-is | - |
| **StatusCode** | Extract the HTTP status code as a string | - |
### Multi-Step Revocation Schema
```yaml
revocation:
type: HttpMultiStep
content:
steps:
- name: <step_name> # Optional: human-readable step name
request: # Standard HTTP request configuration
method: GET|POST|DELETE|...
url: https://api.example.com/...
headers:
Header-Name: "value"
body: "optional request body"
response_matcher: # Required for final step only
- type: StatusMatch
status: [200]
extract: # Optional: extract variables from response
VARIABLE_NAME: # Variable name (uppercase recommended)
type: JsonPath|Regex|Header|Body|StatusCode
path: "$.path.to.value" # For JsonPath
pattern: "regex pattern" # For Regex (use first capture group)
name: "header-name" # For Header
- name: <next_step> # Subsequent steps can use extracted variables
request:
method: DELETE
url: https://api.example.com/tokens/{{ VARIABLE_NAME }}
response_matcher:
- type: StatusMatch
status: [204]
```
### Multi-Step Revocation Requirements
- **Minimum 1, Maximum 2 steps**: You must define at least 1 step and no more than 2 steps
- **Final step requires response_matcher**: The last step must include a `response_matcher` to determine success/failure
- **Intermediate steps are optional**: Earlier steps don't require response matchers but can have them for validation
- **Variables flow forward**: Variables extracted in step 1 are available in step 2 via Liquid templates (e.g., `{{ TOKEN_ID }}`)
- **All standard Liquid filters apply**: You can use filters on extracted variables just like with `{{ TOKEN }}`
### Example 1: Basic Two-Step Revocation
This example shows a service that requires looking up a token's ID before deletion:
```yaml
rules:
- name: Example Service Token
id: kingfisher.example.1
pattern: |
(?xi)
example_token_
[A-Za-z0-9]{32}
min_entropy: 3.5
examples:
- example_token_abc123def456ghi789jkl012mno345
validation:
type: Http
content:
request:
method: GET
url: https://api.example.com/v1/auth/verify
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Look up the token ID
- name: lookup_token_id
request:
method: GET
url: https://api.example.com/v1/tokens/current
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
TOKEN_ID:
type: JsonPath
path: "$.data.token_id"
# Step 2: Delete the token using the ID
- name: delete_token
request:
method: DELETE
url: https://api.example.com/v1/tokens/{{ TOKEN_ID }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [204]
```
### Example 2: Using Multiple Extraction Methods
This example demonstrates extracting values using different methods:
```yaml
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Get metadata from multiple sources
- name: get_token_metadata
request:
method: GET
url: https://api.service.com/tokens/info
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
extract:
# Extract from JSON body
TOKEN_ID:
type: JsonPath
path: "$.id"
# Extract from response header
ACCOUNT_ID:
type: Header
name: X-Account-ID
# Extract using regex
TOKEN_TYPE:
type: Regex
pattern: '"type":\s*"([^"]+)"'
# Step 2: Use all extracted values
- name: revoke_token
request:
method: POST
url: https://api.service.com/accounts/{{ ACCOUNT_ID }}/tokens/{{ TOKEN_ID }}/revoke
headers:
Authorization: "Bearer {{ TOKEN }}"
Content-Type: application/json
body: '{"token_type":"{{ TOKEN_TYPE }}"}'
response_matcher:
- type: StatusMatch
status: [200, 204]
```
### Example 3: Complex JSONPath Extraction
JSONPath supports nested objects and array indexing:
```yaml
extract:
# Extract from nested object
USER_ID:
type: JsonPath
path: "$.data.user.id"
# Extract from array (first element)
FIRST_TOKEN_ID:
type: JsonPath
path: "$.tokens[0].id"
# Extract from nested array
SESSION_ID:
type: JsonPath
path: "$.data.sessions[0].session_id"
```
### Example 4: Single-Step Migration Path
Existing single-step revocations remain unchanged and continue to work:
```yaml
# This continues to work as before
revocation:
type: Http
content:
request:
method: DELETE
url: https://api.service.com/tokens/revoke
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [204]
```
### When to Use Multi-Step Revocation
Use multi-step revocation when:
- **The API requires looking up an ID first**: Some services don't accept the token directly for revocation
- **You need metadata from the token**: The revocation endpoint requires additional information only available via a separate API call
- **The service uses indirect revocation**: The token must be associated with another resource (session, key, credential) that needs to be identified first
Do NOT use multi-step revocation when:
- **The API accepts the token directly**: Use the simpler single-step `Http` revocation
- **You need more than 2 steps**: Kingfisher supports a maximum of 2 steps
- **The service provides a native revocation method**: Use `AWS` or `GCP` types when applicable
## 3. Templating with Liquid
Kingfisher leverages the Liquid template engine for dynamic parts of HTTP request bodies, headers, query parameters, and multipart payloads. The engine supports both built-in and custom filters to manipulate the captured secret (TOKEN) or other named captures ({{ NAME }}).
### Using Liquid Filters in Validation
### Using Liquid Filters in Validation and Revocation
- **Capture Injection**: The unnamed capture from your regex becomes {{ TOKEN }}. Named captures are made available as uppercase variables (e.g. {{ RDMVAL }}).
- **Filter Pipeline**: You can chain filters using the pipe (|) syntax:
@ -141,7 +404,7 @@ Arguments: Some filters accept parameters, provided after a colon:
{{ TOKEN | hmac_sha256: "my-secret-key" }}
```
### 3. Built-in & Custom Liquid Filters
### Built-in & Custom Liquid Filters
Below is the complete list of Liquid filters available in Kingfisher, along with their usage patterns and examples.
| Filter | Parameters | Description | Example |

View file

@ -0,0 +1,232 @@
# Token Revocation Support
This document provides an overview of the revocation support added to Kingfisher for various service tokens.
## Overview
Revocation support has been added for services that provide programmatic API endpoints to delete or revoke access tokens/keys. Most implementations use the **HttpMultiStep** revocation type because they require a two-step process:
1. **Step 1 (Lookup)**: Query the API to retrieve an internal ID or token identifier
2. **Step 2 (Delete)**: Use the extracted ID to perform the actual revocation
## Services with Revocation Support
### 1. SendGrid (`sendgrid.yml`)
- **Rule ID**: `kingfisher.sendgrid.1`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /v3/api_keys/{api_key_id}`
- **Process**:
1. List all API keys to find the current key's ID
2. Delete the API key using its ID
- **Note**: SendGrid only shows partial keys in the list, so the first key is extracted
### 2. Netlify (`netlify.yml`)
- **Rule IDs**: `kingfisher.netlify.1`, `kingfisher.netlify.2`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /api/v1/access_tokens/{token_id}`
- **Process**:
1. List all access tokens to find the current token's ID
2. Delete the access token using its ID
### 3. Tailscale (`tailscale.yml`)
- **Rule ID**: `kingfisher.tailscale.1`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /api/v2/key/{keyId}`
- **Process**:
1. List all keys to find the current key's ID
2. Delete the key using its ID
### 4. ElevenLabs (`elevenlabs.yml`)
- **Rule ID**: `kingfisher.elevenlabs.1`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /v1/user/api-keys/{api_key_id}`
- **Process**:
1. List all API keys to find the current key's ID
2. Delete the API key using its ID
### 5. Sourcegraph (`sourcegraph.yml`)
- **Rule IDs**: `kingfisher.sourcegraph.1`, `kingfisher.sourcegraph.2`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: GraphQL mutation `deleteAccessToken`
- **Process**:
1. Query GraphQL to get the current token's ID
2. Execute GraphQL mutation to delete the token
- **Note**: Uses GraphQL API instead of REST
### 6. MongoDB Atlas (`mongodb.yml`)
- **Rule ID**: `kingfisher.mongodb.1`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /api/atlas/v2/groups/{GROUP_ID}/apiKeys/{PUBLIC_KEY}`
- **Process**:
1. List all groups to get the first GROUP_ID (Project ID)
2. Delete the API key using the public key as ID
- **Authentication**: Uses HTTP Digest authentication
- **Note**: The Public Key is the ID needed for deletion
### 7. Sumo Logic (`sumologic.yml`)
- **Rule ID**: `kingfisher.sumologic.2`
- **Revocation Type**: Http (single-step)
- **Endpoint**: `DELETE /api/v1/accessKeys/{id}`
- **Process**: Direct deletion using the Access ID
- **Authentication**: Basic Auth (Access ID as username, Access Key as password)
- **Note**: The Access ID is the ID needed for deletion (captured from `kingfisher.sumologic.1`)
### 8. Twilio (`twilio.yml`)
- **Rule ID**: `kingfisher.twilio.2`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /2010-04-01/Accounts/{Account_SID}/Keys/{Key_SID}.json`
- **Process**:
1. List accounts to get the Account SID
2. Delete the API key using both Account SID and Key SID
- **Note**: Assumes TWILIOID is an API Key SID (starts with `SK`)
### 9. NPM (`npm.yml`)
- **Rule IDs**: `kingfisher.npm.1`, `kingfisher.npm.2`
- **Revocation Type**: HttpMultiStep (2-step)
- **Endpoint**: `DELETE /-/npm/v1/tokens/token/{token_key}`
- **Process**:
1. List all tokens to find the current token's key ID
2. Revoke the token using its key
- **Alternative**: Can also use `npm token revoke <id>` CLI command
## Testing Revocation
To test revocation for a detected token:
```bash
# Revoke a token using the rule ID
kingfisher revoke --rule <rule_id> <token>
# With debug logging to see step-by-step execution
RUST_LOG=debug kingfisher revoke --rule <rule_id> <token>
# With additional variables if needed (e.g., for services with depends_on_rule)
kingfisher revoke --rule <rule_id> --var EXTRA_VAR=value <token>
```
### Example: Revoking a SendGrid API Key
```bash
# Revoke a SendGrid API key
kingfisher revoke --rule kingfisher.sendgrid.1 "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"
```
### Example: Revoking a MongoDB API Key
```bash
# Revoke a MongoDB Atlas API key (requires both public and private key)
kingfisher revoke --rule kingfisher.mongodb.1 \
--var PUBKEY=qj4Zrh8e6A \
"4b18315e-6b7d-4337-b449-5d38f5a189ec"
```
## Implementation Details
### Multi-Step Revocation Pattern
All multi-step revocations follow this general pattern:
```yaml
revocation:
type: HttpMultiStep
content:
steps:
# Step 1: Lookup
- name: lookup_id
request:
method: GET
url: https://api.service.com/endpoint
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- type: StatusMatch
status: [200]
- type: JsonValid
extract:
ID_VARIABLE:
type: JsonPath
path: "$.path.to.id"
# Step 2: Delete
- name: delete
request:
method: DELETE
url: https://api.service.com/endpoint/{{ ID_VARIABLE }}
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 204]
```
### Variable Extraction Methods
The following extraction methods are used across different services:
| Method | Description | Example Services |
|--------|-------------|------------------|
| **JsonPath** | Extract from JSON response using JSONPath syntax | SendGrid, Netlify, Tailscale, ElevenLabs, NPM, MongoDB |
| **Regex** | Extract using regex with a capture group | (Not used in current implementations) |
| **Header** | Extract an HTTP response header value | (Not used in current implementations) |
| **Body** | Use the entire response body | (Not used in current implementations) |
### Common JSONPath Patterns
- `$.result[0].api_key_id` - SendGrid: Extract first API key ID from result array
- `$[0].id` - Netlify: Extract ID from root-level array
- `$.keys[0].id` - Tailscale: Extract first key ID from keys object
- `$.api_keys[0].api_key_id` - ElevenLabs: Extract first API key ID
- `$.data.currentUser.accessTokens.nodes[0].id` - Sourcegraph: Extract token ID from nested GraphQL response
- `$.results[0].id` - MongoDB: Extract first group ID from results
- `$.objects[0].token.key` - NPM: Extract token key from objects array
- `$.accounts[0].sid` - Twilio: Extract account SID from accounts array
## Security Considerations
### Token Identification
Some services (like SendGrid and Netlify) list all tokens but don't include the full token value in the response. The current implementations extract the **first** token from the list, which assumes:
1. The user has only one active token, OR
2. The token being revoked is the first one in the list
**Important**: If multiple tokens exist, the wrong token might be revoked. In production, consider:
- Adding user prompts to confirm which token to revoke
- Matching tokens by creation date, name, or other metadata
- Displaying a list of tokens for user selection
### Digest Authentication
MongoDB Atlas uses HTTP Digest authentication, which is properly handled by the Kingfisher HTTP client via the `digest` field in the request configuration.
### GraphQL APIs
Sourcegraph uses GraphQL mutations for revocation. The implementation:
1. Uses a GraphQL query to get the token ID
2. Uses a GraphQL mutation with variables to delete the token
## Limitations
1. **Maximum 2 steps**: The HttpMultiStep implementation supports only 1-2 steps
2. **Sequential execution**: Steps execute in order; no parallel execution
3. **Token identification**: Services that don't return full token values may revoke the wrong token if multiple exist
4. **Requires API access**: All revocations require the token to have sufficient permissions to list and delete itself
## Future Enhancements
Potential improvements for revocation support:
1. **Interactive mode**: Prompt user to select which token to revoke when multiple exist
2. **Dry-run mode**: Show what would be revoked without actually revoking
3. **Batch revocation**: Revoke multiple tokens at once
4. **Revocation history**: Track what was revoked and when
5. **Rollback support**: For services that support token restoration
6. **Service-specific CLI support**: For services like NPM that have CLI commands
## References
- [Multi-Step Revocation Implementation](MULTI_STEP_REVOCATION.md)
- [Writing Custom Rules](RULES.md)
- [Kingfisher Rules Schema](../crates/kingfisher-rules/src/rule.rs)

View file

@ -21,13 +21,16 @@ use crate::{
cli::{commands::revoke::RevokeArgs, global::GlobalArgs},
liquid_filters::register_all,
rule_loader::RuleLoader,
rules::{rule::Rule, HttpValidation, Revocation},
validation::aws::{revoke_aws_access_key, validate_aws_credentials_input},
validation::gcp::revoke_gcp_service_account_key,
validation::httpvalidation::{build_request_builder, retry_request, validate_response},
validation::GLOBAL_USER_AGENT,
};
use kingfisher_rules::{
HttpMultiStepRevocation, HttpValidation, ResponseExtractor, Revocation, RevocationStep, Rule,
};
/// Result of a direct revocation attempt.
#[derive(Debug, Clone, Serialize)]
pub struct DirectRevocationResult {
@ -113,6 +116,21 @@ fn extract_revocation_vars(revocation: &Revocation) -> BTreeSet<String> {
vars.extend(extract_template_vars(body));
}
}
Revocation::HttpMultiStep(multi_step) => {
// Extract variables from all steps
// Note: Variables extracted in step 1 are available in step 2,
// but we only track initial input variables here
for step in &multi_step.steps {
vars.extend(extract_template_vars(&step.request.url));
for (key, value) in &step.request.headers {
vars.extend(extract_template_vars(key));
vars.extend(extract_template_vars(value));
}
if let Some(body) = &step.request.body {
vars.extend(extract_template_vars(body));
}
}
}
}
vars
@ -191,6 +209,70 @@ async fn render_and_parse_url(
reqwest::Url::parse(&rendered).map_err(|e| anyhow!("Invalid URL '{}': {}", rendered, e))
}
/// Extract a value from an HTTP response using the specified extractor.
fn extract_value_from_response(
extractor: &ResponseExtractor,
body: &str,
headers: &reqwest::header::HeaderMap,
status: &reqwest::StatusCode,
) -> Result<String> {
match extractor {
ResponseExtractor::JsonPath { path } => {
let json: serde_json::Value =
serde_json::from_str(body).context("Response body is not valid JSON")?;
// Simple JSONPath implementation supporting basic paths like:
// $.field, $.field.nested, $.array[0], $.array[0].field
let path_parts: Vec<&str> = path.trim_start_matches("$.").split('.').collect();
let mut current = &json;
for part in path_parts {
if let Some((array_name, index_str)) = part.split_once('[') {
let index: usize =
index_str.trim_end_matches(']').parse().context("Invalid array index")?;
if !array_name.is_empty() {
current = current
.get(array_name)
.ok_or_else(|| anyhow!("Field '{}' not found", array_name))?;
}
current = current
.get(index)
.ok_or_else(|| anyhow!("Array index {} not found", index))?;
} else {
current =
current.get(part).ok_or_else(|| anyhow!("Field '{}' not found", part))?;
}
}
match current {
serde_json::Value::String(s) => Ok(s.clone()),
serde_json::Value::Number(n) => Ok(n.to_string()),
serde_json::Value::Bool(b) => Ok(b.to_string()),
_ => Ok(current.to_string()),
}
}
ResponseExtractor::Regex { pattern } => {
let re = Regex::new(pattern).context(format!("Invalid regex pattern: {}", pattern))?;
let caps = re
.captures(body)
.ok_or_else(|| anyhow!("Regex pattern did not match response body"))?;
caps.get(1)
.map(|m| m.as_str().to_string())
.ok_or_else(|| anyhow!("No capture group found in regex pattern"))
}
ResponseExtractor::Header { name } => headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("Header '{}' not found in response", name)),
ResponseExtractor::Body => Ok(body.to_string()),
ResponseExtractor::StatusCode => Ok(status.as_u16().to_string()),
}
}
/// Execute HTTP revocation against the provided rule.
async fn execute_http_revocation(
http_revocation: &HttpValidation,
@ -247,6 +329,137 @@ async fn execute_http_revocation(
})
}
/// Execute a single revocation step and extract variables from the response.
async fn execute_revocation_step(
step: &RevocationStep,
globals: &mut Object,
client: &Client,
parser: &liquid::Parser,
timeout: Duration,
retries: u32,
step_number: usize,
) -> Result<(reqwest::StatusCode, String)> {
let default_step_name = format!("step_{}", step_number);
let step_name = step.name.as_ref().map(|s| s.as_str()).unwrap_or(&default_step_name);
debug!("Executing revocation step {}: {}", step_number, step_name);
let url = render_and_parse_url(parser, globals, &step.request.url).await?;
debug!("Step {} URL: {}", step_number, url);
let request_builder = build_request_builder(
client,
&step.request.method,
&url,
&step.request.headers,
&step.request.body,
timeout,
parser,
globals,
)
.map_err(|e| anyhow!("Failed to build request for {}: {}", step_name, e))?;
let backoff_min = Duration::from_millis(100);
let backoff_max = Duration::from_secs(2);
let response = retry_request(request_builder, retries, backoff_min, backoff_max)
.await
.map_err(|e| anyhow!("Request failed for {}: {}", step_name, e))?;
let status = response.status();
let headers = response.headers().clone();
let body =
response.text().await.unwrap_or_else(|e| format!("Failed to read response body: {}", e));
// Extract variables from the response if configured
if let Some(extractors) = &step.extract {
debug!("Extracting {} variable(s) from step {} response", extractors.len(), step_number);
for (var_name, extractor) in extractors {
match extract_value_from_response(extractor, &body, &headers, &status) {
Ok(value) => {
debug!("Step {}: Extracted variable {} = '{}'", step_number, var_name, value);
globals.insert(var_name.to_uppercase().into(), Value::scalar(value));
}
Err(e) => {
return Err(anyhow!(
"Failed to extract variable '{}' in step {}: {}",
var_name,
step_number,
e
));
}
}
}
}
Ok((status, body))
}
/// Execute multi-step HTTP revocation.
async fn execute_multi_step_revocation(
multi_step: &HttpMultiStepRevocation,
globals: &mut Object,
client: &Client,
parser: &liquid::Parser,
timeout: Duration,
retries: u32,
) -> Result<DirectRevocationResult> {
if multi_step.steps.is_empty() {
bail!("Multi-step revocation must have at least one step");
}
if multi_step.steps.len() > 2 {
bail!(
"Multi-step revocation supports a maximum of 2 steps, got {}",
multi_step.steps.len()
);
}
let num_steps = multi_step.steps.len();
debug!("Executing {}-step revocation", num_steps);
// Execute each step sequentially
for (i, step) in multi_step.steps.iter().enumerate() {
let step_number = i + 1;
let is_final_step = step_number == num_steps;
let (status, body) =
execute_revocation_step(step, globals, client, parser, timeout, retries, step_number)
.await?;
if is_final_step {
// Final step: validate response to determine success
let display_body =
if body.len() > 500 { format!("{}...", &body[..500]) } else { body.clone() };
let matchers = step
.request
.response_matcher
.as_deref()
.ok_or_else(|| anyhow!("Final revocation step must have response_matcher"))?;
let headers = reqwest::header::HeaderMap::new();
let html_allowed = step.request.response_is_html;
let revoked = validate_response(matchers, &body, &status, &headers, html_allowed);
return Ok(DirectRevocationResult {
rule_id: String::new(),
rule_name: String::new(),
revoked,
status_code: Some(status.as_u16()),
message: display_body,
});
} else {
// Intermediate step: just log the response
debug!("Step {} completed with status {}", step_number, status);
}
}
// This should never happen due to the checks above, but keep for safety
Err(anyhow!("Multi-step revocation did not complete"))
}
/// Run direct revocation of a secret against one or more rules.
pub async fn run_direct_revocation(
args: &RevokeArgs,
@ -405,6 +618,18 @@ pub async fn run_direct_revocation(
)
.await?
}
Revocation::HttpMultiStep(multi_step) => {
let mut globals_mut = globals.clone();
execute_multi_step_revocation(
multi_step,
&mut globals_mut,
&client,
&parser,
timeout,
args.retries,
)
.await?
}
};
result.rule_id = rule_id;

View file

@ -121,6 +121,15 @@ fn build_revoke_command(
escape_for_shell(snippet)
))
}
Revocation::HttpMultiStep(_) => {
// Multi-step HTTP revocation with dependent variables
Some(format!(
"kingfisher revoke --rule {} {}{}",
rule_id,
var_args,
escape_for_shell(snippet)
))
}
}
}