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
2 changes: 2 additions & 0 deletions secator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ class Wordlists(StrictModel):
'combined_subdomains': 'https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/combined_subdomains.txt', # noqa: E501
'directory_list_small': 'https://gist.githubusercontent.com/sl4v/c087e36164e74233514b/raw/c51a811c70bbdd87f4725521420cc30e7232b36d/directory-list-2.3-small.txt', # noqa: E501
'burp-parameter-names': 'https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Discovery/Web-Content/burp-parameter-names.txt', # noqa: E501
# Assetnote's HTTP Archive API routes dataset (same data kiterunner's -A apiroutes uses), regenerated monthly
'apiroutes': 'https://wordlists-cdn.assetnote.io/data/automated/httparchive_apiroutes_2026_06_27.txt', # noqa: E501
}
lists: Dict[str, List[str]] = {}

Expand Down
24 changes: 24 additions & 0 deletions secator/configs/scans/api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
type: scan
name: api
description: API security scan
long_description: |
End-to-end security assessment of a web API.
Discovers API endpoints (api_discover), then fuzzes their parameters (url_params_fuzz) and scans the
discovered surface for common web vulnerabilities and exposed secrets (url_vuln). Composes existing
workflows rather than duplicating their logic, so each stage stays independently reusable.
profile: default
tags: [http, api, fuzz, vuln, secrets]
input_types:
- url
workflows:
api_discover:
url_params_fuzz:
targets_:
- type: url
field: url
condition: url.verified
url_vuln:
targets_:
- type: url
field: url
condition: url.verified
86 changes: 86 additions & 0 deletions secator/configs/workflows/api_discover.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
type: workflow
name: api_discover
alias: apid
description: API endpoint discovery
long_description: |
Discovers API endpoints on a target web application.
Combines active crawling (katana, which parses JavaScript to surface referenced endpoints) with optional
brute-forcing of API routes (ffuf, using Assetnote's HTTP Archive apiroutes wordlist — the same real-world
route dataset kiterunner relied on). Discovered endpoints are probed with httpx to verify they are live and
fingerprint their technologies. The apiroutes wordlist also covers exposed API specification paths
(openapi/swagger); when one is found, the --spec option hands it off to nuclei, which parses the spec
(input-mode openapi) and DAST-fuzzes every documented endpoint with the correct method and parameters —
recovering the contextual testing kiterunner used to provide, using a maintained tool already in secator.
tags: [http, api, crawl, fuzz]
input_types:
- url

default_options:
follow_redirect: true

options:
waf:
is_flag: True
help: Fingerprint WAF (wafw00f)
default: False

fuzz:
is_flag: True
help: Brute-force API routes with the apiroutes wordlist (ffuf)
default: False
short: fuzz

spec:
is_flag: True
help: Hand off discovered OpenAPI/Swagger specs to nuclei for endpoint fuzzing (combine with --fuzz to find them)
default: False
short: spec

tasks:
katana:
description: Crawl for API endpoints

urlparser:
description: Extract origin URL
include: [url_root]
targets_:
- type: target
field: name
if: opts.fuzz

ffuf:
description: Brute-force API routes
wordlist: apiroutes
auto_calibration: true
if: opts.fuzz
targets_:
# Fuzz from the origin (url_root strips path + trailing slash) with /FUZZ so ffuf's auto-calibration
# probes hit the right host with a clean path. apiroutes entries are absolute (/api/x), so real routes
# come out as host//api — ffuf collapses the redundant slash in its output URLs.
- type: tag
field: '{value}/FUZZ'
condition: item.name == 'url_root'

wafw00f:
description: Fingerprint WAF
if: opts.waf

httpx:
description: Probe discovered API endpoints
tech_detect: True
filter_duplicates: True
targets_:
- target.name
- type: url
field: url
condition: not url.verified

nuclei:
description: Fuzz endpoints from discovered API specs
input_mode: openapi
dast: True
targets_:
- type: url
field: url
condition: "'openapi' in url.url or 'swagger' in url.url or 'api-docs' in url.url"
if: opts.spec
7 changes: 6 additions & 1 deletion secator/tasks/ffuf.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from secator.decorators import task

# fmt: off
Expand Down Expand Up @@ -125,8 +127,11 @@ def on_json_loaded(self, item):
has_status_code_3xx = str(status_code).startswith('3')
is_redirect = (self.get_opt_value('follow_redirect') and 'redirectlocation' in item) or has_status_code_3xx
auto_calibration = self.get_opt_value('auto_calibration')
# Collapse redundant slashes in the path (e.g. host//api from a path-list wordlist whose entries
# start with '/'), while preserving the scheme's '://'.
url = re.sub(r'(?<!:)/{2,}', '/', item['url'])
yield Url(
url=item['url'],
url=url,
host=item['host'],
verified=auto_calibration,
status_code=status_code,
Expand Down
33 changes: 32 additions & 1 deletion secator/tasks/nuclei.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import json
import shlex

from pathlib import Path

from secator.config import CONFIG
from secator.cve import extract_software_and_version
from secator.decorators import task
from secator.utils import download_file

# fmt: off
from secator.definitions import (
Expand All @@ -11,7 +15,7 @@
REMEDIATION, RETRIES, SEVERITY, TAGS, THREADS, TIMEOUT, URL, USER_AGENT
)
# fmt: on
from secator.output_types import Progress, Tag, Technology, Vulnerability
from secator.output_types import Info, Progress, Tag, Technology, Vulnerability
from secator.serializers import JSONSerializer
from secator.tasks._categories import VulnMulti

Expand Down Expand Up @@ -41,6 +45,7 @@ class nuclei(VulnMulti):
opts = {
'automatic_scan': {'is_flag': True, 'short': 'as', 'help': 'Automatic web scan using wappalyzer technology detection to tags mapping'}, # noqa: E501
'bulk_size': {'type': int, 'short': 'bs', 'help': 'Maximum number of hosts to be analyzed in parallel per template'}, # noqa: E501
'dast': {'is_flag': True, 'default': False, 'help': 'Enable DAST fuzzing templates (required to fuzz OpenAPI/Swagger endpoints)'}, # noqa: E501
'debug': {'type': str, 'help': 'Debug mode'},
'display_templates': {'is_flag': True, 'default': False, 'short': 'dt', 'help': 'Display loaded template names.'},
'exclude_severity': {'type': str, 'short': 'es', 'help': 'Exclude severity'},
Expand Down Expand Up @@ -153,6 +158,32 @@ def on_init(self):
self.cmd += f' -elog {output_folder}/{self.fqn}_error.json'
self.cmd += f' -tlog {output_folder}/{self.fqn}_trace.json'

@staticmethod
def on_cmd(self):
# In openapi/swagger input-mode, nuclei reads the spec from an input file (-l), not from a target (-u).
# When the spec is a remote URL, download it first and pass it via -l so the input provider is not empty.
input_mode = self.get_opt_value('input_mode')
if input_mode in ('openapi', 'swagger') and self.inputs:
spec = self.inputs[0]
if spec.startswith(('http://', 'https://')):
dest = Path(f'{self.reports_folder}/.inputs')
dest.mkdir(parents=True, exist_ok=True)
spec_file = download_file(spec, target_folder=dest, offline_mode=CONFIG.offline_mode, type='openapi spec')
if spec_file:
# nuclei's openapi import only supports OpenAPI 3.0. Skip 3.1+ specs gracefully (schemathesis
# covers them) instead of crashing on the incompatible schema.
version = ''
try:
with open(spec_file) as f:
version = str(json.load(f).get('openapi', ''))
except Exception:
pass
if version.startswith('3.1'):
self.add_result(Info(message=f'Skipping nuclei openapi scan: OpenAPI {version} is not supported by nuclei (3.0 only)')) # noqa: E501
self.cmd = 'true'
return
self.cmd = self.cmd.replace(f'-u {spec}', f'-l {shlex.quote(str(spec_file))}')

@staticmethod
def id_extractor(item):
cve_ids = item['info'].get('classification', {}).get('cve-id') or []
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
}

INPUTS_WORKFLOWS = {
'api_discover': 'localhost:3000',
'cidr_recon': '127.0.0.1/30',
'code_scan': str(ROOT_FOLDER),
# 'dir_finder': 'localhost:3000', # TODO: add fixture with directories
Expand All @@ -57,6 +58,7 @@
}

INPUTS_SCANS = {
'api': ['http://localhost:3000'],
'domain': 'testphp.vulnweb.com',
'host': ['localhost'],
'network': '127.0.0.1/24',
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@
}

OUTPUTS_WORKFLOWS = {
'api_discover': [
Url(url='http://localhost:3000', method='GET', _type='url'),
],
'cidr_recon': [Ip(ip='127.0.0.1', host='', alive=True, _source='fping', _type='ip', _uuid='ea92f674-4cfe-4556-91f5-8669644513a0')],
'code_scan': [
Vulnerability(
Expand Down Expand Up @@ -584,4 +587,5 @@
],
'network': [],
'url': [],
'api': [],
}
Loading