kagesec 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/main.py +1666 -0
- kagesec-0.2.0.dist-info/METADATA +712 -0
- kagesec-0.2.0.dist-info/RECORD +207 -0
- kagesec-0.2.0.dist-info/WHEEL +5 -0
- kagesec-0.2.0.dist-info/entry_points.txt +2 -0
- kagesec-0.2.0.dist-info/licenses/LICENSE +21 -0
- kagesec-0.2.0.dist-info/top_level.txt +2 -0
- scanner/__init__.py +1 -0
- scanner/ai/__init__.py +0 -0
- scanner/ai/cve_researcher.py +204 -0
- scanner/ai/reporter.py +52 -0
- scanner/ai/template_selector.py +225 -0
- scanner/ai/verifier.py +110 -0
- scanner/api/__init__.py +0 -0
- scanner/api/server.py +223 -0
- scanner/compliance/__init__.py +0 -0
- scanner/compliance/appi.py +176 -0
- scanner/compliance/gdpr.py +170 -0
- scanner/compliance/hipaa.py +142 -0
- scanner/compliance/iso27001.py +181 -0
- scanner/compliance/mapper.py +19 -0
- scanner/core/__init__.py +0 -0
- scanner/core/api_scanner.py +238 -0
- scanner/core/browser_crawler.py +344 -0
- scanner/core/code_runner.py +178 -0
- scanner/core/config.py +64 -0
- scanner/core/crawl_state.py +83 -0
- scanner/core/crawler.py +224 -0
- scanner/core/engine.py +645 -0
- scanner/core/findings_db.py +200 -0
- scanner/core/fingerprinter.py +173 -0
- scanner/core/flow_evaluator.py +515 -0
- scanner/core/grpc_scanner.py +361 -0
- scanner/core/har_importer.py +159 -0
- scanner/core/headless_runner.py +269 -0
- scanner/core/interactsh.py +200 -0
- scanner/core/js_extractor.py +150 -0
- scanner/core/notifier.py +185 -0
- scanner/core/payload_loader.py +86 -0
- scanner/core/policy.py +108 -0
- scanner/core/profiles.py +157 -0
- scanner/core/rate_limiter.py +63 -0
- scanner/core/scan_policy.py +71 -0
- scanner/core/scan_result.py +153 -0
- scanner/core/soap_scanner.py +289 -0
- scanner/core/suppressions.py +125 -0
- scanner/core/template_runner.py +656 -0
- scanner/core/updater.py +165 -0
- scanner/core/workflow.py +268 -0
- scanner/mcp_server.py +140 -0
- scanner/modules/__init__.py +0 -0
- scanner/modules/ai_cve.py +81 -0
- scanner/modules/api_key_leak.py +307 -0
- scanner/modules/auth_bypass.py +127 -0
- scanner/modules/blind_xss.py +132 -0
- scanner/modules/breach.py +110 -0
- scanner/modules/business_logic.py +173 -0
- scanner/modules/cache_poisoning.py +125 -0
- scanner/modules/captcha_check.py +93 -0
- scanner/modules/clickjacking.py +126 -0
- scanner/modules/cmd_injection.py +131 -0
- scanner/modules/cookie_security.py +74 -0
- scanner/modules/cors.py +101 -0
- scanner/modules/coverage_check.py +157 -0
- scanner/modules/crlf.py +70 -0
- scanner/modules/crossdomain.py +116 -0
- scanner/modules/csrf.py +67 -0
- scanner/modules/csti.py +165 -0
- scanner/modules/cve_check.py +132 -0
- scanner/modules/debug_mode.py +206 -0
- scanner/modules/deserialization.py +261 -0
- scanner/modules/dnssec.py +150 -0
- scanner/modules/exposed_files.py +180 -0
- scanner/modules/file_upload.py +154 -0
- scanner/modules/form_fuzz.py +164 -0
- scanner/modules/graphql.py +268 -0
- scanner/modules/host_header.py +174 -0
- scanner/modules/http_methods.py +142 -0
- scanner/modules/http_param_pollution.py +72 -0
- scanner/modules/idor.py +93 -0
- scanner/modules/jwt_attacks.py +308 -0
- scanner/modules/log4j_deep.py +123 -0
- scanner/modules/multistep_injection.py +194 -0
- scanner/modules/oauth.py +258 -0
- scanner/modules/open_redirect.py +59 -0
- scanner/modules/padding_oracle.py +157 -0
- scanner/modules/param_discovery.py +170 -0
- scanner/modules/path_discovery.py +198 -0
- scanner/modules/path_traversal.py +87 -0
- scanner/modules/prototype_pollution.py +133 -0
- scanner/modules/race_condition.py +168 -0
- scanner/modules/rate_limit.py +83 -0
- scanner/modules/request_smuggling.py +118 -0
- scanner/modules/robots_probe.py +140 -0
- scanner/modules/security_headers.py +110 -0
- scanner/modules/session_entropy.py +160 -0
- scanner/modules/session_fixation.py +227 -0
- scanner/modules/shellshock.py +114 -0
- scanner/modules/sqli.py +637 -0
- scanner/modules/ssi.py +86 -0
- scanner/modules/ssrf.py +204 -0
- scanner/modules/ssti.py +103 -0
- scanner/modules/subdomain_takeover.py +152 -0
- scanner/modules/subresource_integrity.py +49 -0
- scanner/modules/templates.py +121 -0
- scanner/modules/tls.py +148 -0
- scanner/modules/username_enumeration.py +209 -0
- scanner/modules/version_disclosure.py +98 -0
- scanner/modules/vhost_enum.py +190 -0
- scanner/modules/waf_bypass.py +185 -0
- scanner/modules/waf_detect.py +102 -0
- scanner/modules/websocket.py +249 -0
- scanner/modules/xpath.py +98 -0
- scanner/modules/xss.py +486 -0
- scanner/modules/xxe.py +151 -0
- scanner/payloads/blind_xss.yaml +45 -0
- scanner/payloads/cmd_injection.yaml +33 -0
- scanner/payloads/cve_signatures.yaml +221 -0
- scanner/payloads/form_fuzz.yaml +14 -0
- scanner/payloads/jwt_secrets.yaml +104 -0
- scanner/payloads/params.yaml +113 -0
- scanner/payloads/path_traversal.yaml +57 -0
- scanner/payloads/paths.yaml +154 -0
- scanner/payloads/sqli.yaml +134 -0
- scanner/payloads/ssrf.yaml +196 -0
- scanner/payloads/ssti.yaml +41 -0
- scanner/payloads/subdomains.yaml +128 -0
- scanner/payloads/waf_bypass.yaml +26 -0
- scanner/payloads/xss.yaml +203 -0
- scanner/payloads/xxe.yaml +26 -0
- scanner/reporters/__init__.py +0 -0
- scanner/reporters/burp_reporter.py +94 -0
- scanner/reporters/certificate_reporter.py +192 -0
- scanner/reporters/github_reporter.py +134 -0
- scanner/reporters/jira_reporter.py +128 -0
- scanner/reporters/pdf_reporter.py +816 -0
- scanner/reporters/sarif_reporter.py +147 -0
- scanner/reporters/zap_reporter.py +134 -0
- scanner/templates/cves/CVE-2019-11510.yaml +30 -0
- scanner/templates/cves/CVE-2019-19781.yaml +32 -0
- scanner/templates/cves/CVE-2019-8451.yaml +28 -0
- scanner/templates/cves/CVE-2020-11978.yaml +31 -0
- scanner/templates/cves/CVE-2020-14882.yaml +30 -0
- scanner/templates/cves/CVE-2020-17519.yaml +28 -0
- scanner/templates/cves/CVE-2020-1938.yaml +35 -0
- scanner/templates/cves/CVE-2021-21972.yaml +32 -0
- scanner/templates/cves/CVE-2021-21985.yaml +31 -0
- scanner/templates/cves/CVE-2021-22205.yaml +28 -0
- scanner/templates/cves/CVE-2021-22986.yaml +33 -0
- scanner/templates/cves/CVE-2021-25646.yaml +31 -0
- scanner/templates/cves/CVE-2021-26084.yaml +32 -0
- scanner/templates/cves/CVE-2021-26855.yaml +30 -0
- scanner/templates/cves/CVE-2021-3129.yaml +30 -0
- scanner/templates/cves/CVE-2021-34473.yaml +31 -0
- scanner/templates/cves/CVE-2021-40438.yaml +30 -0
- scanner/templates/cves/CVE-2021-41773.yaml +29 -0
- scanner/templates/cves/CVE-2021-43798.yaml +29 -0
- scanner/templates/cves/CVE-2021-44228.yaml +36 -0
- scanner/templates/cves/CVE-2021-44515.yaml +32 -0
- scanner/templates/cves/CVE-2021-45046.yaml +35 -0
- scanner/templates/cves/CVE-2022-0543.yaml +32 -0
- scanner/templates/cves/CVE-2022-1388.yaml +37 -0
- scanner/templates/cves/CVE-2022-2185.yaml +30 -0
- scanner/templates/cves/CVE-2022-22954.yaml +29 -0
- scanner/templates/cves/CVE-2022-22965.yaml +31 -0
- scanner/templates/cves/CVE-2022-26134.yaml +28 -0
- scanner/templates/cves/CVE-2022-26138.yaml +37 -0
- scanner/templates/cves/CVE-2022-26318.yaml +30 -0
- scanner/templates/cves/CVE-2022-30190.yaml +34 -0
- scanner/templates/cves/CVE-2022-41082.yaml +33 -0
- scanner/templates/cves/CVE-2022-42889.yaml +27 -0
- scanner/templates/cves/CVE-2022-46169.yaml +32 -0
- scanner/templates/cves/CVE-2023-20887.yaml +31 -0
- scanner/templates/cves/CVE-2023-22515.yaml +35 -0
- scanner/templates/cves/CVE-2023-23397.yaml +36 -0
- scanner/templates/cves/CVE-2023-27898.yaml +31 -0
- scanner/templates/cves/CVE-2023-28432.yaml +35 -0
- scanner/templates/cves/CVE-2023-29357.yaml +31 -0
- scanner/templates/cves/CVE-2023-33246.yaml +32 -0
- scanner/templates/cves/CVE-2023-34362.yaml +33 -0
- scanner/templates/cves/CVE-2023-42793.yaml +33 -0
- scanner/templates/cves/CVE-2023-46604.yaml +32 -0
- scanner/templates/cves/CVE-2023-4966.yaml +33 -0
- scanner/templates/cves/CVE-2023-50164.yaml +34 -0
- scanner/templates/cves/CVE-2024-1709.yaml +31 -0
- scanner/templates/cves/CVE-2024-21887.yaml +33 -0
- scanner/templates/cves/CVE-2024-23897.yaml +34 -0
- scanner/templates/cves/CVE-2024-27198.yaml +31 -0
- scanner/templates/exposed-panels/grafana.yaml +28 -0
- scanner/templates/exposed-panels/jenkins.yaml +36 -0
- scanner/templates/exposed-panels/kibana.yaml +28 -0
- scanner/templates/exposed-panels/laravel-telescope.yaml +29 -0
- scanner/templates/exposed-panels/phpmyadmin.yaml +31 -0
- scanner/templates/exposed-panels/prometheus.yaml +29 -0
- scanner/templates/exposed-panels/spring-boot-actuator.yaml +34 -0
- scanner/templates/misconfigs/apache-server-status.yaml +34 -0
- scanner/templates/misconfigs/backup-files.yaml +37 -0
- scanner/templates/misconfigs/env-exposed.yaml +29 -0
- scanner/templates/misconfigs/git-exposed.yaml +29 -0
- scanner/templates/misconfigs/graphql-introspection.yaml +32 -0
- scanner/templates/misconfigs/phpinfo.yaml +31 -0
- scanner/templates/misconfigs/swagger-exposed.yaml +34 -0
- scanner/utils/__init__.py +0 -0
- scanner/utils/http.py +55 -0
- scanner/utils/payloads.py +32 -0
- scanner/workflows/quick-web.yaml +38 -0
- scanner/workflows/wordpress.yaml +45 -0
cli/main.py
ADDED
|
@@ -0,0 +1,1666 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import argparse
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import uuid as _uuid
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from scanner.core.engine import run_scan
|
|
10
|
+
from scanner.core.config import ScanConfig, LoginFlow
|
|
11
|
+
from scanner.core import policy as _policy
|
|
12
|
+
|
|
13
|
+
# Force line-buffered stdout so output appears immediately when redirected
|
|
14
|
+
# (e.g. piped to a file, run in CI, or captured by a background task runner)
|
|
15
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
16
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="kagesec",
|
|
22
|
+
description="KageSec — AI-powered web application security scanner",
|
|
23
|
+
)
|
|
24
|
+
sub = parser.add_subparsers(dest="command")
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------ scan
|
|
27
|
+
scan_cmd = sub.add_parser("scan", help="Run a security scan against a target URL")
|
|
28
|
+
scan_cmd.add_argument("target", nargs="?", help="Target URL (e.g. https://example.com)")
|
|
29
|
+
scan_cmd.add_argument(
|
|
30
|
+
"--targets", metavar="FILE",
|
|
31
|
+
help="File with one target URL per line — runs scan against each and writes per-target reports",
|
|
32
|
+
)
|
|
33
|
+
scan_cmd.add_argument("--depth", type=int, default=3, help="Crawl depth (default: 3)")
|
|
34
|
+
scan_cmd.add_argument("--max-pages", type=int, default=100, help="Max pages to crawl")
|
|
35
|
+
scan_cmd.add_argument(
|
|
36
|
+
"--output",
|
|
37
|
+
choices=["json", "markdown", "pdf", "sarif", "burp", "zap", "all"],
|
|
38
|
+
default="json",
|
|
39
|
+
help=(
|
|
40
|
+
"Report format(s) to generate. "
|
|
41
|
+
"'json' — machine-readable (default); "
|
|
42
|
+
"'markdown' — human-readable text report; "
|
|
43
|
+
"'pdf' — professional PDF report (requires: pip install \"kagesec[pdf]\" && playwright install chromium); "
|
|
44
|
+
"'sarif' — SARIF 2.1.0 for GitHub Code Scanning / VS Code; "
|
|
45
|
+
"'burp' — Burp Suite XML issue import format; "
|
|
46
|
+
"'zap' — OWASP ZAP JSON alert format; "
|
|
47
|
+
"'all' — generate all formats."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
scan_cmd.add_argument("--no-ai", action="store_true", help="Skip AI verification and CVE research")
|
|
51
|
+
scan_cmd.add_argument("--api-key", metavar="KEY", help="Anthropic API key (overrides ANTHROPIC_API_KEY)")
|
|
52
|
+
scan_cmd.add_argument(
|
|
53
|
+
"--compliance", nargs="+",
|
|
54
|
+
choices=["iso27001", "hipaa", "gdpr", "appi"],
|
|
55
|
+
help="Generate compliance reports (e.g. --compliance gdpr hipaa)",
|
|
56
|
+
)
|
|
57
|
+
scan_cmd.add_argument("--modules", nargs="+", help="Run only specific modules (e.g. --modules xss sqli)")
|
|
58
|
+
scan_cmd.add_argument("--auth-bearer", metavar="TOKEN", help="Bearer token for authenticated scanning")
|
|
59
|
+
scan_cmd.add_argument("--auth-cookie", metavar="NAME=VALUE", help="Session cookie (e.g. session=abc123)")
|
|
60
|
+
scan_cmd.add_argument(
|
|
61
|
+
"--auth-oauth2-token-url", metavar="URL",
|
|
62
|
+
help="OAuth2 token endpoint for client credentials flow",
|
|
63
|
+
)
|
|
64
|
+
scan_cmd.add_argument("--auth-oauth2-client-id", metavar="ID", help="OAuth2 client ID")
|
|
65
|
+
scan_cmd.add_argument("--auth-oauth2-client-secret", metavar="SECRET", help="OAuth2 client secret")
|
|
66
|
+
scan_cmd.add_argument(
|
|
67
|
+
"--proxy", metavar="URL",
|
|
68
|
+
help="HTTP/HTTPS proxy URL (e.g. http://127.0.0.1:8080) — routes all scan traffic through proxy",
|
|
69
|
+
)
|
|
70
|
+
scan_cmd.add_argument(
|
|
71
|
+
"--passive", action="store_true",
|
|
72
|
+
help="Passive mode — inspect headers/cookies/content only, no injection payloads (safe for production)",
|
|
73
|
+
)
|
|
74
|
+
scan_cmd.add_argument(
|
|
75
|
+
"--follow-robots", action="store_true",
|
|
76
|
+
help="Respect robots.txt Disallow rules during crawl (default: ignore)",
|
|
77
|
+
)
|
|
78
|
+
scan_cmd.add_argument(
|
|
79
|
+
"--no-oob", action="store_true",
|
|
80
|
+
help="Disable out-of-band callback server (use for air-gapped or rate-limited targets)",
|
|
81
|
+
)
|
|
82
|
+
scan_cmd.add_argument(
|
|
83
|
+
"--oob-server", metavar="DOMAIN", default=None,
|
|
84
|
+
help="Custom OOB callback domain (default: oast.pro)",
|
|
85
|
+
)
|
|
86
|
+
scan_cmd.add_argument(
|
|
87
|
+
"--fail-on", choices=["critical", "high", "medium", "low"],
|
|
88
|
+
help="Exit with code 1 if findings at this severity or above are found (CI/CD mode)",
|
|
89
|
+
)
|
|
90
|
+
scan_cmd.add_argument("--browser", action="store_true", default=True, help="Use Playwright headless browser (SPAs, JS content) [default: on]")
|
|
91
|
+
scan_cmd.add_argument("--no-browser", action="store_false", dest="browser", help="Disable Playwright browser (faster, but misses JS-rendered content)")
|
|
92
|
+
scan_cmd.add_argument("--login-url", metavar="URL", help="Login page URL for authenticated scanning")
|
|
93
|
+
scan_cmd.add_argument("--login-user-selector", metavar="CSS", help="CSS selector for username field")
|
|
94
|
+
scan_cmd.add_argument("--login-pass-selector", metavar="CSS", help="CSS selector for password field")
|
|
95
|
+
scan_cmd.add_argument("--login-submit-selector", metavar="CSS", help="CSS selector for submit button")
|
|
96
|
+
scan_cmd.add_argument("--login-username", metavar="VALUE", help="Username / email to login with")
|
|
97
|
+
scan_cmd.add_argument("--login-password", metavar="VALUE", help="Password to login with")
|
|
98
|
+
scan_cmd.add_argument("--login-success", metavar="INDICATOR", help="URL substring or CSS selector post-login")
|
|
99
|
+
scan_cmd.add_argument("--login-totp-secret", metavar="BASE32", help="base32 TOTP secret for 2FA")
|
|
100
|
+
scan_cmd.add_argument("--openapi", metavar="URL_OR_FILE", help="OpenAPI 3.x/Swagger 2.x spec for API scanning")
|
|
101
|
+
scan_cmd.add_argument("--graphql", metavar="URL", help="GraphQL endpoint URL")
|
|
102
|
+
scan_cmd.add_argument(
|
|
103
|
+
"--grpc", metavar="HOST:PORT",
|
|
104
|
+
help=(
|
|
105
|
+
"gRPC endpoint to scan via Server Reflection (e.g. api.example.com:50051). "
|
|
106
|
+
"Discovers all services/methods and fuzzes string fields for injection. "
|
|
107
|
+
"Requires: pip install grpcio grpcio-reflection protobuf"
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
scan_cmd.add_argument("--resume", metavar="SCAN_ID", help="Resume an interrupted scan")
|
|
111
|
+
scan_cmd.add_argument("--nvd-api-key", metavar="KEY", help="NVD API key for CVE enrichment")
|
|
112
|
+
scan_cmd.add_argument("--templates", nargs="+", metavar="DIR", help="Extra YAML template directories")
|
|
113
|
+
scan_cmd.add_argument("--skip-templates", action="store_true", help="Disable built-in YAML template scanning")
|
|
114
|
+
scan_cmd.add_argument("--nuclei-templates", action="store_true", help="Include Nuclei community templates (~10k templates, slow without --api-key)")
|
|
115
|
+
scan_cmd.add_argument(
|
|
116
|
+
"--parallel", type=int, default=1, metavar="N",
|
|
117
|
+
help="Number of targets to scan concurrently when using --targets (default: 1 = sequential)",
|
|
118
|
+
)
|
|
119
|
+
scan_cmd.add_argument(
|
|
120
|
+
"--live", action="store_true",
|
|
121
|
+
help="Print each finding immediately as it is discovered instead of waiting for scan completion",
|
|
122
|
+
)
|
|
123
|
+
scan_cmd.add_argument(
|
|
124
|
+
"--include", nargs="+", metavar="PATTERN",
|
|
125
|
+
help="Only crawl URLs matching these glob patterns (e.g. '*/api/*' '*/admin/*')",
|
|
126
|
+
)
|
|
127
|
+
scan_cmd.add_argument(
|
|
128
|
+
"--exclude", nargs="+", metavar="PATTERN",
|
|
129
|
+
help="Skip URLs matching these glob patterns (e.g. '*/logout*' '*.css' '*.js')",
|
|
130
|
+
)
|
|
131
|
+
scan_cmd.add_argument(
|
|
132
|
+
"--concurrency", type=int, default=8, metavar="N",
|
|
133
|
+
help="Number of module-threads per page (default: 8). Increase for fast targets, decrease to be polite.",
|
|
134
|
+
)
|
|
135
|
+
scan_cmd.add_argument(
|
|
136
|
+
"--rate-limit", type=int, default=10, metavar="RPS",
|
|
137
|
+
help="Maximum HTTP requests per second across the whole scan (default: 10).",
|
|
138
|
+
)
|
|
139
|
+
scan_cmd.add_argument(
|
|
140
|
+
"--har", metavar="FILE",
|
|
141
|
+
help="Import a .har file and scan all captured requests instead of crawling a live URL.",
|
|
142
|
+
)
|
|
143
|
+
scan_cmd.add_argument(
|
|
144
|
+
"--workflow", metavar="NAME_OR_FILE",
|
|
145
|
+
help=(
|
|
146
|
+
"Run a YAML workflow that chains scan steps with conditions. "
|
|
147
|
+
"Built-in: quick-web, wordpress. Custom: path to .yaml file or "
|
|
148
|
+
"name from ~/.kagesec/workflows/. "
|
|
149
|
+
"Use 'kagesec workflows' to list available workflows."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
scan_cmd.add_argument(
|
|
153
|
+
"--auto-update", action="store_true",
|
|
154
|
+
help="Automatically download newer Nuclei templates if available (background, non-blocking).",
|
|
155
|
+
)
|
|
156
|
+
scan_cmd.add_argument(
|
|
157
|
+
"--profile", metavar="NAME",
|
|
158
|
+
help=(
|
|
159
|
+
"Apply a named scan preset. Built-in: quick, full, api, passive, stealth. "
|
|
160
|
+
"Custom profiles in ~/.kagesec/profiles/<name>.yaml. "
|
|
161
|
+
"CLI flags override profile defaults."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
scan_cmd.add_argument(
|
|
165
|
+
"--wsdl", metavar="URL",
|
|
166
|
+
help="SOAP/WSDL endpoint URL — fetches WSDL, discovers operations, and probes for XXE and verbose faults.",
|
|
167
|
+
)
|
|
168
|
+
scan_cmd.add_argument("--jwt-wordlist", metavar="FILE", help="Custom JWT secrets wordlist for weak secret cracking")
|
|
169
|
+
scan_cmd.add_argument("--wordlist", metavar="FILE", help="Custom path discovery wordlist (overrides built-in)")
|
|
170
|
+
scan_cmd.add_argument("--param-wordlist", metavar="FILE", help="Custom parameter discovery wordlist")
|
|
171
|
+
scan_cmd.add_argument("--subdomain-wordlist", metavar="FILE", help="Custom subdomain enumeration wordlist")
|
|
172
|
+
scan_cmd.add_argument("--policy", metavar="FILE", help="Scan policy YAML — per-module enable/strength/timeout overrides")
|
|
173
|
+
scan_cmd.add_argument(
|
|
174
|
+
"--full", action="store_true",
|
|
175
|
+
help="Force a full scan — ignore delta state and re-scan all URLs even if unchanged since last scan",
|
|
176
|
+
)
|
|
177
|
+
scan_cmd.add_argument(
|
|
178
|
+
"--notify-slack", metavar="URL",
|
|
179
|
+
help="Slack incoming webhook URL — posts each finding above --notify-min-severity",
|
|
180
|
+
)
|
|
181
|
+
scan_cmd.add_argument(
|
|
182
|
+
"--notify-teams", metavar="URL",
|
|
183
|
+
help="Microsoft Teams incoming webhook URL",
|
|
184
|
+
)
|
|
185
|
+
scan_cmd.add_argument(
|
|
186
|
+
"--notify-discord", metavar="URL",
|
|
187
|
+
help="Discord webhook URL",
|
|
188
|
+
)
|
|
189
|
+
scan_cmd.add_argument(
|
|
190
|
+
"--notify-webhook", metavar="URL",
|
|
191
|
+
help="Generic JSON webhook URL (POST with finding payload)",
|
|
192
|
+
)
|
|
193
|
+
scan_cmd.add_argument(
|
|
194
|
+
"--notify-min-severity", metavar="LEVEL",
|
|
195
|
+
choices=["critical", "high", "medium", "low", "info"],
|
|
196
|
+
default="high",
|
|
197
|
+
help="Minimum severity to notify (default: high)",
|
|
198
|
+
)
|
|
199
|
+
scan_cmd.add_argument(
|
|
200
|
+
"--timeout", type=int, default=10, metavar="SECONDS",
|
|
201
|
+
help="Per-request HTTP timeout in seconds (default: 10)",
|
|
202
|
+
)
|
|
203
|
+
scan_cmd.add_argument(
|
|
204
|
+
"--retries", type=int, default=0, metavar="N",
|
|
205
|
+
help="Number of times to retry failed HTTP requests (default: 0)",
|
|
206
|
+
)
|
|
207
|
+
scan_cmd.add_argument(
|
|
208
|
+
"--user-agent", metavar="UA",
|
|
209
|
+
help="Custom User-Agent string (default: KageSec/1.0). Useful for WAF evasion or mobile path testing.",
|
|
210
|
+
)
|
|
211
|
+
scan_cmd.add_argument(
|
|
212
|
+
"-H", "--header", dest="custom_headers", metavar="NAME:VALUE",
|
|
213
|
+
action="append", default=[],
|
|
214
|
+
help="Add a custom HTTP header to every request (e.g. -H 'X-Api-Key: abc'). Repeatable.",
|
|
215
|
+
)
|
|
216
|
+
scan_cmd.add_argument(
|
|
217
|
+
"--max-time", type=int, default=0, metavar="MINUTES",
|
|
218
|
+
help="Hard time limit for the scan in minutes (default: 0 = unlimited). Scan stops gracefully when exceeded.",
|
|
219
|
+
)
|
|
220
|
+
scan_cmd.add_argument(
|
|
221
|
+
"-v", "--verbose", action="store_true",
|
|
222
|
+
help="Verbose output — print each URL as it is crawled and each module as it runs.",
|
|
223
|
+
)
|
|
224
|
+
scan_cmd.add_argument(
|
|
225
|
+
"--no-color", action="store_true",
|
|
226
|
+
help="Disable ANSI color codes in output (useful for log files and CI/CD pipelines).",
|
|
227
|
+
)
|
|
228
|
+
scan_cmd.add_argument(
|
|
229
|
+
"--stats", action="store_true",
|
|
230
|
+
help="Show a live progress bar on stderr while scanning (pages/modules completed, findings so far).",
|
|
231
|
+
)
|
|
232
|
+
scan_cmd.add_argument(
|
|
233
|
+
"--extensions", metavar="LIST",
|
|
234
|
+
help=(
|
|
235
|
+
"Comma-separated file extensions to append during path discovery "
|
|
236
|
+
"(e.g. '.php,.asp,.bak,.zip'). Appended to each wordlist entry."
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
scan_cmd.add_argument(
|
|
240
|
+
"--filter-status", metavar="CODES",
|
|
241
|
+
help=(
|
|
242
|
+
"Comma-separated HTTP status codes to suppress in discovery output "
|
|
243
|
+
"(e.g. '404,301' skips these from path/param discovery findings)."
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
scan_cmd.add_argument(
|
|
247
|
+
"--random-agent", action="store_true",
|
|
248
|
+
help="Rotate User-Agent string randomly per request from a built-in list.",
|
|
249
|
+
)
|
|
250
|
+
scan_cmd.add_argument(
|
|
251
|
+
"--cookie-jar", metavar="FILE",
|
|
252
|
+
help="Netscape-format cookie jar file — loads cookies for all scan requests.",
|
|
253
|
+
)
|
|
254
|
+
scan_cmd.add_argument(
|
|
255
|
+
"--dbms", choices=["mysql", "postgres", "mssql", "oracle", "sqlite"],
|
|
256
|
+
help="Specify the backend DBMS to tune SQLi payloads (auto-detected if omitted).",
|
|
257
|
+
)
|
|
258
|
+
scan_cmd.add_argument(
|
|
259
|
+
"--level", type=int, default=1, choices=range(1, 6), metavar="1-5",
|
|
260
|
+
help=(
|
|
261
|
+
"Scan aggressiveness level (default: 1). Higher levels add more payloads, "
|
|
262
|
+
"headers, and cookie injection. 1=safe, 3=standard, 5=maximum."
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
scan_cmd.add_argument(
|
|
266
|
+
"--risk", type=int, default=1, choices=range(1, 4), metavar="1-3",
|
|
267
|
+
help=(
|
|
268
|
+
"Risk of side-effects (default: 1). Higher risks include time-based "
|
|
269
|
+
"and heavy-weight payloads that may affect availability. 1=low, 3=high."
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ diff
|
|
274
|
+
diff_cmd = sub.add_parser("diff", help="Compare two scan reports and show new / resolved findings")
|
|
275
|
+
diff_cmd.add_argument("baseline", help="Baseline JSON report (earlier scan)")
|
|
276
|
+
diff_cmd.add_argument("current", help="Current JSON report (later scan)")
|
|
277
|
+
diff_cmd.add_argument(
|
|
278
|
+
"--fail-on", choices=["critical", "high", "medium", "low"],
|
|
279
|
+
help="Exit with code 1 if new findings at this severity or above are found",
|
|
280
|
+
)
|
|
281
|
+
diff_cmd.add_argument("--output", choices=["text", "json"], default="text", help="Diff output format")
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------ serve
|
|
284
|
+
serve_cmd = sub.add_parser("serve", help="Start KageSec as an HTTP API server")
|
|
285
|
+
serve_cmd.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)") # nosec B104
|
|
286
|
+
serve_cmd.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------ export
|
|
289
|
+
export_cmd = sub.add_parser("export", help="Export a scan checkpoint for transfer to another machine")
|
|
290
|
+
export_cmd.add_argument("--scan-id", required=True, metavar="ID", help="Scan ID to export")
|
|
291
|
+
export_cmd.add_argument("--report", metavar="FILE", default="kagesec_report.json",
|
|
292
|
+
help="JSON report to bundle (default: kagesec_report.json)")
|
|
293
|
+
export_cmd.add_argument("--out", metavar="FILE", default="kagesec_export.zip",
|
|
294
|
+
help="Output zip file (default: kagesec_export.zip)")
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------ import-scan
|
|
297
|
+
import_cmd = sub.add_parser("import-scan", help="Import a previously exported scan checkpoint")
|
|
298
|
+
import_cmd.add_argument("file", help="Exported zip file to import")
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------ history
|
|
301
|
+
history_cmd = sub.add_parser("history", help="Show finding trends from past scans")
|
|
302
|
+
history_cmd.add_argument("target", nargs="?", help="Filter by target URL")
|
|
303
|
+
history_cmd.add_argument("--persisting", action="store_true", help="Show only findings seen in multiple scans")
|
|
304
|
+
history_cmd.add_argument("--scans", action="store_true", help="Show scan history instead of findings")
|
|
305
|
+
history_cmd.add_argument("--limit", type=int, default=10, help="Max rows to show (default: 10)")
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ suppress
|
|
308
|
+
suppress_cmd = sub.add_parser("suppress", help="Manage false-positive suppression rules")
|
|
309
|
+
suppress_sub = suppress_cmd.add_subparsers(dest="suppress_action")
|
|
310
|
+
|
|
311
|
+
supp_add = suppress_sub.add_parser("add", help="Add a suppression rule")
|
|
312
|
+
supp_add.add_argument("--title", metavar="PATTERN", help="Suppress findings whose title contains this string")
|
|
313
|
+
supp_add.add_argument("--url-pattern", metavar="GLOB", help="fnmatch glob matching finding URL (e.g. '*/admin/*')")
|
|
314
|
+
supp_add.add_argument("--target", metavar="URL", help="Only suppress for this target (startswith match)")
|
|
315
|
+
supp_add.add_argument("--note", metavar="TEXT", help="Reason for suppression (stored for audit trail)")
|
|
316
|
+
|
|
317
|
+
suppress_sub.add_parser("list", help="List active suppression rules")
|
|
318
|
+
|
|
319
|
+
supp_rm = suppress_sub.add_parser("remove", help="Remove a suppression rule by ID")
|
|
320
|
+
supp_rm.add_argument("rule_id", help="Rule ID shown in 'suppress list'")
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------ retest
|
|
323
|
+
retest_cmd = sub.add_parser("retest", help="Re-run a specific finding to verify if it still exists")
|
|
324
|
+
retest_cmd.add_argument("finding_id", help="Finding index (0-based) or 'title:substring' to match")
|
|
325
|
+
retest_cmd.add_argument("--report", metavar="FILE", default="kagesec_report.json",
|
|
326
|
+
help="JSON report file containing the finding (default: kagesec_report.json)")
|
|
327
|
+
retest_cmd.add_argument("--api-key", metavar="KEY", help="Anthropic API key")
|
|
328
|
+
retest_cmd.add_argument("--no-ai", action="store_true", help="Skip AI verification in retest")
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------ issues
|
|
331
|
+
issues_cmd = sub.add_parser("issues", help="Export findings to Jira or GitHub Issues")
|
|
332
|
+
issues_cmd.add_argument("--report", metavar="FILE", default="kagesec_report.json",
|
|
333
|
+
help="JSON report to export (default: kagesec_report.json)")
|
|
334
|
+
issues_cmd.add_argument("--format", choices=["jira", "github"], required=True,
|
|
335
|
+
help="Export destination")
|
|
336
|
+
issues_cmd.add_argument("--jira-url", metavar="URL", help="Jira instance base URL (e.g. https://myorg.atlassian.net)")
|
|
337
|
+
issues_cmd.add_argument("--jira-project", metavar="KEY", help="Jira project key (e.g. SEC)")
|
|
338
|
+
issues_cmd.add_argument("--jira-token", metavar="TOKEN", help="Jira API token (user:token base64 or bare token)")
|
|
339
|
+
issues_cmd.add_argument("--github-repo", metavar="OWNER/REPO", help="GitHub repository (e.g. myorg/myapp)")
|
|
340
|
+
issues_cmd.add_argument("--github-token", metavar="TOKEN", help="GitHub personal access token")
|
|
341
|
+
issues_cmd.add_argument("--dry-run", action="store_true", help="Print what would be created without creating issues")
|
|
342
|
+
issues_cmd.add_argument(
|
|
343
|
+
"--min-severity", choices=["critical", "high", "medium", "low", "info"], default="medium",
|
|
344
|
+
help="Only export findings at this severity or above (default: medium)",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# ------------------------------------------------------------------ workflows
|
|
348
|
+
sub.add_parser("workflows", help="List available scan workflows")
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------ config
|
|
351
|
+
config_cmd = sub.add_parser("config", help="View or set persistent default settings (~/.kagesec/config.yaml)")
|
|
352
|
+
config_cmd.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"), action="append",
|
|
353
|
+
help="Set a config key (e.g. --set depth 5 --set output markdown)")
|
|
354
|
+
config_cmd.add_argument("--unset", nargs="+", metavar="KEY", help="Remove config key(s)")
|
|
355
|
+
config_cmd.add_argument("--show", action="store_true", help="Show current config (default action)")
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------ update-templates
|
|
358
|
+
update_cmd = sub.add_parser(
|
|
359
|
+
"update-templates",
|
|
360
|
+
help="Download Nuclei community templates (~9,500 CVE/misconfiguration templates) filtered for KageSec compatibility",
|
|
361
|
+
)
|
|
362
|
+
update_cmd.add_argument(
|
|
363
|
+
"--dir", metavar="PATH",
|
|
364
|
+
default=os.path.expanduser("~/.kagesec/nuclei-templates"),
|
|
365
|
+
help="Directory to save templates (default: ~/.kagesec/nuclei-templates/)",
|
|
366
|
+
)
|
|
367
|
+
update_cmd.add_argument(
|
|
368
|
+
"--all", action="store_true",
|
|
369
|
+
help="Keep all templates including unsupported types (flow/network/dns/headless). Default: compatible only.",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
args = parser.parse_args()
|
|
373
|
+
|
|
374
|
+
_print_disclaimer()
|
|
375
|
+
|
|
376
|
+
if not args.command:
|
|
377
|
+
parser.print_help()
|
|
378
|
+
sys.exit(0)
|
|
379
|
+
|
|
380
|
+
if args.command == "history":
|
|
381
|
+
_run_history(args)
|
|
382
|
+
sys.exit(0)
|
|
383
|
+
|
|
384
|
+
if args.command == "suppress":
|
|
385
|
+
_run_suppress(args)
|
|
386
|
+
sys.exit(0)
|
|
387
|
+
|
|
388
|
+
if args.command == "retest":
|
|
389
|
+
_run_retest(args)
|
|
390
|
+
sys.exit(0)
|
|
391
|
+
|
|
392
|
+
if args.command == "issues":
|
|
393
|
+
_run_issues(args)
|
|
394
|
+
sys.exit(0)
|
|
395
|
+
|
|
396
|
+
if args.command == "workflows":
|
|
397
|
+
from scanner.core.workflow import list_workflows
|
|
398
|
+
names = list_workflows()
|
|
399
|
+
if names:
|
|
400
|
+
print("[+] Available workflows:")
|
|
401
|
+
for n in names:
|
|
402
|
+
print(f" {n}")
|
|
403
|
+
else:
|
|
404
|
+
print("[*] No workflows found. Place .yaml files in ~/.kagesec/workflows/")
|
|
405
|
+
sys.exit(0)
|
|
406
|
+
|
|
407
|
+
if args.command == "config":
|
|
408
|
+
_run_config(args)
|
|
409
|
+
sys.exit(0)
|
|
410
|
+
|
|
411
|
+
if args.command == "update-templates":
|
|
412
|
+
_update_templates(args.dir, keep_all=getattr(args, "all", False))
|
|
413
|
+
sys.exit(0)
|
|
414
|
+
|
|
415
|
+
if args.command == "serve":
|
|
416
|
+
from scanner.api.server import serve
|
|
417
|
+
serve(host=args.host, port=args.port)
|
|
418
|
+
sys.exit(0)
|
|
419
|
+
|
|
420
|
+
if args.command == "export":
|
|
421
|
+
_export_scan(args.scan_id, args.report, args.out)
|
|
422
|
+
sys.exit(0)
|
|
423
|
+
|
|
424
|
+
if args.command == "import-scan":
|
|
425
|
+
_import_scan(args.file)
|
|
426
|
+
sys.exit(0)
|
|
427
|
+
|
|
428
|
+
if args.command == "diff":
|
|
429
|
+
_run_diff(args)
|
|
430
|
+
sys.exit(0)
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------ scan logic
|
|
433
|
+
if not args.target and not getattr(args, "targets", None) and not getattr(args, "har", None):
|
|
434
|
+
scan_cmd.error("provide a target URL, --targets FILE, or --har FILE")
|
|
435
|
+
|
|
436
|
+
targets = _resolve_targets(args)
|
|
437
|
+
|
|
438
|
+
if len(targets) > 1:
|
|
439
|
+
_run_multi_target(targets, args)
|
|
440
|
+
else:
|
|
441
|
+
_run_single_target(targets[0], args, prefix="")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Helpers
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _print_disclaimer() -> None:
|
|
449
|
+
print(
|
|
450
|
+
"\n"
|
|
451
|
+
" KageSec — Authorized Security Testing Only\n"
|
|
452
|
+
" -------------------------------------------\n"
|
|
453
|
+
" This tool actively probes targets with attack payloads.\n"
|
|
454
|
+
" Use it ONLY on systems you own or have explicit written permission to test.\n"
|
|
455
|
+
" Unauthorized scanning may violate the CFAA, Computer Misuse Act, and\n"
|
|
456
|
+
" equivalent laws in your jurisdiction. The authors accept no liability\n"
|
|
457
|
+
" for misuse. By proceeding you confirm you are authorized to test this target.\n"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _resolve_targets(args) -> list[str]:
|
|
462
|
+
if getattr(args, "targets", None):
|
|
463
|
+
try:
|
|
464
|
+
with open(args.targets) as f:
|
|
465
|
+
lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
|
466
|
+
return lines
|
|
467
|
+
except FileNotFoundError:
|
|
468
|
+
print(f"[!] Targets file not found: {args.targets}")
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
if getattr(args, "har", None):
|
|
471
|
+
# Derive target from the first entry in the HAR file
|
|
472
|
+
if args.target:
|
|
473
|
+
return [args.target]
|
|
474
|
+
try:
|
|
475
|
+
import json as _json
|
|
476
|
+
from urllib.parse import urlparse as _up
|
|
477
|
+
with open(args.har) as f:
|
|
478
|
+
har = _json.load(f)
|
|
479
|
+
first_url = har["log"]["entries"][0]["request"]["url"]
|
|
480
|
+
p = _up(first_url)
|
|
481
|
+
return [f"{p.scheme}://{p.netloc}"]
|
|
482
|
+
except Exception:
|
|
483
|
+
print("[!] Could not derive target from HAR — pass --target explicitly.")
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
return [args.target]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _run_multi_target(targets: list[str], args) -> None:
|
|
489
|
+
parallel = getattr(args, "parallel", 1)
|
|
490
|
+
print(f"[*] Multi-target scan: {len(targets)} targets (parallel={parallel})")
|
|
491
|
+
print()
|
|
492
|
+
|
|
493
|
+
if parallel <= 1:
|
|
494
|
+
any_fail = False
|
|
495
|
+
for i, target in enumerate(targets, 1):
|
|
496
|
+
print(f"[{i}/{len(targets)}] {target}")
|
|
497
|
+
prefix = _safe_hostname(target)
|
|
498
|
+
if _run_single_target(target, args, prefix=prefix):
|
|
499
|
+
any_fail = True
|
|
500
|
+
print()
|
|
501
|
+
if any_fail:
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
# Concurrent path — each target in its own thread, output serialised with a lock
|
|
506
|
+
_print_lock = threading.Lock()
|
|
507
|
+
|
|
508
|
+
def _scan_one(i: int, target: str) -> int:
|
|
509
|
+
prefix = _safe_hostname(target)
|
|
510
|
+
with _print_lock:
|
|
511
|
+
print(f"[{i}/{len(targets)}] Starting {target}")
|
|
512
|
+
code = _run_single_target(target, args, prefix=prefix, print_lock=_print_lock)
|
|
513
|
+
with _print_lock:
|
|
514
|
+
print(f"[{i}/{len(targets)}] Done {target}")
|
|
515
|
+
return code
|
|
516
|
+
|
|
517
|
+
any_fail = False
|
|
518
|
+
with ThreadPoolExecutor(max_workers=parallel) as ex:
|
|
519
|
+
futs = {ex.submit(_scan_one, i, t): t for i, t in enumerate(targets, 1)}
|
|
520
|
+
for fut in as_completed(futs):
|
|
521
|
+
if fut.result():
|
|
522
|
+
any_fail = True
|
|
523
|
+
|
|
524
|
+
if any_fail:
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _run_single_target(target: str, args, prefix: str, print_lock=None) -> int:
|
|
529
|
+
"""Run scan against one target. Returns 1 if --fail-on threshold is breached."""
|
|
530
|
+
# Apply scan profile first (lowest priority), then persisted policy
|
|
531
|
+
profile_name = getattr(args, "profile", None)
|
|
532
|
+
if profile_name:
|
|
533
|
+
from scanner.core import profiles as _profiles
|
|
534
|
+
try:
|
|
535
|
+
prof = _profiles.load(profile_name)
|
|
536
|
+
_profiles.apply_to_namespace(prof, args)
|
|
537
|
+
except ValueError as e:
|
|
538
|
+
print(f"[!] {e}")
|
|
539
|
+
sys.exit(1)
|
|
540
|
+
|
|
541
|
+
# Apply persisted policy defaults (CLI overrides them via argparse defaults check)
|
|
542
|
+
_policy.apply_to_namespace(_policy.load(), args)
|
|
543
|
+
|
|
544
|
+
auth = _build_auth(args)
|
|
545
|
+
login_flow = _build_login_flow(args)
|
|
546
|
+
modules = _build_modules(args)
|
|
547
|
+
|
|
548
|
+
config = ScanConfig(
|
|
549
|
+
target=target,
|
|
550
|
+
max_depth=args.depth,
|
|
551
|
+
max_pages=args.max_pages,
|
|
552
|
+
modules=modules,
|
|
553
|
+
auth=auth,
|
|
554
|
+
compliance=args.compliance or [],
|
|
555
|
+
browser=args.browser,
|
|
556
|
+
login_flow=login_flow,
|
|
557
|
+
openapi_spec=args.openapi,
|
|
558
|
+
graphql_endpoint=args.graphql,
|
|
559
|
+
resume_scan_id=args.resume,
|
|
560
|
+
nvd_api_key=args.nvd_api_key,
|
|
561
|
+
template_dirs=args.templates or [],
|
|
562
|
+
nuclei_templates=getattr(args, "nuclei_templates", False),
|
|
563
|
+
proxy=getattr(args, "proxy", None),
|
|
564
|
+
passive=getattr(args, "passive", False),
|
|
565
|
+
follow_robots=getattr(args, "follow_robots", False),
|
|
566
|
+
use_oob=not getattr(args, "no_oob", False),
|
|
567
|
+
oob_server=getattr(args, "oob_server", None) or "oast.pro",
|
|
568
|
+
include_patterns=getattr(args, "include", None) or [],
|
|
569
|
+
exclude_patterns=getattr(args, "exclude", None) or [],
|
|
570
|
+
rate_limit_rps=getattr(args, "rate_limit", 10),
|
|
571
|
+
har_file=getattr(args, "har", None),
|
|
572
|
+
wsdl_url=getattr(args, "wsdl", None),
|
|
573
|
+
jwt_wordlist=getattr(args, "jwt_wordlist", None),
|
|
574
|
+
path_wordlist=getattr(args, "wordlist", None),
|
|
575
|
+
param_wordlist=getattr(args, "param_wordlist", None),
|
|
576
|
+
subdomain_wordlist=getattr(args, "subdomain_wordlist", None),
|
|
577
|
+
scan_policy_file=getattr(args, "policy", None),
|
|
578
|
+
force_full_scan=getattr(args, "full", False),
|
|
579
|
+
timeout=getattr(args, "timeout", 10),
|
|
580
|
+
retries=getattr(args, "retries", 0),
|
|
581
|
+
user_agent=getattr(args, "user_agent", None),
|
|
582
|
+
verbose=getattr(args, "verbose", False),
|
|
583
|
+
no_color=getattr(args, "no_color", False),
|
|
584
|
+
max_time_minutes=getattr(args, "max_time", 0),
|
|
585
|
+
headers=_parse_custom_headers(getattr(args, "custom_headers", [])),
|
|
586
|
+
extensions=_parse_extensions(getattr(args, "extensions", None)),
|
|
587
|
+
filter_status_codes=_parse_status_codes(getattr(args, "filter_status", None)),
|
|
588
|
+
random_agent=getattr(args, "random_agent", False),
|
|
589
|
+
cookie_jar=getattr(args, "cookie_jar", None),
|
|
590
|
+
dbms=getattr(args, "dbms", None),
|
|
591
|
+
level=getattr(args, "level", 1),
|
|
592
|
+
risk=getattr(args, "risk", 1),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
current_scan_id = args.resume or str(_uuid.uuid4())
|
|
596
|
+
|
|
597
|
+
# env var overrides for GitHub Actions (KAGESEC_* vars set by action.yml)
|
|
598
|
+
_env_no_ai = os.getenv("KAGESEC_NO_AI", "").lower() in ("1", "true", "yes")
|
|
599
|
+
_env_passive = os.getenv("KAGESEC_PASSIVE", "").lower() in ("1", "true", "yes")
|
|
600
|
+
_env_modules = os.getenv("KAGESEC_MODULES", "").split() or None
|
|
601
|
+
_env_exclude = os.getenv("KAGESEC_EXCLUDE", "").split() or None
|
|
602
|
+
|
|
603
|
+
if _env_no_ai:
|
|
604
|
+
args.no_ai = True
|
|
605
|
+
if _env_passive:
|
|
606
|
+
config.passive = True
|
|
607
|
+
if _env_modules and not config.modules:
|
|
608
|
+
config.modules = _env_modules
|
|
609
|
+
if _env_exclude and not config.exclude_patterns:
|
|
610
|
+
config.exclude_patterns = _env_exclude
|
|
611
|
+
|
|
612
|
+
api_key = None
|
|
613
|
+
if not args.no_ai:
|
|
614
|
+
api_key = getattr(args, "api_key", None) or os.getenv("ANTHROPIC_API_KEY")
|
|
615
|
+
if not args.no_ai and not api_key:
|
|
616
|
+
print("[!] No Anthropic API key — running without AI features.")
|
|
617
|
+
print(" Set ANTHROPIC_API_KEY or pass --api-key <key> to enable AI.\n")
|
|
618
|
+
|
|
619
|
+
config.api_key = api_key
|
|
620
|
+
|
|
621
|
+
# Live findings callback — prints each finding as it is discovered
|
|
622
|
+
_live = getattr(args, "live", False)
|
|
623
|
+
_no_color = getattr(args, "no_color", False) or not sys.stdout.isatty()
|
|
624
|
+
_severity_colours = {
|
|
625
|
+
"critical": "\033[91m", "high": "\033[91m",
|
|
626
|
+
"medium": "\033[93m", "low": "\033[94m", "info": "\033[96m",
|
|
627
|
+
}
|
|
628
|
+
_RESET = "\033[0m"
|
|
629
|
+
|
|
630
|
+
def _live_print(finding):
|
|
631
|
+
sev = finding.severity.value
|
|
632
|
+
colour = "" if _no_color else _severity_colours.get(sev, "")
|
|
633
|
+
reset = "" if _no_color else _RESET
|
|
634
|
+
line = (
|
|
635
|
+
f"{colour}[LIVE][{sev.upper():<8}]{reset} "
|
|
636
|
+
f"{finding.title} — {finding.url}"
|
|
637
|
+
)
|
|
638
|
+
if print_lock:
|
|
639
|
+
with print_lock:
|
|
640
|
+
print(line, flush=True)
|
|
641
|
+
else:
|
|
642
|
+
print(line, flush=True)
|
|
643
|
+
|
|
644
|
+
# Notifier — posts findings to Slack/Teams/Discord/webhook in real time
|
|
645
|
+
_notifier = None
|
|
646
|
+
_notify_slack = getattr(args, "notify_slack", None)
|
|
647
|
+
_notify_teams = getattr(args, "notify_teams", None)
|
|
648
|
+
_notify_discord = getattr(args, "notify_discord", None)
|
|
649
|
+
_notify_webhook = getattr(args, "notify_webhook", None)
|
|
650
|
+
if any([_notify_slack, _notify_teams, _notify_discord, _notify_webhook]):
|
|
651
|
+
from scanner.core.notifier import Notifier
|
|
652
|
+
from scanner.core.scan_result import Severity as _Severity
|
|
653
|
+
_min_sev_str = getattr(args, "notify_min_severity", "high")
|
|
654
|
+
_min_sev = _Severity(_min_sev_str)
|
|
655
|
+
_notifier = Notifier(
|
|
656
|
+
slack_url=_notify_slack,
|
|
657
|
+
teams_url=_notify_teams,
|
|
658
|
+
discord_url=_notify_discord,
|
|
659
|
+
webhook_url=_notify_webhook,
|
|
660
|
+
min_severity=_min_sev,
|
|
661
|
+
)
|
|
662
|
+
if _live:
|
|
663
|
+
def finding_callback(finding):
|
|
664
|
+
_live_print(finding)
|
|
665
|
+
_notifier(finding)
|
|
666
|
+
else:
|
|
667
|
+
finding_callback = _notifier
|
|
668
|
+
else:
|
|
669
|
+
finding_callback = _live_print if _live else None
|
|
670
|
+
|
|
671
|
+
mode_tags = []
|
|
672
|
+
if config.passive:
|
|
673
|
+
mode_tags.append("passive")
|
|
674
|
+
if config.proxy:
|
|
675
|
+
mode_tags.append(f"proxy={config.proxy}")
|
|
676
|
+
if config.browser:
|
|
677
|
+
mode_tags.append("browser")
|
|
678
|
+
|
|
679
|
+
# First-run bootstrap: download Nuclei templates if not yet installed
|
|
680
|
+
from scanner.core import updater as _updater
|
|
681
|
+
if not getattr(args, "skip_templates", False):
|
|
682
|
+
_updater.bootstrap_if_needed()
|
|
683
|
+
# Non-blocking update check (prints a notice if templates are outdated)
|
|
684
|
+
_updater.check_for_updates(auto=getattr(args, "auto_update", False))
|
|
685
|
+
|
|
686
|
+
print(f"[*] Scan ID: {current_scan_id}")
|
|
687
|
+
print(f"[*] Target: {target}")
|
|
688
|
+
print(f"[*] Depth: {config.max_depth} | Max pages: {config.max_pages}", end="")
|
|
689
|
+
if mode_tags:
|
|
690
|
+
print(f" | {', '.join(mode_tags)}", end="")
|
|
691
|
+
print()
|
|
692
|
+
if profile_name:
|
|
693
|
+
print(f"[*] Profile: {profile_name}")
|
|
694
|
+
if config.modules:
|
|
695
|
+
print(f"[*] Modules: {', '.join(config.modules)}")
|
|
696
|
+
if config.compliance:
|
|
697
|
+
print(f"[*] Compliance: {', '.join(config.compliance).upper()}")
|
|
698
|
+
print()
|
|
699
|
+
|
|
700
|
+
# Progress bar (--stats) — renders on stderr so it doesn't pollute stdout reports
|
|
701
|
+
_stats = getattr(args, "stats", False)
|
|
702
|
+
_progress_cb = None
|
|
703
|
+
if _stats and not _no_color:
|
|
704
|
+
import time as _time
|
|
705
|
+
_bar_start = _time.monotonic()
|
|
706
|
+
|
|
707
|
+
def _progress_cb(done: int, total: int, findings: int):
|
|
708
|
+
if total == 0:
|
|
709
|
+
return
|
|
710
|
+
pct = done / total
|
|
711
|
+
filled = int(30 * pct)
|
|
712
|
+
bar = "\033[92m" + "█" * filled + "\033[90m" + "░" * (30 - filled) + "\033[0m"
|
|
713
|
+
elapsed = _time.monotonic() - _bar_start
|
|
714
|
+
eta = (elapsed / pct - elapsed) if pct > 0 else 0
|
|
715
|
+
line = (
|
|
716
|
+
f"\r[{bar}] {pct * 100:5.1f}% "
|
|
717
|
+
f"{done}/{total} checks "
|
|
718
|
+
f"{findings} finding{'s' if findings != 1 else ''} "
|
|
719
|
+
f"eta {eta:.0f}s "
|
|
720
|
+
)
|
|
721
|
+
sys.stderr.write(line)
|
|
722
|
+
sys.stderr.flush()
|
|
723
|
+
if done == total:
|
|
724
|
+
sys.stderr.write("\r" + " " * len(line) + "\r")
|
|
725
|
+
sys.stderr.flush()
|
|
726
|
+
|
|
727
|
+
workflow_name = getattr(args, "workflow", None)
|
|
728
|
+
if workflow_name:
|
|
729
|
+
from scanner.core.workflow import load as _load_workflow, run_workflow as _run_workflow
|
|
730
|
+
try:
|
|
731
|
+
wf = _load_workflow(workflow_name)
|
|
732
|
+
except ValueError as e:
|
|
733
|
+
print(f"[!] {e}")
|
|
734
|
+
sys.exit(1)
|
|
735
|
+
result = _run_workflow(wf, config, api_key=api_key,
|
|
736
|
+
finding_callback=finding_callback,
|
|
737
|
+
concurrency=getattr(args, "concurrency", 8))
|
|
738
|
+
report_md = None
|
|
739
|
+
else:
|
|
740
|
+
result, report_md = run_scan(
|
|
741
|
+
config=config, api_key=api_key, scan_id=current_scan_id,
|
|
742
|
+
finding_callback=finding_callback,
|
|
743
|
+
concurrency=getattr(args, "concurrency", 8),
|
|
744
|
+
progress_callback=_progress_cb,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# SOAP/WSDL scan (if --wsdl provided) — runs after the main scan
|
|
748
|
+
wsdl_url = getattr(args, "wsdl", None)
|
|
749
|
+
if wsdl_url:
|
|
750
|
+
try:
|
|
751
|
+
from scanner.core.soap_scanner import scan_wsdl
|
|
752
|
+
import httpx as _httpx
|
|
753
|
+
print(f"\n[*] SOAP/WSDL scan: {wsdl_url}")
|
|
754
|
+
_soap_proxies = {"http://": config.proxy, "https://": config.proxy} if config.proxy else None
|
|
755
|
+
with _httpx.Client(follow_redirects=True, timeout=15, verify=False, proxies=_soap_proxies) as _soap_client: # nosec B501
|
|
756
|
+
soap_findings = scan_wsdl(wsdl_url, _soap_client, config)
|
|
757
|
+
if soap_findings:
|
|
758
|
+
print(f"[+] SOAP findings: {len(soap_findings)}")
|
|
759
|
+
result.findings.extend(soap_findings)
|
|
760
|
+
for f in soap_findings:
|
|
761
|
+
if finding_callback:
|
|
762
|
+
finding_callback(f)
|
|
763
|
+
else:
|
|
764
|
+
print("[+] SOAP scan: no issues found")
|
|
765
|
+
except Exception as e:
|
|
766
|
+
print(f"[!] SOAP/WSDL scan failed: {e}")
|
|
767
|
+
|
|
768
|
+
# gRPC scan (if requested) — runs after the main scan
|
|
769
|
+
grpc_endpoint = getattr(args, "grpc", None)
|
|
770
|
+
if grpc_endpoint:
|
|
771
|
+
try:
|
|
772
|
+
from scanner.core.grpc_scanner import scan_grpc
|
|
773
|
+
print(f"\n[*] gRPC scan: {grpc_endpoint}")
|
|
774
|
+
grpc_result = scan_grpc(grpc_endpoint, config)
|
|
775
|
+
if grpc_result.error:
|
|
776
|
+
print(f"[!] gRPC: {grpc_result.error}")
|
|
777
|
+
else:
|
|
778
|
+
print(f"[+] gRPC services: {len(grpc_result.services)} methods: {len(grpc_result.methods)}")
|
|
779
|
+
result.findings.extend(grpc_result.findings)
|
|
780
|
+
for f in grpc_result.findings:
|
|
781
|
+
if finding_callback:
|
|
782
|
+
finding_callback(f)
|
|
783
|
+
except Exception as e:
|
|
784
|
+
print(f"[!] gRPC scan failed: {e}")
|
|
785
|
+
|
|
786
|
+
summary = result.summary()
|
|
787
|
+
dur = summary['duration_seconds']
|
|
788
|
+
pages = summary['pages_crawled']
|
|
789
|
+
_no_color_out = getattr(args, "no_color", False) or not sys.stdout.isatty()
|
|
790
|
+
_G = "" if _no_color_out else "\033[92m"
|
|
791
|
+
_R = "" if _no_color_out else "\033[0m"
|
|
792
|
+
if dur > 0 and pages > 0:
|
|
793
|
+
rate = pages / dur
|
|
794
|
+
rps_hint = f" (~{rate:.1f} pages/s)" if rate >= 1 else f" (~{dur / pages:.0f}s/page)"
|
|
795
|
+
else:
|
|
796
|
+
rps_hint = ""
|
|
797
|
+
print(f"\n{_G}[+] Scan complete{_R} in {dur:.1f}s{rps_hint}")
|
|
798
|
+
print(f"[+] Pages crawled: {pages}")
|
|
799
|
+
print(f"[+] Findings: {summary['total_findings']} total")
|
|
800
|
+
for severity, count in summary["by_severity"].items():
|
|
801
|
+
if count:
|
|
802
|
+
sev_color = {"critical": "\033[91m", "high": "\033[91m", "medium": "\033[93m",
|
|
803
|
+
"low": "\033[94m", "info": "\033[96m"}.get(severity, "")
|
|
804
|
+
sc = "" if _no_color_out else sev_color
|
|
805
|
+
print(f" {sc}{severity.upper():<12}{_R} {count}")
|
|
806
|
+
|
|
807
|
+
if result.compliance_reports:
|
|
808
|
+
print()
|
|
809
|
+
print("[+] Compliance scores:")
|
|
810
|
+
for cr in result.compliance_reports:
|
|
811
|
+
passed = sum(1 for c in cr.controls if c.status == "pass")
|
|
812
|
+
failed = sum(1 for c in cr.controls if c.status == "fail")
|
|
813
|
+
manual = sum(1 for c in cr.controls if c.status == "manual")
|
|
814
|
+
print(f" {cr.standard:<12} {cr.score:.0f}/100 (pass:{passed} fail:{failed} manual:{manual})")
|
|
815
|
+
|
|
816
|
+
slug = f"_{prefix}" if prefix else ""
|
|
817
|
+
_write_reports(args, result, report_md, slug)
|
|
818
|
+
|
|
819
|
+
if args.fail_on:
|
|
820
|
+
severity_order = ["critical", "high", "medium", "low"]
|
|
821
|
+
threshold_idx = severity_order.index(args.fail_on)
|
|
822
|
+
for finding in result.findings:
|
|
823
|
+
if severity_order.index(finding.severity.value) <= threshold_idx:
|
|
824
|
+
print(f"\n[!] Failing CI: {args.fail_on.upper()} or above findings detected.")
|
|
825
|
+
return 1
|
|
826
|
+
return 0
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _write_reports(args, result, report_md, slug: str) -> None:
|
|
830
|
+
import os
|
|
831
|
+
reports_dir = "reports"
|
|
832
|
+
os.makedirs(reports_dir, exist_ok=True)
|
|
833
|
+
|
|
834
|
+
def _rpath(filename: str) -> str:
|
|
835
|
+
return os.path.join(reports_dir, filename)
|
|
836
|
+
|
|
837
|
+
if args.output in ("json", "all"):
|
|
838
|
+
path = _rpath(f"kagesec_report{slug}.json")
|
|
839
|
+
try:
|
|
840
|
+
out = _findings_dict(result)
|
|
841
|
+
with open(path, "w") as fp:
|
|
842
|
+
# default=str converts any non-serializable value (e.g. set, Enum)
|
|
843
|
+
# to its string representation rather than crashing
|
|
844
|
+
json.dump(out, fp, indent=2, default=str)
|
|
845
|
+
print(f"\n[+] JSON report: {path}")
|
|
846
|
+
except Exception as e:
|
|
847
|
+
print(f"\n[!] Failed to write JSON report ({path}): {e}")
|
|
848
|
+
import traceback
|
|
849
|
+
traceback.print_exc()
|
|
850
|
+
|
|
851
|
+
if report_md and args.output in ("markdown", "all"):
|
|
852
|
+
path = _rpath(f"kagesec_report{slug}.md")
|
|
853
|
+
with open(path, "w") as fp:
|
|
854
|
+
fp.write(report_md)
|
|
855
|
+
print(f"[+] Markdown report: {path}")
|
|
856
|
+
|
|
857
|
+
if args.output in ("sarif", "all"):
|
|
858
|
+
try:
|
|
859
|
+
from scanner.reporters.sarif_reporter import generate_sarif
|
|
860
|
+
sarif_path = generate_sarif(result, _rpath(f"kagesec_report{slug}.sarif"))
|
|
861
|
+
print(f"[+] SARIF report: {sarif_path}")
|
|
862
|
+
except Exception as e:
|
|
863
|
+
print(f"[!] SARIF generation failed: {e}")
|
|
864
|
+
|
|
865
|
+
if args.output in ("pdf", "all"):
|
|
866
|
+
try:
|
|
867
|
+
from scanner.reporters.pdf_reporter import generate_pdf
|
|
868
|
+
_auth_type, _auth_value = _auth_display(args)
|
|
869
|
+
pdf_path = generate_pdf(
|
|
870
|
+
result, _rpath(f"kagesec_report{slug}.pdf"),
|
|
871
|
+
auth_type=_auth_type,
|
|
872
|
+
auth_value=_auth_value,
|
|
873
|
+
)
|
|
874
|
+
print(f"[+] PDF report: {pdf_path}")
|
|
875
|
+
except RuntimeError as e:
|
|
876
|
+
print(f"[!] PDF generation skipped: {e}")
|
|
877
|
+
|
|
878
|
+
if args.output in ("burp", "all"):
|
|
879
|
+
try:
|
|
880
|
+
from scanner.reporters.burp_reporter import generate_burp
|
|
881
|
+
burp_path = generate_burp(result, _rpath(f"kagesec_report{slug}.xml"))
|
|
882
|
+
print(f"[+] Burp XML report: {burp_path}")
|
|
883
|
+
except Exception as e:
|
|
884
|
+
print(f"[!] Burp export failed: {e}")
|
|
885
|
+
|
|
886
|
+
if args.output in ("zap", "all"):
|
|
887
|
+
try:
|
|
888
|
+
from scanner.reporters.zap_reporter import generate_zap
|
|
889
|
+
zap_path = generate_zap(result, _rpath(f"kagesec_report{slug}_zap.json"))
|
|
890
|
+
print(f"[+] ZAP JSON report: {zap_path}")
|
|
891
|
+
except Exception as e:
|
|
892
|
+
print(f"[!] ZAP export failed: {e}")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _findings_dict(result) -> dict:
|
|
896
|
+
summary = result.summary()
|
|
897
|
+
return {
|
|
898
|
+
"summary": summary,
|
|
899
|
+
"findings": [
|
|
900
|
+
{
|
|
901
|
+
"title": f.title,
|
|
902
|
+
"severity": f.severity.value,
|
|
903
|
+
"owasp_category": f.owasp_category,
|
|
904
|
+
"url": f.url,
|
|
905
|
+
"parameter": f.parameter,
|
|
906
|
+
"payload": f.payload,
|
|
907
|
+
"evidence": f.evidence,
|
|
908
|
+
"verified": f.verified,
|
|
909
|
+
"confidence": f.confidence,
|
|
910
|
+
"ai_verdict": f.ai_verdict,
|
|
911
|
+
"ai_analysis": f.ai_analysis,
|
|
912
|
+
"ai_exploitability": f.ai_exploitability,
|
|
913
|
+
"ai_business_impact": f.ai_business_impact,
|
|
914
|
+
"ai_attack_scenario": f.ai_attack_scenario,
|
|
915
|
+
"cwe": f.cwe,
|
|
916
|
+
"cvss": f.cvss,
|
|
917
|
+
"remediation": f.remediation,
|
|
918
|
+
"standards": f.standards,
|
|
919
|
+
"poc_curl": f.poc_curl,
|
|
920
|
+
}
|
|
921
|
+
for f in result.findings
|
|
922
|
+
if not f.false_positive_suppressed
|
|
923
|
+
],
|
|
924
|
+
"compliance": [cr.summary() for cr in result.compliance_reports],
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
# ---------------------------------------------------------------------------
|
|
929
|
+
# Diff subcommand
|
|
930
|
+
# ---------------------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
def _run_diff(args) -> None:
|
|
933
|
+
try:
|
|
934
|
+
with open(args.baseline) as f:
|
|
935
|
+
baseline = json.load(f)
|
|
936
|
+
with open(args.current) as f:
|
|
937
|
+
current = json.load(f)
|
|
938
|
+
except FileNotFoundError as e:
|
|
939
|
+
print(f"[!] {e}")
|
|
940
|
+
sys.exit(1)
|
|
941
|
+
except json.JSONDecodeError as e:
|
|
942
|
+
print(f"[!] Invalid JSON: {e}")
|
|
943
|
+
sys.exit(1)
|
|
944
|
+
|
|
945
|
+
def _key(finding: dict) -> str:
|
|
946
|
+
return f"{finding['title']}|{finding['url']}|{finding.get('parameter', '')}"
|
|
947
|
+
|
|
948
|
+
baseline_keys = {_key(f): f for f in baseline.get("findings", [])}
|
|
949
|
+
current_keys = {_key(f): f for f in current.get("findings", [])}
|
|
950
|
+
|
|
951
|
+
new_findings = [f for k, f in current_keys.items() if k not in baseline_keys]
|
|
952
|
+
resolved = [f for k, f in baseline_keys.items() if k not in current_keys]
|
|
953
|
+
unchanged = [f for k, f in current_keys.items() if k in baseline_keys]
|
|
954
|
+
|
|
955
|
+
if args.output == "json":
|
|
956
|
+
print(json.dumps({
|
|
957
|
+
"new": new_findings,
|
|
958
|
+
"resolved": resolved,
|
|
959
|
+
"unchanged_count": len(unchanged),
|
|
960
|
+
}, indent=2))
|
|
961
|
+
else:
|
|
962
|
+
print(f"[+] New findings: {len(new_findings)}")
|
|
963
|
+
print(f"[+] Resolved findings: {len(resolved)}")
|
|
964
|
+
print(f"[+] Unchanged: {len(unchanged)}")
|
|
965
|
+
|
|
966
|
+
if new_findings:
|
|
967
|
+
print("\n--- NEW ---")
|
|
968
|
+
for f in sorted(new_findings, key=lambda x: x.get("severity", "low")):
|
|
969
|
+
print(f" [{f['severity'].upper():<8}] {f['title']}")
|
|
970
|
+
print(f" {f['url']}")
|
|
971
|
+
|
|
972
|
+
if resolved:
|
|
973
|
+
print("\n--- RESOLVED ---")
|
|
974
|
+
for f in resolved:
|
|
975
|
+
print(f" [{f['severity'].upper():<8}] {f['title']}")
|
|
976
|
+
|
|
977
|
+
if args.fail_on:
|
|
978
|
+
severity_order = ["critical", "high", "medium", "low"]
|
|
979
|
+
threshold_idx = severity_order.index(args.fail_on)
|
|
980
|
+
for f in new_findings:
|
|
981
|
+
if severity_order.index(f.get("severity", "low")) <= threshold_idx:
|
|
982
|
+
print(f"\n[!] New {args.fail_on.upper()}+ findings detected.")
|
|
983
|
+
sys.exit(1)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
# ---------------------------------------------------------------------------
|
|
987
|
+
# Auth / config builders
|
|
988
|
+
# ---------------------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
def _build_auth(args) -> dict | None:
|
|
991
|
+
if getattr(args, "auth_oauth2_token_url", None):
|
|
992
|
+
token = _fetch_oauth2_token(
|
|
993
|
+
args.auth_oauth2_token_url,
|
|
994
|
+
getattr(args, "auth_oauth2_client_id", ""),
|
|
995
|
+
getattr(args, "auth_oauth2_client_secret", ""),
|
|
996
|
+
)
|
|
997
|
+
if token:
|
|
998
|
+
return {"type": "bearer", "value": token}
|
|
999
|
+
print("[!] OAuth2 token exchange failed — continuing unauthenticated.")
|
|
1000
|
+
return None
|
|
1001
|
+
|
|
1002
|
+
if getattr(args, "auth_bearer", None):
|
|
1003
|
+
return {"type": "bearer", "value": args.auth_bearer}
|
|
1004
|
+
|
|
1005
|
+
if getattr(args, "auth_cookie", None):
|
|
1006
|
+
name, _, value = args.auth_cookie.partition("=")
|
|
1007
|
+
return {"type": "cookie", "cookies": {name: value}}
|
|
1008
|
+
|
|
1009
|
+
return None
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _fetch_oauth2_token(token_url: str, client_id: str, client_secret: str) -> str | None:
|
|
1013
|
+
try:
|
|
1014
|
+
import httpx
|
|
1015
|
+
resp = httpx.post(
|
|
1016
|
+
token_url,
|
|
1017
|
+
data={
|
|
1018
|
+
"grant_type": "client_credentials",
|
|
1019
|
+
"client_id": client_id,
|
|
1020
|
+
"client_secret": client_secret,
|
|
1021
|
+
},
|
|
1022
|
+
timeout=15,
|
|
1023
|
+
)
|
|
1024
|
+
data = resp.json()
|
|
1025
|
+
token = data.get("access_token")
|
|
1026
|
+
if token:
|
|
1027
|
+
print(f"[+] OAuth2 token obtained (expires_in={data.get('expires_in', '?')}s)")
|
|
1028
|
+
return token
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
print(f"[!] OAuth2 error: {e}")
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _build_login_flow(args) -> "LoginFlow | None":
|
|
1035
|
+
if not getattr(args, "login_url", None):
|
|
1036
|
+
return None
|
|
1037
|
+
return LoginFlow(
|
|
1038
|
+
url=args.login_url,
|
|
1039
|
+
username_selector=args.login_user_selector or 'input[type="email"], input[name="username"], input[name="email"]',
|
|
1040
|
+
password_selector=args.login_pass_selector or 'input[type="password"]',
|
|
1041
|
+
submit_selector=args.login_submit_selector or 'button[type="submit"], input[type="submit"]',
|
|
1042
|
+
username=args.login_username or "",
|
|
1043
|
+
password=args.login_password or "",
|
|
1044
|
+
success_indicator=args.login_success or "/dashboard",
|
|
1045
|
+
totp_secret=args.login_totp_secret,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _build_modules(args) -> list[str] | None:
|
|
1050
|
+
modules = list(args.modules) if args.modules else None
|
|
1051
|
+
if getattr(args, "skip_templates", False):
|
|
1052
|
+
if modules is None:
|
|
1053
|
+
from scanner.core.engine import ALL_MODULES
|
|
1054
|
+
modules = [m.__name__.split(".")[-1] for m in ALL_MODULES if m.__name__.split(".")[-1] != "templates"]
|
|
1055
|
+
else:
|
|
1056
|
+
modules = [m for m in modules if m != "templates"]
|
|
1057
|
+
return modules
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _parse_extensions(raw: str | None) -> list[str] | None:
|
|
1061
|
+
if not raw:
|
|
1062
|
+
return None
|
|
1063
|
+
exts = [e.strip() if e.strip().startswith(".") else f".{e.strip()}" for e in raw.split(",") if e.strip()]
|
|
1064
|
+
return exts or None
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _parse_status_codes(raw: str | None) -> list[int] | None:
|
|
1068
|
+
if not raw:
|
|
1069
|
+
return None
|
|
1070
|
+
codes = []
|
|
1071
|
+
for part in raw.split(","):
|
|
1072
|
+
part = part.strip()
|
|
1073
|
+
if part.isdigit():
|
|
1074
|
+
codes.append(int(part))
|
|
1075
|
+
return codes or None
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _parse_custom_headers(raw: list[str]) -> dict:
|
|
1079
|
+
"""Parse ['-H', 'Name:Value', ...] into a dict."""
|
|
1080
|
+
headers = {}
|
|
1081
|
+
for item in raw:
|
|
1082
|
+
if ":" in item:
|
|
1083
|
+
name, _, value = item.partition(":")
|
|
1084
|
+
headers[name.strip()] = value.strip()
|
|
1085
|
+
return headers
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _auth_display(args) -> tuple[str, str]:
|
|
1089
|
+
if getattr(args, "auth_bearer", None):
|
|
1090
|
+
return "Bearer Token", f"{args.auth_bearer[:8]}…"
|
|
1091
|
+
if getattr(args, "auth_cookie", None):
|
|
1092
|
+
return "Session Cookie", args.auth_cookie.split("=")[0]
|
|
1093
|
+
if getattr(args, "login_url", None):
|
|
1094
|
+
return "Login Flow", args.login_url
|
|
1095
|
+
if getattr(args, "auth_oauth2_token_url", None):
|
|
1096
|
+
return "OAuth2", args.auth_oauth2_token_url
|
|
1097
|
+
return "Unauthenticated", "—"
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _safe_hostname(url: str) -> str:
|
|
1101
|
+
from urllib.parse import urlparse
|
|
1102
|
+
return urlparse(url).hostname or url.replace("://", "_").replace("/", "_")
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ---------------------------------------------------------------------------
|
|
1106
|
+
# export / import-scan
|
|
1107
|
+
# ---------------------------------------------------------------------------
|
|
1108
|
+
|
|
1109
|
+
def _export_scan(scan_id: str, report_path: str, out_path: str) -> None:
|
|
1110
|
+
"""
|
|
1111
|
+
Bundle a scan checkpoint + JSON report into a portable zip.
|
|
1112
|
+
The checkpoint is what --resume reads. The report is the finished output.
|
|
1113
|
+
Together they let someone on another machine either resume or review the scan.
|
|
1114
|
+
"""
|
|
1115
|
+
import zipfile
|
|
1116
|
+
|
|
1117
|
+
checkpoint = os.path.join(tempfile.gettempdir(), f"kagesec_{scan_id}.json")
|
|
1118
|
+
if not os.path.exists(checkpoint):
|
|
1119
|
+
print(f"[!] Checkpoint not found: {checkpoint}")
|
|
1120
|
+
print(f" Make sure scan_id '{scan_id}' was run on this machine.")
|
|
1121
|
+
sys.exit(1)
|
|
1122
|
+
|
|
1123
|
+
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
1124
|
+
# Always include the checkpoint
|
|
1125
|
+
zf.write(checkpoint, arcname=f"kagesec_{scan_id}.json")
|
|
1126
|
+
print(f"[+] Added checkpoint: {checkpoint}")
|
|
1127
|
+
|
|
1128
|
+
# Include report if it exists
|
|
1129
|
+
if os.path.exists(report_path):
|
|
1130
|
+
zf.write(report_path, arcname=os.path.basename(report_path))
|
|
1131
|
+
print(f"[+] Added report: {report_path}")
|
|
1132
|
+
else:
|
|
1133
|
+
print(f"[~] Report not found ({report_path}) — checkpoint only")
|
|
1134
|
+
|
|
1135
|
+
# Metadata so import knows the scan_id without parsing filenames
|
|
1136
|
+
meta = json.dumps({"scan_id": scan_id, "exported_by": "kagesec"})
|
|
1137
|
+
zf.writestr("_meta.json", meta)
|
|
1138
|
+
|
|
1139
|
+
print(f"[+] Exported to: {out_path}")
|
|
1140
|
+
print(f" Transfer this file and run: kagesec import-scan {out_path}")
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _import_scan(zip_path: str) -> None:
|
|
1144
|
+
"""
|
|
1145
|
+
Restore a checkpoint from an exported zip so --resume works on this machine.
|
|
1146
|
+
"""
|
|
1147
|
+
import zipfile
|
|
1148
|
+
|
|
1149
|
+
if not os.path.exists(zip_path):
|
|
1150
|
+
print(f"[!] File not found: {zip_path}")
|
|
1151
|
+
sys.exit(1)
|
|
1152
|
+
|
|
1153
|
+
try:
|
|
1154
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
1155
|
+
names = zf.namelist()
|
|
1156
|
+
|
|
1157
|
+
# Read metadata
|
|
1158
|
+
if "_meta.json" in names:
|
|
1159
|
+
meta = json.loads(zf.read("_meta.json"))
|
|
1160
|
+
scan_id = meta.get("scan_id")
|
|
1161
|
+
else:
|
|
1162
|
+
# Fall back: find checkpoint file by name pattern
|
|
1163
|
+
checkpoints = [n for n in names if n.startswith("kagesec_") and n.endswith(".json")]
|
|
1164
|
+
if not checkpoints:
|
|
1165
|
+
print("[!] No checkpoint found in the zip.")
|
|
1166
|
+
sys.exit(1)
|
|
1167
|
+
scan_id = checkpoints[0].removeprefix("kagesec_").removesuffix(".json")
|
|
1168
|
+
|
|
1169
|
+
# Restore checkpoint
|
|
1170
|
+
checkpoint_name = f"kagesec_{scan_id}.json"
|
|
1171
|
+
if checkpoint_name in names:
|
|
1172
|
+
dest = os.path.join(tempfile.gettempdir(), checkpoint_name)
|
|
1173
|
+
with open(dest, "wb") as f:
|
|
1174
|
+
f.write(zf.read(checkpoint_name))
|
|
1175
|
+
print(f"[+] Checkpoint restored to: {dest}")
|
|
1176
|
+
|
|
1177
|
+
# Restore report if present
|
|
1178
|
+
for name in names:
|
|
1179
|
+
if name.endswith(".json") and name != checkpoint_name and name != "_meta.json":
|
|
1180
|
+
with open(name, "wb") as f:
|
|
1181
|
+
f.write(zf.read(name))
|
|
1182
|
+
print(f"[+] Report restored to: {name}")
|
|
1183
|
+
|
|
1184
|
+
except zipfile.BadZipFile:
|
|
1185
|
+
print(f"[!] Not a valid zip file: {zip_path}")
|
|
1186
|
+
sys.exit(1)
|
|
1187
|
+
|
|
1188
|
+
print()
|
|
1189
|
+
print(f"[+] Scan ID: {scan_id}")
|
|
1190
|
+
print(f" Resume with: kagesec scan <target> --resume {scan_id}")
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
# ---------------------------------------------------------------------------
|
|
1194
|
+
# update-templates
|
|
1195
|
+
# ---------------------------------------------------------------------------
|
|
1196
|
+
|
|
1197
|
+
# Nuclei template keys that KageSec cannot run — filter these out by default
|
|
1198
|
+
_UNSUPPORTED_KEYS = ("flow:", "network:", "dns:", "headless:", "ssl:", "websocket:", "whois:")
|
|
1199
|
+
|
|
1200
|
+
# Directories inside nuclei-templates to skip entirely (non-HTTP content)
|
|
1201
|
+
_SKIP_DIRS = {
|
|
1202
|
+
"dns", "network", "headless", "ssl", "whois",
|
|
1203
|
+
"workflows", ".github", "helpers", "fuzzing",
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _is_compatible(content: bytes) -> bool:
|
|
1208
|
+
"""Return True if a template only uses features KageSec supports."""
|
|
1209
|
+
try:
|
|
1210
|
+
text = content.decode("utf-8", errors="ignore")
|
|
1211
|
+
return not any(key in text for key in _UNSUPPORTED_KEYS)
|
|
1212
|
+
except Exception:
|
|
1213
|
+
return False
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _run_history(args) -> None:
|
|
1217
|
+
from scanner.core.findings_db import (
|
|
1218
|
+
get_scan_history, get_persisting_findings, trending_summary
|
|
1219
|
+
)
|
|
1220
|
+
target = getattr(args, "target", None) or ""
|
|
1221
|
+
|
|
1222
|
+
if getattr(args, "scans", False):
|
|
1223
|
+
rows = get_scan_history(target, limit=args.limit) if target else []
|
|
1224
|
+
if not rows:
|
|
1225
|
+
print("[*] No scan history found.")
|
|
1226
|
+
return
|
|
1227
|
+
print(f"{'Scan ID':<38} {'Target':<35} {'Findings':<10} {'Duration':>10}")
|
|
1228
|
+
print("-" * 95)
|
|
1229
|
+
for r in rows:
|
|
1230
|
+
import datetime
|
|
1231
|
+
ts = datetime.datetime.fromtimestamp(r["started_at"]).strftime("%Y-%m-%d %H:%M")
|
|
1232
|
+
print(f"{r['scan_id']:<38} {r['target']:<35} {r['total_findings']:<10} {r['duration_seconds']:>8.1f}s {ts}")
|
|
1233
|
+
return
|
|
1234
|
+
|
|
1235
|
+
if getattr(args, "persisting", False) and target:
|
|
1236
|
+
rows = get_persisting_findings(target)
|
|
1237
|
+
if not rows:
|
|
1238
|
+
print("[*] No persisting findings found.")
|
|
1239
|
+
return
|
|
1240
|
+
print(f"{'Severity':<10} {'Occurrences':<13} {'Title':<40} URL")
|
|
1241
|
+
print("-" * 100)
|
|
1242
|
+
for r in rows[:args.limit]:
|
|
1243
|
+
print(f"{r['severity'].upper():<10} {r['occurrences']:<13} {r['title'][:38]:<40} {r['url']}")
|
|
1244
|
+
return
|
|
1245
|
+
|
|
1246
|
+
if target:
|
|
1247
|
+
summary = trending_summary(target)
|
|
1248
|
+
print(f"[+] Target: {summary['target']}")
|
|
1249
|
+
print(f"[+] Scans run: {summary['scans_run']}")
|
|
1250
|
+
print(f"[+] Unique findings total: {summary['total_unique_findings']}")
|
|
1251
|
+
print(f"[+] Persisting (multi-scan):{summary['persisting_across_scans']}")
|
|
1252
|
+
print("[+] By severity:")
|
|
1253
|
+
for sev, count in summary["by_severity"].items():
|
|
1254
|
+
if count:
|
|
1255
|
+
print(f" {sev.upper():<12} {count}")
|
|
1256
|
+
else:
|
|
1257
|
+
print("[!] Provide a target URL: kagesec history https://example.com")
|
|
1258
|
+
print(" Options: --scans --persisting --limit N")
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _run_config(args) -> None:
|
|
1262
|
+
pol = _policy.load()
|
|
1263
|
+
|
|
1264
|
+
if getattr(args, "unset", None):
|
|
1265
|
+
for key in args.unset:
|
|
1266
|
+
pol.pop(key, None)
|
|
1267
|
+
_policy.save(pol)
|
|
1268
|
+
print(f"[+] Unset: {', '.join(args.unset)}")
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
if getattr(args, "set", None):
|
|
1272
|
+
for key, raw_val in args.set:
|
|
1273
|
+
# Coerce common types
|
|
1274
|
+
if raw_val.lower() in ("true", "yes", "1"):
|
|
1275
|
+
val = True
|
|
1276
|
+
elif raw_val.lower() in ("false", "no", "0"):
|
|
1277
|
+
val = False
|
|
1278
|
+
else:
|
|
1279
|
+
try:
|
|
1280
|
+
val = int(raw_val)
|
|
1281
|
+
except ValueError:
|
|
1282
|
+
val = raw_val
|
|
1283
|
+
pol[key] = val
|
|
1284
|
+
_policy.save(pol)
|
|
1285
|
+
print(f"[+] Config updated: {_policy._CONFIG_PATH}")
|
|
1286
|
+
_policy.print_policy(_policy.load())
|
|
1287
|
+
return
|
|
1288
|
+
|
|
1289
|
+
_policy.print_policy(pol)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _run_suppress(args) -> None:
|
|
1293
|
+
from scanner.core.suppressions import (
|
|
1294
|
+
add_suppression, remove_suppression, load_suppressions
|
|
1295
|
+
)
|
|
1296
|
+
action = getattr(args, "suppress_action", None)
|
|
1297
|
+
if not action:
|
|
1298
|
+
print("Usage: kagesec suppress <add|list|remove>")
|
|
1299
|
+
print(" add --title PATTERN [--url-pattern GLOB] [--target URL] [--note TEXT]")
|
|
1300
|
+
print(" list")
|
|
1301
|
+
print(" remove RULE_ID")
|
|
1302
|
+
return
|
|
1303
|
+
|
|
1304
|
+
if action == "list":
|
|
1305
|
+
rules = load_suppressions()
|
|
1306
|
+
if not rules:
|
|
1307
|
+
print("[*] No suppression rules configured.")
|
|
1308
|
+
return
|
|
1309
|
+
print(f"{'ID':<10} {'Title contains':<30} {'URL pattern':<25} {'Target':<30} Note")
|
|
1310
|
+
print("-" * 110)
|
|
1311
|
+
for r in rules:
|
|
1312
|
+
print(
|
|
1313
|
+
f"{r.get('id', ''):<10} {(r.get('title_contains') or ''):<30} "
|
|
1314
|
+
f"{(r.get('url_pattern') or ''):<25} "
|
|
1315
|
+
f"{(r.get('target') or ''):<30} {r.get('note') or ''}"
|
|
1316
|
+
)
|
|
1317
|
+
return
|
|
1318
|
+
|
|
1319
|
+
if action == "add":
|
|
1320
|
+
rule = add_suppression(
|
|
1321
|
+
title_contains=getattr(args, "title", None) or "",
|
|
1322
|
+
url_pattern=getattr(args, "url_pattern", None) or "*",
|
|
1323
|
+
target=getattr(args, "target", None) or "",
|
|
1324
|
+
note=getattr(args, "note", None) or "",
|
|
1325
|
+
)
|
|
1326
|
+
print(f"[+] Suppression rule added (ID: {rule['id']})")
|
|
1327
|
+
return
|
|
1328
|
+
|
|
1329
|
+
if action == "remove":
|
|
1330
|
+
removed = remove_suppression(args.rule_id)
|
|
1331
|
+
if removed:
|
|
1332
|
+
print(f"[+] Rule {args.rule_id} removed.")
|
|
1333
|
+
else:
|
|
1334
|
+
print(f"[!] Rule {args.rule_id} not found.")
|
|
1335
|
+
return
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _run_retest(args) -> None:
|
|
1339
|
+
"""Re-run a single finding to verify it still exists (Gap 22)."""
|
|
1340
|
+
import httpx
|
|
1341
|
+
from scanner.core.engine import ALL_MODULES
|
|
1342
|
+
from scanner.core.config import ScanConfig
|
|
1343
|
+
from scanner.core.crawler import CrawlResult
|
|
1344
|
+
|
|
1345
|
+
report_path = getattr(args, "report", "kagesec_report.json")
|
|
1346
|
+
try:
|
|
1347
|
+
with open(report_path) as f:
|
|
1348
|
+
report = json.load(f)
|
|
1349
|
+
except FileNotFoundError:
|
|
1350
|
+
print(f"[!] Report not found: {report_path}")
|
|
1351
|
+
sys.exit(1)
|
|
1352
|
+
except json.JSONDecodeError as e:
|
|
1353
|
+
print(f"[!] Invalid JSON: {e}")
|
|
1354
|
+
sys.exit(1)
|
|
1355
|
+
|
|
1356
|
+
findings = report.get("findings", [])
|
|
1357
|
+
if not findings:
|
|
1358
|
+
print("[!] No findings in report.")
|
|
1359
|
+
sys.exit(1)
|
|
1360
|
+
|
|
1361
|
+
# Resolve finding by index or title substring
|
|
1362
|
+
finding_id = args.finding_id
|
|
1363
|
+
target_finding = None
|
|
1364
|
+
if finding_id.isdigit():
|
|
1365
|
+
idx = int(finding_id)
|
|
1366
|
+
if 0 <= idx < len(findings):
|
|
1367
|
+
target_finding = findings[idx]
|
|
1368
|
+
else:
|
|
1369
|
+
print(f"[!] Index {idx} out of range (report has {len(findings)} findings).")
|
|
1370
|
+
sys.exit(1)
|
|
1371
|
+
elif finding_id.startswith("title:"):
|
|
1372
|
+
pattern = finding_id[6:].lower()
|
|
1373
|
+
matches = [f for f in findings if pattern in f.get("title", "").lower()]
|
|
1374
|
+
if not matches:
|
|
1375
|
+
print(f"[!] No findings matching title '{pattern}'.")
|
|
1376
|
+
sys.exit(1)
|
|
1377
|
+
target_finding = matches[0]
|
|
1378
|
+
if len(matches) > 1:
|
|
1379
|
+
print(f"[~] {len(matches)} findings matched — using first: {target_finding['title']}")
|
|
1380
|
+
else:
|
|
1381
|
+
# Try title substring without prefix
|
|
1382
|
+
pattern = finding_id.lower()
|
|
1383
|
+
matches = [f for f in findings if pattern in f.get("title", "").lower()]
|
|
1384
|
+
if not matches:
|
|
1385
|
+
print(f"[!] No findings matching '{finding_id}'. Use an index (0-N) or 'title:substring'.")
|
|
1386
|
+
sys.exit(1)
|
|
1387
|
+
target_finding = matches[0]
|
|
1388
|
+
|
|
1389
|
+
url = target_finding.get("url", "")
|
|
1390
|
+
title = target_finding.get("title", "")
|
|
1391
|
+
severity = target_finding.get("severity", "")
|
|
1392
|
+
param = target_finding.get("parameter", "")
|
|
1393
|
+
payload = target_finding.get("payload", "")
|
|
1394
|
+
|
|
1395
|
+
print("[*] Retesting finding:")
|
|
1396
|
+
print(f" Title: {title}")
|
|
1397
|
+
print(f" Severity: {severity.upper()}")
|
|
1398
|
+
print(f" URL: {url}")
|
|
1399
|
+
if param:
|
|
1400
|
+
print(f" Parameter: {param}")
|
|
1401
|
+
if payload:
|
|
1402
|
+
print(f" Payload: {payload[:80]}")
|
|
1403
|
+
print()
|
|
1404
|
+
|
|
1405
|
+
# Determine which module to use based on title keywords
|
|
1406
|
+
module_map = {
|
|
1407
|
+
"xss": "xss", "sqli": "sqli", "sql": "sqli", "injection": "sqli",
|
|
1408
|
+
"open redirect": "open_redirect", "redirect": "open_redirect",
|
|
1409
|
+
"csrf": "csrf", "ssrf": "ssrf", "xxe": "xxe",
|
|
1410
|
+
"jwt": "jwt_attacks", "header": "security_headers",
|
|
1411
|
+
"cors": "cors", "directory": "directory_listing",
|
|
1412
|
+
"traversal": "path_traversal", "upload": "file_upload",
|
|
1413
|
+
"deserialization": "deserialization", "graphql": "graphql",
|
|
1414
|
+
"host": "host_header", "csti": "csti",
|
|
1415
|
+
"business": "business_logic", "wizard": "multistep_injection",
|
|
1416
|
+
"blind xss": "blind_xss", "entropy": "session_entropy",
|
|
1417
|
+
"oauth": "oauth",
|
|
1418
|
+
}
|
|
1419
|
+
module_name = None
|
|
1420
|
+
title_lower = title.lower()
|
|
1421
|
+
for keyword, mod in module_map.items():
|
|
1422
|
+
if keyword in title_lower:
|
|
1423
|
+
module_name = mod
|
|
1424
|
+
break
|
|
1425
|
+
|
|
1426
|
+
# Find the module object
|
|
1427
|
+
target_module = None
|
|
1428
|
+
if module_name:
|
|
1429
|
+
for mod in ALL_MODULES:
|
|
1430
|
+
if mod.__name__.split(".")[-1] == module_name:
|
|
1431
|
+
target_module = mod
|
|
1432
|
+
break
|
|
1433
|
+
|
|
1434
|
+
from urllib.parse import urlparse
|
|
1435
|
+
parsed = urlparse(url)
|
|
1436
|
+
base_target = f"{parsed.scheme}://{parsed.netloc}"
|
|
1437
|
+
|
|
1438
|
+
api_key = getattr(args, "api_key", None) or os.getenv("ANTHROPIC_API_KEY")
|
|
1439
|
+
config = ScanConfig(target=base_target, max_depth=1, max_pages=1)
|
|
1440
|
+
config.api_key = api_key if not getattr(args, "no_ai", False) else None
|
|
1441
|
+
|
|
1442
|
+
with httpx.Client(follow_redirects=True, timeout=15, verify=False) as client: # nosec B501
|
|
1443
|
+
try:
|
|
1444
|
+
resp = client.get(url, timeout=10)
|
|
1445
|
+
from bs4 import BeautifulSoup
|
|
1446
|
+
from scanner.core.crawler import CrawlResult
|
|
1447
|
+
soup = BeautifulSoup(resp.text, "html.parser")
|
|
1448
|
+
forms = []
|
|
1449
|
+
for form in soup.find_all("form"):
|
|
1450
|
+
from urllib.parse import urljoin
|
|
1451
|
+
action = urljoin(url, form.get("action", url))
|
|
1452
|
+
inputs = [
|
|
1453
|
+
{"name": inp.get("name", ""), "type": inp.get("type", "text"), "value": inp.get("value", "")}
|
|
1454
|
+
for inp in form.find_all(["input", "textarea", "select"])
|
|
1455
|
+
]
|
|
1456
|
+
forms.append({"action": action, "method": form.get("method", "get").lower(), "inputs": inputs})
|
|
1457
|
+
|
|
1458
|
+
page = CrawlResult(
|
|
1459
|
+
url=url,
|
|
1460
|
+
status_code=resp.status_code,
|
|
1461
|
+
headers=dict(resp.headers),
|
|
1462
|
+
body=resp.text,
|
|
1463
|
+
forms=forms,
|
|
1464
|
+
)
|
|
1465
|
+
except Exception as e:
|
|
1466
|
+
print(f"[!] Could not fetch {url}: {e}")
|
|
1467
|
+
sys.exit(1)
|
|
1468
|
+
|
|
1469
|
+
if target_module:
|
|
1470
|
+
print(f"[*] Running module: {module_name}")
|
|
1471
|
+
try:
|
|
1472
|
+
new_findings = target_module.test(page, client, config)
|
|
1473
|
+
except TypeError:
|
|
1474
|
+
try:
|
|
1475
|
+
new_findings = target_module.test(page, client)
|
|
1476
|
+
except Exception as e2:
|
|
1477
|
+
print(f"[!] Module error: {e2}")
|
|
1478
|
+
new_findings = []
|
|
1479
|
+
else:
|
|
1480
|
+
print("[~] Could not map finding to a specific module — running all active modules")
|
|
1481
|
+
new_findings = []
|
|
1482
|
+
for mod in ALL_MODULES:
|
|
1483
|
+
try:
|
|
1484
|
+
res = mod.test(page, client, config)
|
|
1485
|
+
new_findings.extend(res or [])
|
|
1486
|
+
except Exception:
|
|
1487
|
+
pass
|
|
1488
|
+
|
|
1489
|
+
# Report results
|
|
1490
|
+
if not new_findings:
|
|
1491
|
+
print("\n[+] RESOLVED — Finding no longer detected.")
|
|
1492
|
+
print(" The vulnerability may have been fixed or requires specific conditions.")
|
|
1493
|
+
sys.exit(0)
|
|
1494
|
+
|
|
1495
|
+
matched = [f for f in new_findings if title_lower[:30] in f.title.lower()]
|
|
1496
|
+
if matched:
|
|
1497
|
+
print("\n[!] STILL VULNERABLE — Finding confirmed active.")
|
|
1498
|
+
for f in matched:
|
|
1499
|
+
print(f" [{f.severity.value.upper():<8}] {f.title}")
|
|
1500
|
+
print(f" URL: {f.url}")
|
|
1501
|
+
if f.evidence:
|
|
1502
|
+
print(f" Evidence: {f.evidence[:120]}")
|
|
1503
|
+
else:
|
|
1504
|
+
print(f"\n[~] INCONCLUSIVE — {len(new_findings)} findings on this page but original not re-confirmed.")
|
|
1505
|
+
print(" The finding may require specific payload/session context to reproduce.")
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _run_issues(args) -> None:
|
|
1509
|
+
"""Export findings to Jira or GitHub Issues (Gap 21)."""
|
|
1510
|
+
report_path = getattr(args, "report", "kagesec_report.json")
|
|
1511
|
+
try:
|
|
1512
|
+
with open(report_path) as f:
|
|
1513
|
+
report_data = json.load(f)
|
|
1514
|
+
except FileNotFoundError:
|
|
1515
|
+
print(f"[!] Report not found: {report_path}")
|
|
1516
|
+
sys.exit(1)
|
|
1517
|
+
except json.JSONDecodeError as e:
|
|
1518
|
+
print(f"[!] Invalid JSON: {e}")
|
|
1519
|
+
sys.exit(1)
|
|
1520
|
+
|
|
1521
|
+
min_sev = getattr(args, "min_severity", "medium")
|
|
1522
|
+
sev_order = ["critical", "high", "medium", "low", "info"]
|
|
1523
|
+
threshold_idx = sev_order.index(min_sev)
|
|
1524
|
+
findings = [
|
|
1525
|
+
f for f in report_data.get("findings", [])
|
|
1526
|
+
if sev_order.index(f.get("severity", "info")) <= threshold_idx
|
|
1527
|
+
]
|
|
1528
|
+
|
|
1529
|
+
if not findings:
|
|
1530
|
+
print(f"[*] No findings at {min_sev.upper()} or above to export.")
|
|
1531
|
+
return
|
|
1532
|
+
|
|
1533
|
+
dry_run = getattr(args, "dry_run", False)
|
|
1534
|
+
|
|
1535
|
+
from scanner.core.scan_result import Finding, Severity
|
|
1536
|
+
|
|
1537
|
+
class _MockResult:
|
|
1538
|
+
def __init__(self):
|
|
1539
|
+
self.findings = []
|
|
1540
|
+
self.target = report_data.get("summary", {}).get("target", "unknown")
|
|
1541
|
+
self.compliance_reports = []
|
|
1542
|
+
|
|
1543
|
+
mock = _MockResult()
|
|
1544
|
+
for fd in findings:
|
|
1545
|
+
try:
|
|
1546
|
+
sev = Severity(fd["severity"])
|
|
1547
|
+
except ValueError:
|
|
1548
|
+
sev = Severity.INFO
|
|
1549
|
+
mock.findings.append(Finding(
|
|
1550
|
+
title=fd["title"], severity=sev, url=fd["url"],
|
|
1551
|
+
parameter=fd.get("parameter"), payload=fd.get("payload"),
|
|
1552
|
+
evidence=fd.get("evidence"), description=fd.get("remediation", ""),
|
|
1553
|
+
remediation=fd.get("remediation", ""), cwe=fd.get("cwe", ""),
|
|
1554
|
+
cvss=fd.get("cvss", 0.0), owasp_category=fd.get("owasp_category", ""),
|
|
1555
|
+
confidence=fd.get("confidence", 0.0),
|
|
1556
|
+
))
|
|
1557
|
+
|
|
1558
|
+
if args.format == "jira":
|
|
1559
|
+
if not args.jira_url or not args.jira_project or not args.jira_token:
|
|
1560
|
+
print("[!] Jira export requires --jira-url, --jira-project, and --jira-token")
|
|
1561
|
+
sys.exit(1)
|
|
1562
|
+
from scanner.reporters.jira_reporter import export_to_jira
|
|
1563
|
+
export_to_jira(
|
|
1564
|
+
mock, args.jira_url, args.jira_project, args.jira_token,
|
|
1565
|
+
dry_run=dry_run,
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
elif args.format == "github":
|
|
1569
|
+
if not args.github_repo or not args.github_token:
|
|
1570
|
+
print("[!] GitHub export requires --github-repo and --github-token")
|
|
1571
|
+
sys.exit(1)
|
|
1572
|
+
from scanner.reporters.github_reporter import export_to_github
|
|
1573
|
+
export_to_github(mock, args.github_repo, args.github_token, dry_run=dry_run)
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _update_templates(dest_dir: str, keep_all: bool = False) -> None:
|
|
1577
|
+
import urllib.request
|
|
1578
|
+
import urllib.error
|
|
1579
|
+
import zipfile
|
|
1580
|
+
import io
|
|
1581
|
+
|
|
1582
|
+
NUCLEI_ZIP = "https://github.com/projectdiscovery/nuclei-templates/archive/refs/heads/main.zip"
|
|
1583
|
+
|
|
1584
|
+
print("[*] Downloading Nuclei community templates")
|
|
1585
|
+
print(f" Source: {NUCLEI_ZIP}")
|
|
1586
|
+
print(f" Destination: {dest_dir}")
|
|
1587
|
+
if not keep_all:
|
|
1588
|
+
print(" Filter: compatible templates only (use --all to skip filtering)")
|
|
1589
|
+
print()
|
|
1590
|
+
|
|
1591
|
+
try:
|
|
1592
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
1593
|
+
|
|
1594
|
+
print("[*] Fetching archive (~50 MB) …")
|
|
1595
|
+
req = urllib.request.Request(
|
|
1596
|
+
NUCLEI_ZIP,
|
|
1597
|
+
headers={"User-Agent": "KageSec/1.0 template-updater"},
|
|
1598
|
+
)
|
|
1599
|
+
with urllib.request.urlopen(req, timeout=120) as resp: # nosec B310
|
|
1600
|
+
data = resp.read()
|
|
1601
|
+
print(f"[+] Downloaded {len(data) // 1_048_576} MB")
|
|
1602
|
+
|
|
1603
|
+
saved = skipped_unsupported = skipped_dir = 0
|
|
1604
|
+
|
|
1605
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
1606
|
+
members = [m for m in zf.namelist() if m.endswith(".yaml")]
|
|
1607
|
+
print(f"[*] Processing {len(members):,} YAML files …")
|
|
1608
|
+
|
|
1609
|
+
for member in members:
|
|
1610
|
+
# Strip the top-level repo dir (nuclei-templates-main/...)
|
|
1611
|
+
parts = member.split("/", 1)
|
|
1612
|
+
rel = parts[1] if len(parts) > 1 else member
|
|
1613
|
+
|
|
1614
|
+
# Skip unsupported top-level directories
|
|
1615
|
+
top = rel.split("/")[0].lower()
|
|
1616
|
+
if top in _SKIP_DIRS:
|
|
1617
|
+
skipped_dir += 1
|
|
1618
|
+
continue
|
|
1619
|
+
|
|
1620
|
+
content = zf.read(member)
|
|
1621
|
+
|
|
1622
|
+
if not keep_all and not _is_compatible(content):
|
|
1623
|
+
skipped_unsupported += 1
|
|
1624
|
+
continue
|
|
1625
|
+
|
|
1626
|
+
out_path = os.path.join(dest_dir, rel)
|
|
1627
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
1628
|
+
with open(out_path, "wb") as dst:
|
|
1629
|
+
dst.write(content)
|
|
1630
|
+
saved += 1
|
|
1631
|
+
|
|
1632
|
+
# Save version stamp so auto-update check knows what's installed
|
|
1633
|
+
try:
|
|
1634
|
+
from scanner.core.updater import get_remote_version, save_local_version
|
|
1635
|
+
remote_ver = get_remote_version()
|
|
1636
|
+
if remote_ver:
|
|
1637
|
+
save_local_version(remote_ver)
|
|
1638
|
+
print(f"[+] Version: {remote_ver}")
|
|
1639
|
+
except Exception:
|
|
1640
|
+
pass
|
|
1641
|
+
|
|
1642
|
+
print()
|
|
1643
|
+
print(f"[+] Saved: {saved:,} compatible templates")
|
|
1644
|
+
if skipped_unsupported:
|
|
1645
|
+
print(f"[~] Skipped: {skipped_unsupported:,} unsupported (flow/network/dns/headless) — use --all to keep")
|
|
1646
|
+
if skipped_dir:
|
|
1647
|
+
print(f"[~] Skipped: {skipped_dir:,} from non-HTTP directories (dns/network/ssl/…)")
|
|
1648
|
+
print()
|
|
1649
|
+
print(f"[+] Templates saved to: {dest_dir}")
|
|
1650
|
+
print(f"[+] Use them: kagesec scan <target> --templates {dest_dir}")
|
|
1651
|
+
print("[+] Or make permanent: add to ~/.kagesec/config.yaml (coming soon)")
|
|
1652
|
+
|
|
1653
|
+
except urllib.error.URLError as e:
|
|
1654
|
+
print(f"[!] Network error: {e}")
|
|
1655
|
+
print(" Check your internet connection and try again.")
|
|
1656
|
+
sys.exit(1)
|
|
1657
|
+
except zipfile.BadZipFile:
|
|
1658
|
+
print("[!] Downloaded file is not a valid zip — try again.")
|
|
1659
|
+
sys.exit(1)
|
|
1660
|
+
except Exception as e:
|
|
1661
|
+
print(f"[!] Template update failed: {e}")
|
|
1662
|
+
sys.exit(1)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
if __name__ == "__main__":
|
|
1666
|
+
main()
|