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