@murk-security/runner-linux-arm64 0.0.0-stage → 0.7.0

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 (30) hide show
  1. package/README.md +2 -2
  2. package/bin/harness +0 -0
  3. package/package.json +15 -4
  4. package/runners/basic_harness_run.py +1271 -0
  5. package/runners/checklists/anonymous.json +24 -0
  6. package/runners/cve_feed.json +53 -0
  7. package/runners/prod_env.py +1686 -0
  8. package/runners/profile.py +307 -0
  9. package/runners/recon.py +396 -0
  10. package/runners/remediate.py +429 -0
  11. package/runners/report.py +365 -0
  12. package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
  13. package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
  14. package/runners/skills/exploit/business-logic/SKILL.md +53 -0
  15. package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
  16. package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
  17. package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
  18. package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
  19. package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
  20. package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
  21. package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
  22. package/runners/skills/exploit/service-probing/SKILL.md +51 -0
  23. package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
  24. package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
  25. package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
  26. package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
  27. package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
  28. package/runners/skills/web/content-discovery/SKILL.md +59 -0
  29. package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
  30. package/runners/skills.py +174 -0
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env python3
2
+ """Preliminary-scan ("profile") pre-pass -- Phase 0.
3
+
4
+ A DETERMINISTIC step that runs BEFORE the agent rounds and reuses prod_env's passive
5
+ machinery to produce a profile.json: fingerprint + security-posture FACTS + candidate
6
+ SIGNALS. It is a seed for the orchestrator, NOT a verdict engine -- every signal is an
7
+ observation to confirm, never a finding (see docs/preliminary-scan-plan.md).
8
+
9
+ Gating: this module does nothing unless a driver invokes it. prod_env consumes its
10
+ output ONLY when BLACKGLASS_PROFILE is enabled and BLACKGLASS_PROFILE_PATH points at the
11
+ file -- so with the feature off, the adapter is byte-identical to before.
12
+
13
+ Phase 0 scope: section B of the catalog (security headers / cookie flags / transport),
14
+ plus a light fingerprint. Passive, scope-bound (it only touches the origin, anonymously,
15
+ via prod_env._http). Later phases add exposed-path sweep, version->CVE, the notable-CVE
16
+ feed, safe-active signals, and the source/static track.
17
+
18
+ python3 profile.py --spec input-spec.json [--out profile.json] [--tier passive]
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+ sys.path.insert(0, str(HERE))
33
+ import prod_env # reuse Target + _http (scope-bound, WAF-friendly fetch)
34
+
35
+ # Notable/recent-CVE feed (area: known-CVE / version->CVE track). A separate,
36
+ # refreshable JSON data file -- NOT code -- because flagship threats (PHP2Shell etc.)
37
+ # are always post-model-cutoff; point BLACKGLASS_CVE_FEED at a newer copy to update.
38
+ DEFAULT_CVE_FEED = HERE / "cve_feed.json"
39
+ # Matches "product/1.2.3" tokens in a Server / X-Powered-By banner (>=2 version parts).
40
+ _FP_RE = re.compile(r"([A-Za-z][A-Za-z0-9_+.-]*?)/(\d+(?:\.\d+)+)")
41
+
42
+ # Exposed-path sweep (catalog area C). A CAPPED list of high-signal paths, each with a
43
+ # content SIGNATURE (`must`) the 200 body has to match -- that gate is what gives
44
+ # precision: a soft-404 / SPA catch-all / decoy that returns 200 with unrelated content
45
+ # does NOT flag. Signatures distilled from the nuclei exposures/misconfiguration families
46
+ # (MIT) -- as knowledge, not by running the YAML DSL. Categories drive severity/confidence.
47
+ MAX_SWEEP_PATHS = 40
48
+ EXPOSED_PATHS = [
49
+ {"path": "/.git/config", "cat": "vcs", "must": r"\[core\]|repositoryformatversion|\[remote "},
50
+ {"path": "/.git/HEAD", "cat": "vcs", "must": r"ref:\s*refs/"},
51
+ {"path": "/.svn/entries", "cat": "vcs", "must": r"svn|dir\b|\d+\n"},
52
+ {"path": "/.hg/requires", "cat": "vcs", "must": r"revlog|dotencode|store"},
53
+ {"path": "/.env", "cat": "secrets", "must": r"(?m)^[A-Z0-9_]{2,}="},
54
+ {"path": "/.env.local", "cat": "secrets", "must": r"(?m)^[A-Z0-9_]{2,}="},
55
+ {"path": "/.env.production", "cat": "secrets", "must": r"(?m)^[A-Z0-9_]{2,}="},
56
+ {"path": "/.aws/credentials", "cat": "secrets", "must": r"aws_access_key_id|aws_secret_access_key"},
57
+ {"path": "/config.php.bak", "cat": "secrets", "must": r"<\?php|define\(|DB_"},
58
+ {"path": "/wp-config.php.bak", "cat": "secrets", "must": r"DB_PASSWORD|DB_NAME|AUTH_KEY"},
59
+ {"path": "/backup.sql", "cat": "backup", "must": r"INSERT INTO|CREATE TABLE|-- dump|DROP TABLE"},
60
+ {"path": "/dump.sql", "cat": "backup", "must": r"INSERT INTO|CREATE TABLE|-- dump|DROP TABLE"},
61
+ {"path": "/database.sql", "cat": "backup", "must": r"INSERT INTO|CREATE TABLE|-- dump"},
62
+ {"path": "/server-status", "cat": "debug", "must": r"Apache Server Status|Server uptime|Total Traffic"},
63
+ {"path": "/server-info", "cat": "debug", "must": r"Apache Server Information|Server Settings"},
64
+ {"path": "/phpinfo.php", "cat": "debug", "must": r"phpinfo\(\)|PHP Version|php\.ini"},
65
+ {"path": "/info.php", "cat": "debug", "must": r"phpinfo\(\)|PHP Version"},
66
+ {"path": "/actuator", "cat": "debug", "must": r"_links|/actuator"},
67
+ {"path": "/actuator/env", "cat": "debug", "must": r"propertySources|activeProfiles"},
68
+ {"path": "/actuator/heapdump", "cat": "debug", "must": r"JAVA PROFILE|HPROF"},
69
+ {"path": "/swagger-ui.html", "cat": "api-doc", "must": r"Swagger UI|swagger-ui"},
70
+ {"path": "/swagger.json", "cat": "api-doc", "must": r"\"swagger\"|\"openapi\""},
71
+ {"path": "/openapi.json", "cat": "api-doc", "must": r"\"openapi\"|\"swagger\""},
72
+ {"path": "/v2/api-docs", "cat": "api-doc", "must": r"\"swagger\"|\"openapi\""},
73
+ {"path": "/api-docs", "cat": "api-doc", "must": r"\"swagger\"|\"openapi\"|\"paths\""},
74
+ {"path": "/graphql", "cat": "api", "must": r"__schema|\"data\"|\"errors\"|GraphQL"},
75
+ {"path": "/phpmyadmin/", "cat": "admin", "must": r"phpMyAdmin|pma_"},
76
+ {"path": "/adminer.php", "cat": "admin", "must": r"Adminer|Login - Adminer"},
77
+ {"path": "/.gitlab-ci.yml", "cat": "ci", "must": r"(?m)^stages:|^script:|^image:"},
78
+ {"path": "/Jenkinsfile", "cat": "ci", "must": r"pipeline\s*\{|stage\(|node\s*\{"},
79
+ {"path": "/WEB-INF/web.xml", "cat": "framework", "must": r"<web-app|<servlet"},
80
+ {"path": "/.well-known/security.txt", "cat": "hygiene-positive", "must": r"Contact:|Policy:|Expires:"},
81
+ ]
82
+
83
+ # Security response headers whose ABSENCE is a posture gap (a neutral FACT, not a vuln).
84
+ SECURITY_HEADERS = {
85
+ "strict-transport-security": "HSTS (transport downgrade protection)",
86
+ "content-security-policy": "CSP (XSS/injection mitigation)",
87
+ "x-frame-options": "clickjacking protection (or frame-ancestors in CSP)",
88
+ "x-content-type-options": "MIME-sniffing protection (nosniff)",
89
+ "referrer-policy": "Referrer-Policy",
90
+ "permissions-policy": "Permissions-Policy",
91
+ }
92
+
93
+
94
+ def _lower_keys(headers: dict) -> dict:
95
+ return {str(k).lower(): str(v) for k, v in (headers or {}).items()}
96
+
97
+
98
+ def header_posture(headers: dict) -> tuple[dict, list[dict]]:
99
+ """Return (posture, gaps). posture records each security header present/absent (+
100
+ value); gaps is one signal-shaped dict per MISSING header. Pure parsing -- the same
101
+ input always yields the same output, so a benign (well-secured) response reports no
102
+ gap for a header it actually sets (precision)."""
103
+ low = _lower_keys(headers)
104
+ posture, gaps = {}, []
105
+ for h, desc in SECURITY_HEADERS.items():
106
+ present = h in low
107
+ posture[h] = {"present": present, "value": low.get(h, "")}
108
+ if not present:
109
+ gaps.append({"area": "security-header", "title": f"missing {h}", "detail": desc,
110
+ "confidence": "high", "tier_to_confirm": "passive"})
111
+ # CORS posture (fact only): a reflected-Origin + credentials combo is the dangerous one.
112
+ acao = low.get("access-control-allow-origin")
113
+ acac = low.get("access-control-allow-credentials")
114
+ if acao is not None:
115
+ posture["cors"] = {"allow_origin": acao, "allow_credentials": acac or ""}
116
+ return posture, gaps
117
+
118
+
119
+ def cookie_posture(set_cookie: str) -> list[dict]:
120
+ """Best-effort flags per Set-Cookie value. urllib may join multiple cookies; we scan
121
+ the raw string for the flags, which is enough for a Phase-0 posture note."""
122
+ out = []
123
+ if not set_cookie:
124
+ return out
125
+ for chunk in set_cookie.split(","):
126
+ name = chunk.strip().split("=", 1)[0].strip()
127
+ if not name or " " in name:
128
+ continue
129
+ low = chunk.lower()
130
+ out.append({"name": name, "secure": "secure" in low, "httponly": "httponly" in low,
131
+ "samesite": ("samesite" in low)})
132
+ return out
133
+
134
+
135
+ def exposed_sweep(t) -> tuple[list[dict], list[dict]]:
136
+ """GET each capped path; flag a signal only on 200 AND a content-signature match
137
+ (the precision gate). hygiene-positive hits (security.txt) go to posture, not signals."""
138
+ signals, positives = [], []
139
+ for spec in EXPOSED_PATHS[:MAX_SWEEP_PATHS]:
140
+ r = prod_env._http(t, {"path": spec["path"]})
141
+ if not r.get("ok") or r.get("status") != 200:
142
+ continue
143
+ body = r.get("body") or ""
144
+ if not re.search(spec["must"], body, re.I | re.M):
145
+ continue # 200 but content does not match -> soft-404/decoy; skip (precision)
146
+ if spec["cat"] == "hygiene-positive":
147
+ positives.append({"path": spec["path"], "note": "security.txt present"})
148
+ continue
149
+ signals.append({
150
+ "area": "exposed-path", "category": spec["cat"],
151
+ "title": f"{spec['path']} readable ({spec['cat']})", "path": spec["path"], "status": 200,
152
+ "confidence": "high" if spec["cat"] in ("vcs", "secrets", "backup") else "med",
153
+ "tier_to_confirm": "passive", "snippet": body[:160],
154
+ })
155
+ return signals, positives
156
+
157
+
158
+ def load_cve_feed(path: str | None = None) -> list[dict]:
159
+ """Load the version->CVE feed (list of entries). Missing/bad feed => []."""
160
+ p = Path(path or os.environ.get("BLACKGLASS_CVE_FEED", "") or DEFAULT_CVE_FEED)
161
+ try:
162
+ data = json.loads(p.read_text())
163
+ except Exception:
164
+ return []
165
+ return data.get("entries", []) if isinstance(data, dict) else (data or [])
166
+
167
+
168
+ def _vtuple(v) -> tuple:
169
+ """A dotted version -> tuple of ints ('8.1.2-beta+5' -> (8,1,2)). No digits -> ()."""
170
+ head = re.split(r"[-+ ]", str(v).strip(), 1)[0]
171
+ parts = re.findall(r"\d+", head)
172
+ return tuple(int(x) for x in parts) if parts else ()
173
+
174
+
175
+ def _vcmp(a: tuple, b: tuple) -> int:
176
+ """Compare version tuples with zero-padding so (1,30) == (1,30,0)."""
177
+ n = max(len(a), len(b))
178
+ a = a + (0,) * (n - len(a))
179
+ b = b + (0,) * (n - len(b))
180
+ return (a > b) - (a < b)
181
+
182
+
183
+ def _in_range(v: tuple, rng: dict) -> bool:
184
+ if "ge" in rng and _vcmp(v, _vtuple(rng["ge"])) < 0:
185
+ return False
186
+ if "gt" in rng and _vcmp(v, _vtuple(rng["gt"])) <= 0:
187
+ return False
188
+ if "lt" in rng and _vcmp(v, _vtuple(rng["lt"])) >= 0:
189
+ return False
190
+ if "le" in rng and _vcmp(v, _vtuple(rng["le"])) > 0:
191
+ return False
192
+ return True
193
+
194
+
195
+ def fingerprint_products(headers: dict) -> list[dict]:
196
+ """Extract (product, version) pairs from the Server / X-Powered-By banners."""
197
+ low = _lower_keys(headers)
198
+ out, seen = [], set()
199
+ for src in ("server", "x-powered-by"):
200
+ for m in _FP_RE.finditer(low.get(src, "")):
201
+ prod, ver = m.group(1).lower(), m.group(2)
202
+ if (prod, ver) in seen:
203
+ continue
204
+ seen.add((prod, ver))
205
+ out.append({"product": prod, "version": ver, "source": src})
206
+ return out
207
+
208
+
209
+ def match_cves(products: list[dict], feed: list[dict] | None = None) -> list[dict]:
210
+ """A SIGNAL (not a verdict) per fingerprinted product whose version falls in a
211
+ known-vulnerable range. A version banner is suggestive only (it can be spoofed or
212
+ backport-patched), so confidence is 'med' and confirmation is a separate tier."""
213
+ feed = feed if feed is not None else load_cve_feed()
214
+ sigs = []
215
+ for p in products:
216
+ pv = _vtuple(p.get("version"))
217
+ if not pv:
218
+ continue
219
+ prod = str(p.get("product", "")).lower()
220
+ for e in feed:
221
+ names = [str(x).lower() for x in (e.get("match") or [e.get("product", "")])]
222
+ if prod not in names:
223
+ continue
224
+ if not any(_in_range(pv, r) for r in (e.get("affected") or [])):
225
+ continue
226
+ sigs.append({
227
+ "area": "known-cve", "product": p.get("product"), "version": p.get("version"),
228
+ "source": p.get("source"), "cve": e.get("id"), "aka": e.get("aka", ""),
229
+ "cvss": e.get("cvss"), "title": e.get("title", ""), "published": e.get("published", ""),
230
+ "confidence": "med", # version-fingerprint match, not a confirmed exploit
231
+ "tier_to_confirm": e.get("confirm", "exploit"),
232
+ "detail": (f"{p.get('product')}/{p.get('version')} is within the affected range. "
233
+ + e.get("note", "")
234
+ + " Detection by version fingerprint only; confirmation/exploitation is a separate tiered step."),
235
+ })
236
+ return sigs
237
+
238
+
239
+ def build_profile(spec: dict, tier: str = "passive") -> dict:
240
+ """Build the profile by reusing prod_env's passive fetch against the origin only."""
241
+ t = prod_env.Target(spec, [])
242
+ out = {
243
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
244
+ "tier": tier, "scope": {"origin": t.origin, "root_domain": t.root_domain},
245
+ "target": {}, "posture": {}, "signals": [], "coverage": [],
246
+ "provenance": {"engine": "profile.py", "phase": 0},
247
+ }
248
+ r = prod_env._http(t, {"path": "/", "raw_headers": True})
249
+ if r.get("ok"):
250
+ hdrs = r.get("raw_headers") or r.get("headers") or {}
251
+ low = _lower_keys(hdrs)
252
+ out["target"]["server"] = low.get("server", "")
253
+ out["target"]["x_powered_by"] = low.get("x-powered-by", "")
254
+ out["target"]["status"] = r.get("status")
255
+ posture, gaps = header_posture(hdrs)
256
+ out["posture"]["headers"] = posture
257
+ out["posture"]["cookies"] = cookie_posture(low.get("set-cookie", ""))
258
+ out["signals"].extend(gaps)
259
+ out["coverage"].extend(["security-headers", "cookie-flags", "fingerprint"])
260
+ # Version->CVE track (incl. the notable-CVE feed, e.g. PHP2Shell): fingerprint
261
+ # products from the banners and flag any whose version is in a known range.
262
+ products = fingerprint_products(hdrs)
263
+ if products:
264
+ out["target"]["fingerprints"] = products
265
+ out["signals"].extend(match_cves(products))
266
+ out["coverage"].append("version-cve")
267
+ # Allowed methods (area A): OPTIONS on the root, best-effort (OPTIONS is passive).
268
+ opt = prod_env._http(t, {"path": "/", "method": "OPTIONS", "raw_headers": True})
269
+ allow = _lower_keys(opt.get("raw_headers") or {}).get("allow") if opt.get("ok") else None
270
+ if allow:
271
+ out["target"]["allow_methods"] = allow
272
+ out["coverage"].append("http-methods")
273
+ else:
274
+ out["target"]["error"] = r.get("error")
275
+ # Exposed-path sweep (area C): capped GETs with a content-signature gate for precision.
276
+ sweep_sigs, sweep_pos = exposed_sweep(t)
277
+ out["signals"].extend(sweep_sigs)
278
+ if sweep_pos:
279
+ out["posture"]["hygiene_positives"] = sweep_pos
280
+ out["coverage"].append("exposed-paths")
281
+ https = t.origin.lower().startswith("https")
282
+ out["posture"]["transport"] = {"https": https}
283
+ out["coverage"].append("transport")
284
+ if not https:
285
+ out["signals"].append({"area": "transport", "title": "origin served over plaintext HTTP",
286
+ "detail": "no TLS on the origin", "confidence": "high",
287
+ "tier_to_confirm": "passive"})
288
+ return out
289
+
290
+
291
+ def main() -> int:
292
+ ap = argparse.ArgumentParser()
293
+ ap.add_argument("--spec", required=True)
294
+ ap.add_argument("--out", default="")
295
+ ap.add_argument("--tier", default="passive")
296
+ a = ap.parse_args()
297
+ spec = json.loads(Path(a.spec).read_text())
298
+ prof = build_profile(spec, a.tier)
299
+ text = json.dumps(prof, indent=2)
300
+ if a.out:
301
+ Path(a.out).write_text(text)
302
+ print(text)
303
+ return 0
304
+
305
+
306
+ if __name__ == "__main__":
307
+ raise SystemExit(main())