redforge-sec 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
redforge/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """RedForge — Security Operations Platform.
2
+
3
+ A legitimate offensive + defensive security platform: penetration testing,
4
+ vulnerability assessment, red-team automation, bug-bounty tooling, compliance
5
+ auditing, and defensive monitoring. Scope is strictly limited to assets the
6
+ user owns or is explicitly authorized to test.
7
+ """
8
+
9
+ __version__ = "0.2.0"
redforge/cli.py ADDED
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env python3
2
+ """RedForge command-line interface.
3
+
4
+ Usage:
5
+ redforge recon subdomains <domain> --scope scope.json
6
+ redforge recon headers <url> --scope scope.json
7
+ redforge recon ports <host> --scope scope.json
8
+ redforge vuln cve <name>
9
+ redforge vuln config <file>
10
+ redforge vuln tls <host>
11
+ redforge compliance cis
12
+ redforge compliance password <password>
13
+ redforge defense logs <file>
14
+ redforge defense ioc <text>
15
+ redforge report <target> --scope scope.json
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import sys
22
+
23
+ from .core.config import Config
24
+ from .core.report import build_markdown_report, write_report
25
+ from .core.store import Store
26
+
27
+
28
+ def _clean_output(findings: list, target: str, tool: str) -> list:
29
+ """Attach target/tool and drop internal keys for display."""
30
+ out = []
31
+ for f in findings:
32
+ f = dict(f)
33
+ f.setdefault("target", target)
34
+ f.setdefault("tool", tool)
35
+ out.append(f)
36
+ return out
37
+
38
+
39
+ def cmd_recon(args, cfg: Config):
40
+ tool = args.sub
41
+ if tool == "subdomains":
42
+ from .recon import enum_subdomains, resolve_subdomains
43
+ cfg.scope.require(args.domain)
44
+ subs = enum_subdomains(args.domain)
45
+ print(f"Found {len(subs)} subdomains for {args.domain}")
46
+ for s in subs:
47
+ print(" " + s)
48
+ if args.resolve:
49
+ resolved = resolve_subdomains(subs)
50
+ for r in resolved:
51
+ print(f" → {r['host']} = {r['ip']}")
52
+ elif tool == "headers":
53
+ from .recon import scan_headers
54
+ import urllib.parse
55
+ host = urllib.parse.urlparse(args.url).netloc
56
+ cfg.scope.require(host)
57
+ findings = _clean_output(scan_headers(args.url), host, "header-scan")
58
+ _print_findings(findings)
59
+ if args.save and cfg.db_path:
60
+ _save(args, findings, cfg)
61
+ elif tool == "ports":
62
+ from .recon import scan_common_ports
63
+ cfg.scope.require(args.host)
64
+ ports = scan_common_ports(args.host, banner=getattr(args, "banner", False))
65
+ for p in ports:
66
+ b = f" [{p.get('banner', '')}]" if p.get("banner") else ""
67
+ print(f" {p['port']:<6} {p['service']}{b}")
68
+ print(f"Open ports: {len(ports)}")
69
+ elif tool == "dns":
70
+ from .recon import resolve_records, enum_subdomains_robust, brute_subdomains
71
+ cfg.scope.require(args.domain)
72
+ records = resolve_records(args.domain)
73
+ for rtype, vals in records.items():
74
+ if vals:
75
+ print(f"{rtype}: {', '.join(vals)}")
76
+ subs = enum_subdomains_robust(args.domain)
77
+ print(f"Subdomains ({len(subs)}): {', '.join(subs[:20])}")
78
+ if getattr(args, "brute", False):
79
+ print("Brute-forcing common subdomains...")
80
+ brute = brute_subdomains(args.domain)
81
+ print(f"Brute found ({len(brute)}): {', '.join(brute)}")
82
+ elif tool == "endpoints":
83
+ import urllib.parse
84
+ if "://" not in args.url:
85
+ host = args.url
86
+ url = f"https://{args.url}"
87
+ else:
88
+ host = urllib.parse.urlparse(args.url).netloc
89
+ url = args.url
90
+ cfg.scope.require(host)
91
+ from .recon import discover_endpoints
92
+ found = discover_endpoints(url)
93
+ for e in found:
94
+ print(f" {e['status']:<4} {e['path']} ({e['size']} bytes)")
95
+ print(f"Endpoints found: {len(found)}")
96
+ elif tool == "takeover":
97
+ from .recon import detect_takeover
98
+ cfg.scope.require(args.host)
99
+ findings = detect_takeover(args.host)
100
+ _print_findings(findings)
101
+ else:
102
+ print(f"Unknown recon tool: {tool}")
103
+ sys.exit(2)
104
+
105
+
106
+ def cmd_vuln(args, cfg: Config):
107
+ tool = args.sub
108
+ if tool == "cve":
109
+ from .vuln import lookup_cve, search_cves
110
+ result = lookup_cve(args.name)
111
+ if result:
112
+ print(f"{result['cve']} — {args.name} [{result['severity']}]")
113
+ print(f" Affected: {result['affected']}")
114
+ print(f" Note: {result['note']}")
115
+ print(f" Remediation: {result['remediation']}")
116
+ else:
117
+ matches = search_cves(args.name)
118
+ if matches:
119
+ print(f"No exact match. Similar CVEs:")
120
+ for m in matches:
121
+ print(f" {m['name']} ({m['cve']}) [{m['severity']}]")
122
+ else:
123
+ print(f"Unknown CVE: {args.name}")
124
+ elif tool == "config":
125
+ from .vuln import audit_config
126
+ findings = _clean_output(audit_config(args.file), args.file, "config-audit")
127
+ _print_findings(findings)
128
+ if args.save:
129
+ _save(args, findings, cfg)
130
+ elif tool == "tls":
131
+ from .vuln import scan_tls
132
+ cfg.scope.require(args.host)
133
+ findings = _clean_output(scan_tls(args.host, args.port), args.host, "tls-scan")
134
+ _print_findings(findings)
135
+ if args.save:
136
+ _save(args, findings, cfg)
137
+ elif tool == "secrets":
138
+ from .vuln import scan_repo
139
+ findings = _clean_output(scan_repo(args.path), args.path, "secret-scan")
140
+ _print_findings(findings)
141
+ if args.save:
142
+ _save(args, findings, cfg)
143
+ else:
144
+ print(f"Unknown vuln tool: {tool}")
145
+ sys.exit(2)
146
+
147
+
148
+ def cmd_pentest(args, cfg: Config):
149
+ tool = args.sub
150
+ if tool == "cors":
151
+ from .pentest import CorsScanner
152
+ import urllib.parse
153
+ host = urllib.parse.urlparse(args.url).netloc or args.url
154
+ cfg.scope.require(host)
155
+ findings = CorsScanner(args.url, host, cfg.scope.targets).scan()
156
+ _print_findings(findings)
157
+ elif tool == "verbs":
158
+ from .pentest import HttpVerbTester
159
+ import urllib.parse
160
+ host = urllib.parse.urlparse(args.url).netloc or args.url
161
+ cfg.scope.require(host)
162
+ findings = HttpVerbTester(args.url, host, cfg.scope.targets).scan()
163
+ _print_findings(findings)
164
+ elif tool == "jwt":
165
+ from .pentest import JwtAuditor
166
+ findings = JwtAuditor(args.token).audit()
167
+ _print_findings(findings)
168
+ elif tool == "redir":
169
+ from .pentest import OpenRedirectProber
170
+ payloads = OpenRedirectProber().payloads()
171
+ for k, v in payloads.items():
172
+ print(f" {k}: {v}")
173
+ print("Inert payload strings — apply manually to authorized targets.")
174
+ elif tool == "ssrf":
175
+ from .pentest import SsrfProber
176
+ if args.url:
177
+ import urllib.parse
178
+ host = urllib.parse.urlparse(args.url).netloc or args.url
179
+ cfg.scope.require(host)
180
+ hits = SsrfProber.find_candidate_params(args.url)
181
+ if hits:
182
+ print("SSRF-prone parameters found:")
183
+ for h in hits:
184
+ print(f" ?{h['param']}={h['value']}")
185
+ else:
186
+ print("No SSRF-prone parameters detected in the query string.")
187
+ print("\nSSRF payload candidates (inert, manual review):")
188
+ for k, v in SsrfProber().payloads().items():
189
+ print(f" {k}: {v}")
190
+ elif tool == "ratelimit":
191
+ from .pentest import RateLimitTester
192
+ import urllib.parse
193
+ host = urllib.parse.urlparse(args.url).netloc or args.url
194
+ cfg.scope.require(host)
195
+ result = RateLimitTester(args.url, host, cfg.scope.targets).test()
196
+ _print_findings(result["findings"])
197
+ print(f"Statuses observed: {result['statuses']}")
198
+ elif tool == "idor":
199
+ from .pentest import IdorGuide
200
+ guide = IdorGuide(getattr(args, "base_id", "1000"))
201
+ print(guide.guidance())
202
+ print(f"\nCandidate IDs: {', '.join(guide.adjacent_ids())}")
203
+ if getattr(args, "url", None):
204
+ print("Candidate URLs (manual review only):")
205
+ for u in guide.candidate_urls(args.url):
206
+ print(f" {u}")
207
+ elif tool == "hdrinj":
208
+ from .pentest import HeaderInjectionTester
209
+ import urllib.parse
210
+ host = urllib.parse.urlparse(args.url).netloc or args.url
211
+ cfg.scope.require(host)
212
+ tester = HeaderInjectionTester(args.url, host, cfg.scope.targets)
213
+ print("CRLF payloads (inert):")
214
+ for p in tester.payloads():
215
+ print(f" {p}")
216
+ print("\nProbing header reflection...")
217
+ _print_findings(tester.probe_reflection())
218
+ elif tool == "graphql":
219
+ from .pentest import GraphqlIntrospector
220
+ import urllib.parse
221
+ host = urllib.parse.urlparse(args.url).netloc or args.url
222
+ cfg.scope.require(host)
223
+ endpoint = args.url if args.url.endswith("/graphql") else f"{args.url.rstrip('/')}/graphql"
224
+ _print_findings(GraphqlIntrospector(endpoint, host, cfg.scope.targets).probe())
225
+ elif tool == "hosthdr":
226
+ from .pentest import HostHeaderTester
227
+ import urllib.parse
228
+ host = urllib.parse.urlparse(args.url).netloc or args.url
229
+ cfg.scope.require(host)
230
+ _print_findings(HostHeaderTester(args.url, host, cfg.scope.targets).scan())
231
+ elif tool == "cachep":
232
+ from .pentest import CachePoisonTester
233
+ import urllib.parse
234
+ host = urllib.parse.urlparse(args.url).netloc or args.url
235
+ cfg.scope.require(host)
236
+ result = CachePoisonTester(args.url, host, cfg.scope.targets).probe()
237
+ _print_findings(result["findings"])
238
+ print(f"Statuses: base={result.get('base_status')} busted={result.get('busted_status')}")
239
+ elif tool == "backups":
240
+ from .pentest import BackupFileFinder
241
+ import urllib.parse
242
+ host = urllib.parse.urlparse(args.url).netloc or args.url
243
+ cfg.scope.require(host)
244
+ findings = BackupFileFinder(args.url, host, cfg.scope.targets).scan()
245
+ _print_findings(findings)
246
+ print(f"{len(findings)} backup candidates found.")
247
+ else:
248
+ print(f"Unknown pentest tool: {tool}")
249
+ sys.exit(2)
250
+
251
+
252
+ def cmd_compliance(args, cfg: Config):
253
+ if args.sub == "cis":
254
+ from .compliance import run_cis_checks
255
+ findings = _clean_output(run_cis_checks(), "localhost", "cis")
256
+ _print_findings(findings)
257
+ if args.save:
258
+ _save(args, findings, cfg)
259
+ elif args.sub == "password":
260
+ from .compliance import check_password_strength
261
+ r = check_password_strength(args.password)
262
+ print(f"Score: {r['score']}/9 Strength: {r['strength']}")
263
+ for issue in r["issues"]:
264
+ print(f" ! {issue}")
265
+ else:
266
+ print(f"Unknown compliance tool: {args.sub}")
267
+ sys.exit(2)
268
+
269
+
270
+ def cmd_defense(args, cfg: Config):
271
+ if args.sub == "logs":
272
+ from .defense import analyze_log_file
273
+ findings = _clean_output(analyze_log_file(args.file), "<logs>", "log-analyzer")
274
+ _print_findings(findings)
275
+ if args.save:
276
+ _save(args, findings, cfg)
277
+ elif args.sub == "ioc":
278
+ from .defense import match_iocs
279
+ findings = _clean_output(match_iocs(args.text), "<text>", "ioc-matcher")
280
+ _print_findings(findings)
281
+ else:
282
+ print(f"Unknown defense tool: {args.sub}")
283
+ sys.exit(2)
284
+
285
+
286
+ def cmd_report(args, cfg: Config):
287
+ store = Store(cfg.db_path)
288
+ findings = store.findings(target=args.target)
289
+ if not findings:
290
+ print(f"No findings stored for {args.target}. Run a scan with --save first.")
291
+ sys.exit(1)
292
+ if args.format == "csv":
293
+ from .core.report import export_csv
294
+ path = f"{cfg.report_dir}/{args.target.replace('/', '_')}-report.csv"
295
+ write_report(path, export_csv(findings))
296
+ print(f"CSV report written: {path}")
297
+ store.close()
298
+ return
299
+ if args.format == "json":
300
+ from .core.report import export_json
301
+ path = f"{cfg.report_dir}/{args.target.replace('/', '_')}-report.json"
302
+ write_report(path, export_json(findings))
303
+ print(f"JSON report written: {path}")
304
+ store.close()
305
+ return
306
+ md = build_markdown_report(
307
+ args.target, findings,
308
+ scope_note=cfg.scope.note,
309
+ engagement=args.engagement,
310
+ )
311
+ path = f"{cfg.report_dir}/{args.target.replace('/', '_')}-report.md"
312
+ p = write_report(path, md)
313
+ print(f"Report written: {p}")
314
+ store.close()
315
+
316
+
317
+ def cmd_scan(args, cfg: Config):
318
+ from .core.runner import run_against
319
+ tools = [t.strip() for t in args.tools.split(",") if t.strip()]
320
+ try:
321
+ result = run_against(args.target, tools, cfg)
322
+ except PermissionError as e:
323
+ print(f"BLOCKED: {e}")
324
+ sys.exit(3)
325
+ print(f"Scan complete: {result['target']} ({', '.join(result['tools'])})")
326
+ print(f" Findings saved: {result['findings_saved']}")
327
+ print(f" Report: {result['report_path']}")
328
+
329
+
330
+ def cmd_schedule(args, cfg: Config):
331
+ from .core.scheduler import ScanScheduler, make_job
332
+ from .core.runner import run_against
333
+ tools = [t.strip() for t in args.tools.split(",") if t.strip()]
334
+ # Verify scope up-front so a misconfigured target fails fast
335
+ try:
336
+ run_against(args.target, tools, cfg)
337
+ except PermissionError as e:
338
+ print(f"BLOCKED: {e}")
339
+ sys.exit(3)
340
+ job = make_job(args.target, tools, cfg)
341
+ sched = ScanScheduler(args.interval, job, max_iterations=args.runs)
342
+ print(f"Scheduling scan of {args.target} every {args.interval}s "
343
+ f"{'(max ' + str(args.runs) + ' runs)' if args.runs else '(until interrupted)'}...")
344
+ try:
345
+ sched.run_forever()
346
+ except KeyboardInterrupt:
347
+ print("\nStopped.")
348
+
349
+
350
+ def _save(args, findings: list, cfg: Config):
351
+ store = Store(cfg.db_path)
352
+ target = getattr(args, "target", None) or "local"
353
+ for f in findings:
354
+ store.add_finding(
355
+ target=target,
356
+ tool=f.get("tool", "unknown"),
357
+ severity=f.get("severity", "info"),
358
+ title=f.get("title", ""),
359
+ detail=f.get("detail", ""),
360
+ evidence=f.get("evidence", ""),
361
+ remediation=f.get("remediation", ""),
362
+ )
363
+ store.close()
364
+ print(f"Saved {len(findings)} findings to {cfg.db_path}")
365
+
366
+
367
+ def _print_findings(findings: list):
368
+ if not findings:
369
+ print("No findings.")
370
+ return
371
+ for f in findings:
372
+ sev = f.get("severity", "info").upper()
373
+ print(f"[{sev}] {f.get('title')}")
374
+ if f.get("detail"):
375
+ print(f" {f['detail']}")
376
+ if f.get("remediation"):
377
+ print(f" Fix: {f['remediation']}")
378
+
379
+
380
+ def build_parser() -> argparse.ArgumentParser:
381
+ parser = argparse.ArgumentParser(prog="redforge", description="Security Operations Platform")
382
+ sub = parser.add_subparsers(dest="command", required=True)
383
+
384
+ p_recon = sub.add_parser("recon", help="Reconnaissance tools")
385
+ recon_sub = p_recon.add_subparsers(dest="sub", required=True)
386
+ p_sub = recon_sub.add_parser("subdomains", help="Enumerate subdomains (crt.sh)")
387
+ p_sub.add_argument("domain")
388
+ p_sub.add_argument("--resolve", action="store_true")
389
+ p_hdr = recon_sub.add_parser("headers", help="Scan HTTP security headers")
390
+ p_hdr.add_argument("url")
391
+ p_hdr.add_argument("--save", action="store_true")
392
+ p_hdr.add_argument("--target")
393
+ p_port = recon_sub.add_parser("ports", help="Scan common ports")
394
+ p_port.add_argument("host")
395
+ p_port.add_argument("--banner", action="store_true", help="Grab service banners")
396
+ p_dns = recon_sub.add_parser("dns", help="Enumerate DNS records + subdomains")
397
+ p_dns.add_argument("domain")
398
+ p_dns.add_argument("--brute", action="store_true", help="Brute-force common subdomains")
399
+ p_ep = recon_sub.add_parser("endpoints", help="Discover common endpoints")
400
+ p_ep.add_argument("url")
401
+ p_tk = recon_sub.add_parser("takeover", help="Detect potential subdomain takeover")
402
+ p_tk.add_argument("host")
403
+
404
+ p_vuln = sub.add_parser("vuln", help="Vulnerability assessment")
405
+ vuln_sub = p_vuln.add_subparsers(dest="sub", required=True)
406
+ p_cve = vuln_sub.add_parser("cve", help="Look up a CVE")
407
+ p_cve.add_argument("name")
408
+ p_cfg = vuln_sub.add_parser("config", help="Audit a config file")
409
+ p_cfg.add_argument("file")
410
+ p_cfg.add_argument("--save", action="store_true")
411
+ p_tls = vuln_sub.add_parser("tls", help="Scan TLS config")
412
+ p_tls.add_argument("host")
413
+ p_tls.add_argument("--port", type=int, default=443)
414
+ p_tls.add_argument("--save", action="store_true")
415
+ p_sec = vuln_sub.add_parser("secrets", help="Recursively scan a repo for secrets")
416
+ p_sec.add_argument("path")
417
+ p_sec.add_argument("--save", action="store_true")
418
+
419
+ p_comp = sub.add_parser("compliance", help="Compliance auditing")
420
+ comp_sub = p_comp.add_subparsers(dest="sub", required=True)
421
+ p_cis = comp_sub.add_parser("cis", help="Run CIS-style checks")
422
+ p_cis.add_argument("--save", action="store_true")
423
+ p_pw = comp_sub.add_parser("password", help="Check password strength")
424
+ p_pw.add_argument("password")
425
+
426
+ p_pt = sub.add_parser("pentest", help="Web attack-surface testing (authorized)")
427
+ pt_sub = p_pt.add_subparsers(dest="sub", required=True)
428
+ p_cors = pt_sub.add_parser("cors", help="Scan CORS misconfiguration")
429
+ p_cors.add_argument("url")
430
+ p_verbs = pt_sub.add_parser("verbs", help="Test HTTP methods")
431
+ p_verbs.add_argument("url")
432
+ p_jwt = pt_sub.add_parser("jwt", help="Audit a JWT token")
433
+ p_jwt.add_argument("token")
434
+ p_redir = pt_sub.add_parser("redir", help="Generate open-redirect payloads")
435
+ p_redir.add_argument("--url", help="Optional target base (informational)")
436
+ p_ssrf = pt_sub.add_parser("ssrf", help="SSRF payloads + URL param detection")
437
+ p_ssrf.add_argument("--url", help="Target URL to analyze for SSRF-prone params")
438
+ p_rl = pt_sub.add_parser("ratelimit", help="Bounded rate-limit assessment")
439
+ p_rl.add_argument("url")
440
+ p_idor = pt_sub.add_parser("idor", help="IDOR candidate generation (guidance)")
441
+ p_idor.add_argument("--url", help="Base API URL for candidate review")
442
+ p_idor.add_argument("--base-id", default="1000", help="Known object ID")
443
+ p_hdr = pt_sub.add_parser("hdrinj", help="Header-injection payloads + reflection probe")
444
+ p_hdr.add_argument("url")
445
+ p_gql = pt_sub.add_parser("graphql", help="GraphQL introspection check")
446
+ p_gql.add_argument("url")
447
+ p_hh = pt_sub.add_parser("hosthdr", help="Host-header / cache-poisoning signals")
448
+ p_hh.add_argument("url")
449
+ p_cp = pt_sub.add_parser("cachep", help="Cache-key poisoning probe")
450
+ p_cp.add_argument("url")
451
+ p_bk = pt_sub.add_parser("backups", help="Probe for exposed backup files")
452
+ p_bk.add_argument("url")
453
+
454
+ p_def = sub.add_parser("defense", help="Defensive monitoring")
455
+ def_sub = p_def.add_subparsers(dest="sub", required=True)
456
+ p_logs = def_sub.add_parser("logs", help="Analyze an access log")
457
+ p_logs.add_argument("file")
458
+ p_logs.add_argument("--save", action="store_true")
459
+ p_ioc = def_sub.add_parser("ioc", help="Match IOCs in text")
460
+ p_ioc.add_argument("text")
461
+
462
+ p_rep = sub.add_parser("report", help="Generate a report")
463
+ p_rep.add_argument("target")
464
+ p_rep.add_argument("--engagement", default="")
465
+ p_rep.add_argument("--format", choices=["md", "csv", "json"], default="md")
466
+
467
+ p_scan = sub.add_parser("scan", help="Run a combined scan against a scoped target")
468
+ p_scan.add_argument("target")
469
+ p_scan.add_argument("--tools", default="headers,ports,tls",
470
+ help="Comma-separated: headers,ports,tls,cis,dns,endpoints,takeover,cors,verbs")
471
+
472
+ p_sched = sub.add_parser("schedule", help="Run a scan on an interval")
473
+ p_sched.add_argument("target")
474
+ p_sched.add_argument("--tools", default="headers,ports,tls")
475
+ p_sched.add_argument("--interval", type=int, default=3600, help="Seconds between runs (>=5)")
476
+ p_sched.add_argument("--runs", type=int, default=None, help="Max runs")
477
+
478
+ parser.add_argument("--scope", help="Path to scope.json")
479
+ parser.add_argument("--db", help="SQLite db path")
480
+ return parser
481
+
482
+
483
+ def main(argv=None):
484
+ parser = build_parser()
485
+ args = parser.parse_args(argv)
486
+ cfg = Config.load(args.scope)
487
+ if args.db:
488
+ cfg.db_path = args.db
489
+
490
+ if args.command == "recon":
491
+ cmd_recon(args, cfg)
492
+ elif args.command == "vuln":
493
+ cmd_vuln(args, cfg)
494
+ elif args.command == "compliance":
495
+ cmd_compliance(args, cfg)
496
+ elif args.command == "pentest":
497
+ cmd_pentest(args, cfg)
498
+ elif args.command == "defense":
499
+ cmd_defense(args, cfg)
500
+ elif args.command == "report":
501
+ cmd_report(args, cfg)
502
+ elif args.command == "scan":
503
+ cmd_scan(args, cfg)
504
+ elif args.command == "schedule":
505
+ cmd_schedule(args, cfg)
506
+ else:
507
+ parser.print_help()
508
+ sys.exit(2)
509
+
510
+
511
+ if __name__ == "__main__":
512
+ main()
@@ -0,0 +1,6 @@
1
+ """Compliance auditing module."""
2
+
3
+ from .cis import run_cis_checks
4
+ from .password import check_password_strength
5
+
6
+ __all__ = ["run_cis_checks", "check_password_strength"]
@@ -0,0 +1,103 @@
1
+ """CIS-style benchmark checks for the user's own systems.
2
+
3
+ Runs a set of read-only configuration and system checks against the local
4
+ host (the operator's own machine/server) to assess baseline hardening against
5
+ common CIS benchmark controls. Read-only — no changes are made.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import stat
13
+ from pathlib import Path
14
+ from typing import List
15
+
16
+
17
+ def _check_file_perms(path: str, mode: int) -> bool:
18
+ try:
19
+ st = os.stat(path)
20
+ return (stat.S_IMODE(st.st_mode) & mode) == 0
21
+ except OSError:
22
+ return True # file absent = not a finding
23
+
24
+
25
+ def _is_installed(bin: str) -> bool:
26
+ return shutil.which(bin) is not None
27
+
28
+
29
+ def run_cis_checks() -> List[dict]:
30
+ """Evaluate a battery of CIS-style hardening checks on the local host."""
31
+ findings = []
32
+
33
+ # World-writable sensitive files
34
+ for p in ["/etc/passwd", "/etc/shadow", "/etc/ssh/sshd_config",
35
+ os.path.expanduser("~/.ssh/authorized_keys")]:
36
+ if os.path.exists(p) and _check_file_perms(p, stat.S_IWOTH):
37
+ findings.append({
38
+ "tool": "cis",
39
+ "severity": "medium",
40
+ "title": f"World-writable file: {p}",
41
+ "detail": f"{p} is writable by other users.",
42
+ "remediation": f"chmod 600/644 {p}",
43
+ })
44
+
45
+ # Root login via SSH
46
+ sshd = "/etc/ssh/sshd_config"
47
+ if os.path.exists(sshd):
48
+ try:
49
+ cfg = Path(sshd).read_text()
50
+ if "PermitRootLogin yes" in cfg and not cfg.lower().startswith("#"):
51
+ findings.append({
52
+ "tool": "cis",
53
+ "severity": "high",
54
+ "title": "SSH root login permitted",
55
+ "detail": "PermitRootLogin is set to yes.",
56
+ "remediation": "Set PermitRootLogin no (or prohibit-password).",
57
+ })
58
+ except OSError:
59
+ pass
60
+
61
+ # Firewall present
62
+ if not (_is_installed("ufw") or _is_installed("firewalld") or _is_installed("iptables")):
63
+ findings.append({
64
+ "tool": "cis",
65
+ "severity": "medium",
66
+ "title": "No firewall tool detected",
67
+ "detail": "None of ufw/firewalld/iptables found on PATH.",
68
+ "remediation": "Enable a host firewall.",
69
+ })
70
+
71
+ # Passwordless sudo (indicative)
72
+ sudoers = "/etc/sudoers"
73
+ if os.path.exists(sudoers):
74
+ try:
75
+ if "NOPASSWD" in Path(sudoers).read_text():
76
+ findings.append({
77
+ "tool": "cis",
78
+ "severity": "medium",
79
+ "title": "Passwordless sudo configured",
80
+ "detail": "NOPASSWD directive found in sudoers.",
81
+ "remediation": "Require password for sudo.",
82
+ })
83
+ except OSError:
84
+ pass
85
+
86
+ # Core dump limit
87
+ if not os.path.exists("/proc/sys/fs/suid_dumpable"):
88
+ pass # non-Linux; skip
89
+ else:
90
+ try:
91
+ val = Path("/proc/sys/fs/suid_dumpable").read_text().strip()
92
+ if val != "0":
93
+ findings.append({
94
+ "tool": "cis",
95
+ "severity": "info",
96
+ "title": "SUID core dumps enabled",
97
+ "detail": f"suid_dumpable = {val}.",
98
+ "remediation": "Set fs.suid_dumpable = 0.",
99
+ })
100
+ except OSError:
101
+ pass
102
+
103
+ return findings