netbear 0.1.3__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.
Files changed (92) hide show
  1. api/__init__.py +1 -0
  2. api/access.py +82 -0
  3. api/app.py +346 -0
  4. api/db.py +79 -0
  5. api/job_store.py +428 -0
  6. api/schemas.py +113 -0
  7. api/static/admin.html +134 -0
  8. api/static/admin.js +57 -0
  9. api/static/dashboard.js +121 -0
  10. api/static/index.html +179 -0
  11. api/static/jobs.html +145 -0
  12. api/static/jobs.js +204 -0
  13. api/static/shared.js +200 -0
  14. api/static/styles.css +773 -0
  15. cli/__init__.py +1 -0
  16. cli/api.py +32 -0
  17. cli/artifacts.py +186 -0
  18. cli/check.py +155 -0
  19. cli/console.py +71 -0
  20. cli/crawl.py +125 -0
  21. cli/init.py +65 -0
  22. cli/main.py +158 -0
  23. cli/replay.py +219 -0
  24. cli/report_utils.py +120 -0
  25. cli/runs.py +85 -0
  26. cli/worker.py +29 -0
  27. core/__init__.py +1 -0
  28. core/auth/__init__.py +1 -0
  29. core/auth/authenticated_endpoint_crawler.py +351 -0
  30. core/auth/orchestrator.py +71 -0
  31. core/auth/session.py +185 -0
  32. core/browser/__init__.py +1 -0
  33. core/browser/fetcher.py +235 -0
  34. core/detect/__init__.py +1 -0
  35. core/detect/detectors.py +148 -0
  36. core/dom/__init__.py +63 -0
  37. core/dom/dom_xss.py +112 -0
  38. core/dom/event_handlers.py +167 -0
  39. core/dom/graph.py +519 -0
  40. core/dom/headers.py +232 -0
  41. core/dom/hidden_fields.py +98 -0
  42. core/dom/intelligence.py +110 -0
  43. core/dom/models.py +355 -0
  44. core/dom/postmessage.py +143 -0
  45. core/dom/prototype.py +60 -0
  46. core/dom/websockets.py +116 -0
  47. core/extract/__init__.py +1 -0
  48. core/extract/endpoint_extractor.py +373 -0
  49. core/extract/har_endpoint_extractor.py +299 -0
  50. core/extract/js_analyser.py +263 -0
  51. core/extract/parser.py +49 -0
  52. core/fingerprint/__init__.py +6 -0
  53. core/fingerprint/active.py +24 -0
  54. core/fingerprint/js_enrichment.py +258 -0
  55. core/fingerprint/passive.py +219 -0
  56. core/fingerprint/profiles.py +95 -0
  57. core/fuzz/__init__.py +1 -0
  58. core/fuzz/fuzzer.py +260 -0
  59. core/models.py +134 -0
  60. core/reporting/__init__.py +25 -0
  61. core/reporting/replay.py +99 -0
  62. core/reporting/reporting.py +666 -0
  63. core/scan/__init__.py +1 -0
  64. core/scan/netbear_crawler.py +767 -0
  65. core/scanning/__init__.py +1 -0
  66. core/scanning/netbear-api-exposure-direct.yaml +41 -0
  67. core/scanning/netbear-auth-bypass-direct.yaml +72 -0
  68. core/scanning/netbear-idor-direct.yaml +43 -0
  69. core/scanning/nuclei_handler.py +717 -0
  70. core/scope/__init__.py +19 -0
  71. core/scope/policy.py +147 -0
  72. core/shared/__init__.py +1 -0
  73. core/shared/domains.py +33 -0
  74. core/shared/network.py +103 -0
  75. core/shared/persistence.py +56 -0
  76. core/shared/utils.py +11 -0
  77. netbear/__init__.py +20 -0
  78. netbear-0.1.3.dist-info/METADATA +204 -0
  79. netbear-0.1.3.dist-info/RECORD +92 -0
  80. netbear-0.1.3.dist-info/WHEEL +5 -0
  81. netbear-0.1.3.dist-info/entry_points.txt +10 -0
  82. netbear-0.1.3.dist-info/licenses/LICENSE +661 -0
  83. netbear-0.1.3.dist-info/top_level.txt +7 -0
  84. services/__init__.py +1 -0
  85. services/crawl_service.py +34 -0
  86. services/har_service.py +46 -0
  87. services/nuclei_service.py +35 -0
  88. templates/__init__.py +1 -0
  89. templates/doctolib_scopes.txt +8 -0
  90. templates/doctolib_targets.txt +10 -0
  91. workers/__init__.py +1 -0
  92. workers/worker.py +89 -0
api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """FastAPI application package for NetBear."""
api/access.py ADDED
@@ -0,0 +1,82 @@
1
+ import json
2
+ import secrets
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ import netbear.config as config
7
+ from netbear.core.shared.utils import ensure_dir, sanitize_filename
8
+
9
+
10
+ def _now() -> str:
11
+ return datetime.now(timezone.utc).isoformat()
12
+
13
+
14
+ def _default_registry() -> dict[str, Any]:
15
+ return {"clients": []}
16
+
17
+
18
+ def load_registry() -> dict[str, Any]:
19
+ ensure_dir(config.APP_DATA_DIR)
20
+ try:
21
+ with open(config.API_KEYS_FILE, encoding="utf-8") as handle:
22
+ data = json.load(handle)
23
+ if isinstance(data, dict) and isinstance(data.get("clients"), list):
24
+ return data
25
+ except FileNotFoundError:
26
+ pass
27
+ except Exception:
28
+ pass
29
+ return _default_registry()
30
+
31
+
32
+ def save_registry(registry: dict[str, Any]) -> None:
33
+ ensure_dir(config.APP_DATA_DIR)
34
+ with open(config.API_KEYS_FILE, "w", encoding="utf-8") as handle:
35
+ json.dump(registry, handle, indent=2)
36
+
37
+
38
+ def get_allowed_api_keys() -> set[str]:
39
+ keys = set(config.API_KEYS)
40
+ registry = load_registry()
41
+ for client in registry.get("clients", []):
42
+ if client.get("enabled", True) and client.get("api_key"):
43
+ keys.add(client["api_key"])
44
+ return keys
45
+
46
+
47
+ def build_client_bundle(
48
+ *,
49
+ client_name: str,
50
+ base_url: str,
51
+ description: str = "",
52
+ scopes: list[str] | None = None,
53
+ commands: list[dict[str, str]] | None = None,
54
+ ) -> dict[str, Any]:
55
+ client_slug = sanitize_filename(client_name.strip().lower()) or "client"
56
+ client_id = f"{client_slug}_{secrets.token_hex(4)}"
57
+ api_key = secrets.token_urlsafe(24)
58
+ created_at = _now()
59
+
60
+ return {
61
+ "client_id": client_id,
62
+ "client_name": client_name,
63
+ "description": description,
64
+ "enabled": True,
65
+ "created_at": created_at,
66
+ "base_url": base_url.rstrip("/"),
67
+ "api_key": api_key,
68
+ "scopes": scopes or [],
69
+ "commands": commands or [],
70
+ }
71
+
72
+
73
+ def register_client_bundle(bundle: dict[str, Any]) -> str:
74
+ registry = load_registry()
75
+ registry.setdefault("clients", []).append(bundle)
76
+ save_registry(registry)
77
+
78
+ ensure_dir(config.CLIENT_BUNDLES_DIR)
79
+ bundle_path = f"{config.CLIENT_BUNDLES_DIR}/{bundle['client_id']}.json"
80
+ with open(bundle_path, "w", encoding="utf-8") as handle:
81
+ json.dump(bundle, handle, indent=2)
82
+ return bundle_path
api/app.py ADDED
@@ -0,0 +1,346 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import Depends, FastAPI, Header, HTTPException
4
+ from fastapi.responses import FileResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+
7
+ import netbear.config as config
8
+ from api.access import get_allowed_api_keys
9
+ from api.db import db
10
+ from api.job_store import create_job_store
11
+ from api.schemas import ArtifactManifest, CrawlJobRequest, CrawlJobResponse
12
+ from api.schemas import Finding as FindingSchema
13
+ from api.schemas import (
14
+ HarExtractRequest,
15
+ HarExtractResponse,
16
+ HealthResponse,
17
+ JobStatusResponse,
18
+ NucleiJobRequest,
19
+ NucleiJobResponse,
20
+ VersionResponse,
21
+ )
22
+ from services.crawl_service import run_crawl_service
23
+ from services.har_service import extract_har_service
24
+ from services.nuclei_service import run_nuclei_service
25
+
26
+ app = FastAPI(title="NetBear API", version="0.1.0")
27
+ jobs = create_job_store()
28
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
29
+ BRANDING_IMAGE = (
30
+ Path(__file__).resolve().parent.parent
31
+ / "insert_operation_images"
32
+ / "IMG_20260401_074509.jpg"
33
+ )
34
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
35
+ REPORTS_ROOT = Path(config.REPORTS_DIR).resolve()
36
+
37
+
38
+ def _build_crawl_response(data: dict) -> CrawlJobResponse:
39
+ artifacts = ArtifactManifest(
40
+ run_dir=data.get("run_dir"),
41
+ summary_path=data.get("summary_path"),
42
+ har_path=data.get("har_path"),
43
+ curl_path=data.get("curl_path"),
44
+ filtered_curl_path=data.get("filtered_curl_path"),
45
+ filtered_summary_path=data.get("filtered_summary_path"),
46
+ fuzzing_report=data.get("fuzzing_report"),
47
+ )
48
+ return CrawlJobResponse(
49
+ run_id=data["run_id"],
50
+ run_dir=data["run_dir"],
51
+ stats=data["stats"],
52
+ targets=data["targets"],
53
+ scopes=data["scopes"],
54
+ domains=data["domains"],
55
+ nuclei_results=data["nuclei_results"],
56
+ artifacts=artifacts,
57
+ )
58
+
59
+
60
+ def require_api_key(
61
+ x_api_key: str | None = Header(default=None, alias=config.API_AUTH_HEADER)
62
+ ):
63
+ allowed_keys = get_allowed_api_keys()
64
+ if not allowed_keys:
65
+ return
66
+ if x_api_key not in allowed_keys:
67
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
68
+
69
+
70
+ def _safe_path(path: str, base_dir: Path) -> Path:
71
+ candidate = Path(path).resolve()
72
+ base_resolved = base_dir.resolve()
73
+ if candidate != base_resolved and base_resolved not in candidate.parents:
74
+ raise HTTPException(
75
+ status_code=403, detail="Artifact path is outside the allowed directory"
76
+ )
77
+ if not candidate.exists() or not candidate.is_file():
78
+ raise HTTPException(status_code=404, detail="Artifact not found")
79
+ return candidate
80
+
81
+
82
+ def _artifact_manifest_for_job(job: dict) -> dict:
83
+ result = job.get("result") or {}
84
+ if job["job_type"] == "crawl":
85
+ artifacts = {
86
+ key: value
87
+ for key, value in {
88
+ "summary_path": result.get("summary_path"),
89
+ "har_path": result.get("har_path"),
90
+ "curl_path": result.get("curl_path"),
91
+ "filtered_curl_path": result.get("filtered_curl_path"),
92
+ "filtered_summary_path": result.get("filtered_summary_path"),
93
+ "fuzzing_report": result.get("fuzzing_report"),
94
+ }.items()
95
+ if value
96
+ }
97
+ run_dir = result.get("run_dir")
98
+ return {"base_dir": run_dir, "artifacts": artifacts}
99
+
100
+ if job["job_type"] == "nuclei":
101
+ output_dir = result.get("output_dir")
102
+ report_dir = result.get("report_dir")
103
+ artifacts = {}
104
+ if output_dir:
105
+ output_path = Path(output_dir)
106
+ for filename in [
107
+ "results.txt",
108
+ "results.json",
109
+ "report.html",
110
+ "targets.txt",
111
+ ]:
112
+ candidate = output_path / filename
113
+ if candidate.exists():
114
+ artifacts[filename] = str(candidate)
115
+ return {"base_dir": output_dir or report_dir, "artifacts": artifacts}
116
+
117
+ return {"base_dir": None, "artifacts": {}}
118
+
119
+
120
+ @app.get("/health", response_model=HealthResponse)
121
+ def health() -> HealthResponse:
122
+ return HealthResponse(status="ok", service="netbear-api")
123
+
124
+
125
+ @app.get("/version", response_model=VersionResponse)
126
+ def version() -> VersionResponse:
127
+ return VersionResponse(service="netbear-api", version="0.1.0")
128
+
129
+
130
+ @app.get("/")
131
+ def dashboard_index():
132
+ return FileResponse(STATIC_DIR / "index.html")
133
+
134
+
135
+ @app.get("/dashboard")
136
+ def dashboard():
137
+ return FileResponse(STATIC_DIR / "index.html")
138
+
139
+
140
+ @app.get("/monitor/jobs")
141
+ def jobs_dashboard():
142
+ return FileResponse(STATIC_DIR / "jobs.html")
143
+
144
+
145
+ @app.get("/monitor/admin")
146
+ def admin_dashboard():
147
+ return FileResponse(STATIC_DIR / "admin.html")
148
+
149
+
150
+ @app.get("/branding/logo")
151
+ def branding_logo():
152
+ if not BRANDING_IMAGE.exists():
153
+ raise HTTPException(status_code=404, detail="Branding image not available")
154
+ return FileResponse(BRANDING_IMAGE)
155
+
156
+
157
+ @app.get("/findings", response_model=list[FindingSchema])
158
+ def list_findings(
159
+ severity: str | None = None, _: None = Depends(require_api_key)
160
+ ) -> list[FindingSchema]:
161
+ with db.connection() as conn:
162
+ with conn.cursor() as cur:
163
+ if severity:
164
+ cur.execute(
165
+ "SELECT * FROM findings WHERE severity = %s ORDER BY created_at DESC",
166
+ (severity,),
167
+ )
168
+ else:
169
+ cur.execute("SELECT * FROM findings ORDER BY created_at DESC")
170
+ rows = cur.fetchall()
171
+ return [
172
+ FindingSchema(
173
+ **{
174
+ **row,
175
+ "id": str(row["id"]),
176
+ "scan_id": str(row["scan_id"]),
177
+ "created_at": row["created_at"].isoformat(),
178
+ }
179
+ )
180
+ for row in rows
181
+ ]
182
+
183
+
184
+ @app.get("/jobs/{job_id}/findings", response_model=list[FindingSchema])
185
+ def list_job_findings(
186
+ job_id: str, _: None = Depends(require_api_key)
187
+ ) -> list[FindingSchema]:
188
+ with db.connection() as conn:
189
+ with conn.cursor() as cur:
190
+ cur.execute(
191
+ "SELECT * FROM findings WHERE scan_id = %s ORDER BY created_at DESC",
192
+ (job_id,),
193
+ )
194
+ rows = cur.fetchall()
195
+ return [
196
+ FindingSchema(
197
+ **{
198
+ **row,
199
+ "id": str(row["id"]),
200
+ "scan_id": str(row["scan_id"]),
201
+ "created_at": row["created_at"].isoformat(),
202
+ }
203
+ )
204
+ for row in rows
205
+ ]
206
+
207
+
208
+ @app.post("/jobs/crawl", response_model=JobStatusResponse)
209
+ def create_crawl_job(
210
+ request: CrawlJobRequest, _: None = Depends(require_api_key)
211
+ ) -> JobStatusResponse:
212
+ job = jobs.create_job("crawl")
213
+ jobs.run_background(
214
+ job["job_id"],
215
+ run_crawl_service,
216
+ request.targets,
217
+ request.scopes,
218
+ request.max_depth,
219
+ request.max_pages_per_domain,
220
+ request.delay_sec,
221
+ request.run_nuclei_after_crawl,
222
+ auth_config=request.auth_config,
223
+ )
224
+ return JobStatusResponse(**job)
225
+
226
+
227
+ @app.post("/jobs/nuclei", response_model=JobStatusResponse)
228
+ def create_nuclei_job(
229
+ request: NucleiJobRequest, _: None = Depends(require_api_key)
230
+ ) -> JobStatusResponse:
231
+ job = jobs.create_job("nuclei")
232
+ jobs.run_background(
233
+ job["job_id"],
234
+ run_nuclei_service,
235
+ request.site_dir,
236
+ request.domain,
237
+ request.report_path,
238
+ request.severity,
239
+ request.templates,
240
+ request.timeout,
241
+ )
242
+ return JobStatusResponse(**job)
243
+
244
+
245
+ @app.get("/jobs/{job_id}", response_model=JobStatusResponse)
246
+ def get_job(job_id: str, _: None = Depends(require_api_key)) -> JobStatusResponse:
247
+ job = jobs.get_job(job_id)
248
+ if not job:
249
+ raise HTTPException(status_code=404, detail="Job not found")
250
+ return JobStatusResponse(**job)
251
+
252
+
253
+ @app.get("/jobs")
254
+ def list_jobs(_: None = Depends(require_api_key)):
255
+ return {"jobs": jobs.list_jobs()}
256
+
257
+
258
+ @app.delete("/jobs")
259
+ def clear_jobs(
260
+ include_running: bool = False,
261
+ force: bool = False,
262
+ _: None = Depends(require_api_key),
263
+ ):
264
+ if include_running and not force:
265
+ raise HTTPException(
266
+ status_code=409, detail="Force confirmation required to clear running jobs"
267
+ )
268
+ deleted = jobs.clear_jobs(include_running=include_running)
269
+ return {"deleted": deleted}
270
+
271
+
272
+ @app.get("/jobs/{job_id}/results")
273
+ def get_job_results(job_id: str, _: None = Depends(require_api_key)):
274
+ job = jobs.get_job(job_id)
275
+ if not job:
276
+ raise HTTPException(status_code=404, detail="Job not found")
277
+ if job["status"] != "completed":
278
+ raise HTTPException(status_code=409, detail="Job has not completed")
279
+
280
+ if job["job_type"] == "crawl":
281
+ return _build_crawl_response(job["result"])
282
+ if job["job_type"] == "nuclei":
283
+ return NucleiJobResponse(**job["result"])
284
+ return job["result"]
285
+
286
+
287
+ @app.delete("/jobs/{job_id}")
288
+ def delete_job(job_id: str, force: bool = False, _: None = Depends(require_api_key)):
289
+ job = jobs.get_job(job_id)
290
+ if not job:
291
+ raise HTTPException(status_code=404, detail="Job not found")
292
+ if job["status"] == "running" and not force:
293
+ raise HTTPException(status_code=409, detail="Cannot delete a running job")
294
+ deleted = jobs.delete_job(job_id)
295
+ return {"deleted": deleted, "job_id": job_id}
296
+
297
+
298
+ @app.get("/jobs/{job_id}/artifacts")
299
+ def list_job_artifacts(job_id: str, _: None = Depends(require_api_key)):
300
+ job = jobs.get_job(job_id)
301
+ if not job:
302
+ raise HTTPException(status_code=404, detail="Job not found")
303
+ if job["status"] != "completed":
304
+ raise HTTPException(status_code=409, detail="Job has not completed")
305
+ return _artifact_manifest_for_job(job)
306
+
307
+
308
+ @app.get("/artifacts/{job_id}/{artifact_name}")
309
+ def download_artifact(
310
+ job_id: str, artifact_name: str, _: None = Depends(require_api_key)
311
+ ):
312
+ job = jobs.get_job(job_id)
313
+ if not job:
314
+ raise HTTPException(status_code=404, detail="Job not found")
315
+ if job["status"] != "completed":
316
+ raise HTTPException(status_code=409, detail="Job has not completed")
317
+
318
+ manifest = _artifact_manifest_for_job(job)
319
+ artifact_path = manifest["artifacts"].get(artifact_name)
320
+ if not artifact_path:
321
+ raise HTTPException(status_code=404, detail="Artifact not available")
322
+
323
+ base_dir = manifest["base_dir"]
324
+ if not base_dir:
325
+ raise HTTPException(
326
+ status_code=404, detail="Artifact base directory unavailable"
327
+ )
328
+
329
+ safe_artifact = _safe_path(artifact_path, Path(base_dir))
330
+ _safe_path(str(safe_artifact), REPORTS_ROOT)
331
+ return FileResponse(safe_artifact)
332
+
333
+
334
+ @app.post("/extract/har", response_model=HarExtractResponse)
335
+ def extract_har(
336
+ request: HarExtractRequest, _: None = Depends(require_api_key)
337
+ ) -> HarExtractResponse:
338
+ result = extract_har_service(
339
+ har_file=request.har_file,
340
+ api_prefix=request.api_prefix,
341
+ status_filter=request.status_filter,
342
+ output_txt=request.output_txt,
343
+ output_json=request.output_json,
344
+ output_report=request.output_report,
345
+ )
346
+ return HarExtractResponse(**result)
api/db.py ADDED
@@ -0,0 +1,79 @@
1
+ import logging
2
+ from collections.abc import Generator
3
+ from contextlib import contextmanager
4
+
5
+ import psycopg
6
+ from psycopg.rows import dict_row
7
+
8
+ import netbear.config as config
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Database:
14
+ def __init__(self, url: str = config.DATABASE_URL):
15
+ self.url = url
16
+ self._pool = None
17
+
18
+ def get_conn(self):
19
+ # In a real production app we'd use a connection pool (psycopg_pool)
20
+ # For V1 modular monolith, we'll start with direct connections or a simple wrapper
21
+ return psycopg.connect(self.url, row_factory=dict_row)
22
+
23
+ @contextmanager
24
+ def connection(self) -> Generator[psycopg.Connection, None, None]:
25
+ conn = self.get_conn()
26
+ try:
27
+ yield conn
28
+ conn.commit()
29
+ except Exception:
30
+ conn.rollback()
31
+ raise
32
+ finally:
33
+ conn.close()
34
+
35
+ def init_db(self):
36
+ """Initialize the database schema."""
37
+ logger.info("Initializing database schema...")
38
+ with self.connection() as conn:
39
+ with conn.cursor() as cur:
40
+ # Jobs table
41
+ cur.execute("""
42
+ CREATE TABLE IF NOT EXISTS jobs (
43
+ job_id UUID PRIMARY KEY,
44
+ job_type TEXT NOT NULL,
45
+ status TEXT NOT NULL,
46
+ created_at TIMESTAMPTZ NOT NULL,
47
+ updated_at TIMESTAMPTZ NOT NULL,
48
+ error TEXT,
49
+ result JSONB,
50
+ task TEXT,
51
+ args JSONB,
52
+ kwargs JSONB
53
+ );
54
+ """)
55
+
56
+ # Findings table (V1 Requirement)
57
+ cur.execute("""
58
+ CREATE TABLE IF NOT EXISTS findings (
59
+ id UUID PRIMARY KEY,
60
+ scan_id UUID REFERENCES jobs(job_id) ON DELETE CASCADE,
61
+ target_url TEXT NOT NULL,
62
+ title TEXT NOT NULL,
63
+ severity TEXT NOT NULL,
64
+ evidence JSONB DEFAULT '[]',
65
+ reproducibility JSONB DEFAULT '{}',
66
+ created_at TIMESTAMPTZ DEFAULT NOW()
67
+ );
68
+ """)
69
+
70
+ # Index for findings
71
+ cur.execute(
72
+ "CREATE INDEX IF NOT EXISTS idx_findings_scan_id ON findings(scan_id);"
73
+ )
74
+ cur.execute(
75
+ "CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);"
76
+ )
77
+
78
+
79
+ db = Database()