attackmap 0.6.4__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 (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,365 @@
1
+ """Novel vulnerability-class detectors (#77).
2
+
3
+ Per-file finder (rides the built-in scanner's read loop) for bug classes
4
+ beyond the taint / crypto / web-hardening families. Part 1 covers:
5
+
6
+ prototype_pollution __proto__ writes; deep-merge of a request object
7
+ mass_assignment request object bound wholesale to a model
8
+ jwt_weakness alg=none / signature verification disabled
9
+ xxe XML parser with external entities enabled
10
+ graphql_no_query_limits a GraphQL server built with no depth/complexity
11
+ limit anywhere in the same file (#244)
12
+ graphql_batching Apollo/Yoga HTTP batching switched on (#244)
13
+
14
+ Precision-first: everything anchors on a concrete risky construct (not
15
+ absence), and request-object patterns name the request container
16
+ explicitly. ReDoS, insecure-upload, and GraphQL-exposure classes are
17
+ declared in the model but land in a follow-up.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from .srcpaths import line_number
25
+ from .models import CodeWeakness
26
+ from .taint_sinks import call_receiver
27
+
28
+ _REQ = r"(?:req|request|body|query|params|payload)"
29
+
30
+
31
+ def _rx(pattern: str) -> re.Pattern[str]:
32
+ return re.compile(pattern, re.IGNORECASE | re.MULTILINE)
33
+
34
+
35
+ _JS_BODY_BIND = _rx(
36
+ rf"(?:\.(?:create|update|updateOne|updateMany|insert|save|build)|Object\.assign\s*\([^,]+,)\s*\(?\s*{_REQ}\.body\b"
37
+ )
38
+ # A Sequelize field allow-list in the same call: `.update(req.body, { fields: [...] })`.
39
+ _FIELD_ALLOWLIST = _rx(r"\bfields\s*:\s*\[")
40
+ # A Node crypto hash/HMAC/cipher (or hashlib/CryptoJS/forge) receiver:
41
+ # `crypto.createHmac(…).update(req.body)` digests the body, it doesn't bind it
42
+ # (#330). Same pattern as the `mongo-filter-request` sink's `receiver_unless`.
43
+ _DIGEST_RECEIVER = re.compile(
44
+ r"(?:\bcreate(?:Hmac|Hash|Cipheriv|Decipheriv|Cipher|Decipher|Sign|Verify)|\bhashlib\.\w{1,40}|\bhmac\.new"
45
+ r"|\bCryptoJS\.[\w.]{1,80}|\bforge\.(?:md|hmac)\.[\w.]{1,80})\(\)(?:\.update\(\)){0,8}$"
46
+ )
47
+ _CALL_SCAN_LIMIT = 2000
48
+
49
+ _PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
50
+ # --- prototype pollution (JS/TS) ------------------------------------
51
+ (
52
+ "prototype_pollution",
53
+ "high",
54
+ # Literal __proto__ / constructor.prototype *write* (assignment target).
55
+ # Requiring a trailing `=` (not `==`) avoids flagging prototype-chain
56
+ # *reads* like `x.prototype.__proto__.constructor` (#94).
57
+ _rx(
58
+ r"(?:"
59
+ r"\.__proto__"
60
+ r"|\[\s*['\"]__proto__['\"]\s*\]"
61
+ r"|\[\s*['\"]constructor['\"]\s*\]\s*\[\s*['\"]prototype['\"]\s*\]"
62
+ r"|\.constructor\s*\.\s*prototype"
63
+ r")"
64
+ r"(?:\s*\.\s*\w+|\s*\[[^\]]*\])*" # optional further member/subscript access
65
+ r"\s*=(?!=)"
66
+ ),
67
+ ),
68
+ (
69
+ "prototype_pollution",
70
+ "high",
71
+ # Recursive merge of a request object into a target (lodash / deepmerge).
72
+ _rx(rf"(?:_\.(?:merge|mergeWith|defaultsDeep|set|setWith)|deepmerge)\s*\([^)]*\b{_REQ}\b"),
73
+ ),
74
+ (
75
+ "prototype_pollution",
76
+ "high",
77
+ # jQuery deep extend of a request object: $.extend(true, target, req.*)
78
+ _rx(rf"\$\.extend\s*\(\s*true\b[^)]*\b{_REQ}\b"),
79
+ ),
80
+ # --- mass assignment ------------------------------------------------
81
+ (
82
+ "mass_assignment",
83
+ "high",
84
+ # Python: Model(**request.json) / .objects.create(**request.data) / .update(**req.form)
85
+ _rx(rf"\(\s*\*\*\s*{_REQ}\.(?:json|data|POST|GET|form|args|get_json\s*\(\s*\))"),
86
+ ),
87
+ (
88
+ "mass_assignment",
89
+ "high",
90
+ # JS: ORM-style bind of the whole body — .create(req.body) /
91
+ # .update(req.body) / Object.assign(model, req.body). A Sequelize
92
+ # `{ fields: [...] }` allow-list clears it (see find_code_weaknesses).
93
+ _JS_BODY_BIND,
94
+ ),
95
+ (
96
+ "mass_assignment",
97
+ "high",
98
+ # Mongoose: the body as the *update document* —
99
+ # findByIdAndUpdate(id, req.body) / updateOne(filter, { $set: req.body }).
100
+ _rx(
101
+ r"\.(?:findByIdAndUpdate|findOneAndUpdate|findOneAndReplace|updateOne|updateMany|replaceOne)"
102
+ rf"\s*\([^(),;\n]*(?:\([^()\n]*\)[^(),;\n]*)?,\s*(?:\{{\s*\$set\s*:\s*)?{_REQ}\.body\b"
103
+ ),
104
+ ),
105
+ (
106
+ "mass_assignment",
107
+ "high",
108
+ # JS: `new Model(req.body)` — excluding Web/std builtins that
109
+ # legitimately take a body (Fetch Response/Request, Blob, URL, …).
110
+ _rx(
111
+ rf"new\s+(?!(?:Response|Request|Blob|File|FormData|Headers|URL|URLSearchParams|ReadableStream|Error|Buffer|Uint8Array|TextEncoder|TextDecoder)\b)\w+\s*\(\s*{_REQ}\.body\b"
112
+ ),
113
+ ),
114
+ (
115
+ "mass_assignment",
116
+ "high",
117
+ # Rails strong-params bypass.
118
+ _rx(r"\.permit!\B|params\.permit!"),
119
+ ),
120
+ # --- JWT weaknesses -------------------------------------------------
121
+ (
122
+ "jwt_weakness",
123
+ "high",
124
+ # alg 'none' in an algorithms list (PyJWT + jsonwebtoken).
125
+ _rx(r"algorithms?\s*[=:]\s*\[[^\]]*['\"]none['\"]"),
126
+ ),
127
+ (
128
+ "jwt_weakness",
129
+ "high",
130
+ # signature verification turned off.
131
+ _rx(r"jwt\.decode\s*\([^)]*verify\s*=\s*False|verify_signature['\"]?\s*[:=]\s*(?:false|False)|['\"]alg['\"]?\s*:\s*['\"]none['\"]"),
132
+ ),
133
+ # --- XXE ------------------------------------------------------------
134
+ (
135
+ "xxe",
136
+ "high",
137
+ _rx(
138
+ r"resolve_entities\s*=\s*True"
139
+ r"|\bload_dtd\s*=\s*True"
140
+ r"|libxml_disable_entity_loader\s*\(\s*(?:false|0)\s*\)"
141
+ r"|\.setExpandEntityReferences\s*\(\s*true\s*\)"
142
+ r"|disallow-doctype-decl['\"]?\s*,\s*false"
143
+ r"|FEATURE_SECURE_PROCESSING[^;\n]*false"
144
+ ),
145
+ ),
146
+ # --- ReDoS (catastrophic backtracking) ------------------------------
147
+ # A quantified group whose body also has a quantifier — (a+)+, (.*)*,
148
+ # (\d+)*. Gated to a REGEX context (regex literal, re.* call, or
149
+ # RegExp constructor) so arithmetic like `(x+1)*2` isn't flagged.
150
+ (
151
+ "redos",
152
+ "medium",
153
+ # JS regex literal: /.../ containing a nested quantifier.
154
+ re.compile(r"/[^/\n]*\([^()\n]*[+*][^()\n]*\)[+*][^/\n]*/"),
155
+ ),
156
+ (
157
+ "redos",
158
+ "medium",
159
+ # Python re.* with a nested-quantifier pattern string.
160
+ re.compile(
161
+ r"\bre\.(?:compile|match|search|fullmatch|findall|finditer|sub|subn|split)"
162
+ r"\s*\(\s*[rbuRBU]*['\"][^'\"\n]*\([^()\n]*[+*][^()\n]*\)[+*]"
163
+ ),
164
+ ),
165
+ (
166
+ "redos",
167
+ "medium",
168
+ re.compile(r"\bnew\s+RegExp\s*\(\s*['\"][^'\"\n]*\([^()\n]*[+*][^()\n]*\)[+*]"),
169
+ ),
170
+ # --- insecure file upload -------------------------------------------
171
+ (
172
+ "insecure_upload",
173
+ "high",
174
+ # Werkzeug FileStorage saved with the client-supplied filename
175
+ # (path traversal / overwrite), or Multer originalname into a write.
176
+ _rx(
177
+ r"\.save\s*\([^)]*\.filename\b"
178
+ r"|(?:writeFile|writeFileSync|createWriteStream)\s*\([^)]*\.originalname\b"
179
+ r"|path\.join\s*\([^)]*\.originalname\b"
180
+ ),
181
+ ),
182
+ # --- GraphQL exposure -----------------------------------------------
183
+ (
184
+ "graphql_exposure",
185
+ "low",
186
+ _rx(
187
+ r"\bintrospection\s*:\s*true"
188
+ r"|\bgraphiql\s*[:=]\s*(?:true|True)"
189
+ r"|\bplayground\s*:\s*true"
190
+ ),
191
+ ),
192
+ )
193
+
194
+
195
+ # GraphQL server construction (#244). Limits are usually passed in the same
196
+ # call (`validationRules`, `extensions=[...]`) or applied to the server object
197
+ # in the same file (gqlgen `srv.Use(...)`); any limit marker in the file clears
198
+ # the finding.
199
+ _GRAPHQL_SERVER = _rx(
200
+ r"\bnew\s+ApolloServer\s*\("
201
+ r"|\bgraphqlHTTP\s*\("
202
+ r"|\bcreateYoga\s*\("
203
+ r"|\bmercurius\b"
204
+ r"|\bstrawberry\.Schema\s*\("
205
+ r"|\bGraphQLView\.as_view\s*\("
206
+ r"|\bhandler\.NewDefaultServer\s*\("
207
+ )
208
+ _GRAPHQL_LIMITS = _rx(
209
+ r"depthLimit|depth[_-]limit|QueryDepthLimiter|MaxTokensLimiter|MaxAliasesLimiter"
210
+ r"|ComplexityLimit|complexity[_-]?limit|createComplexityRule|queryComplexity|query[_-]complexity"
211
+ r"|graphql-armor|@escape\.tech|costLimit|cost[_-]?analysis|maxDepth|max_depth|maxAliases|maxTokens"
212
+ r"|validationRules|validation_rules|useDepthLimit|queryDepth|query_depth"
213
+ )
214
+ _GRAPHQL_BATCHING = _rx(r"\ballowBatchedHttpRequests\s*:\s*true\b|\bbatching\s*:\s*(?:true\b|\{)")
215
+
216
+
217
+ def find_graphql_weaknesses(content: str, rel_file: str) -> list[CodeWeakness]:
218
+ """Missing query limits and enabled batching on GraphQL servers built in
219
+ this file (#244)."""
220
+ server = _GRAPHQL_SERVER.search(content)
221
+ if server is None:
222
+ return []
223
+ out: list[CodeWeakness] = []
224
+
225
+ def _add(kind: str, severity: str, offset: int) -> None:
226
+ out.append(
227
+ CodeWeakness(
228
+ kind=kind, # type: ignore[arg-type]
229
+ file=rel_file,
230
+ line=line_number(content, offset),
231
+ evidence_text=_snippet(content, offset),
232
+ severity=severity, # type: ignore[arg-type]
233
+ source_analyzer="weaknesses",
234
+ )
235
+ )
236
+
237
+ if _GRAPHQL_LIMITS.search(content) is None:
238
+ _add("graphql_no_query_limits", "low", server.start())
239
+ for match in _GRAPHQL_BATCHING.finditer(content):
240
+ if not _in_line_comment(content, match.start()):
241
+ _add("graphql_batching", "medium", match.start())
242
+ break
243
+ return out
244
+
245
+
246
+ # Text aimed at an automated reviewer (#233). Unlike the patterns above these
247
+ # live in comments and strings by design, so they skip the comment filter.
248
+ PROMPT_INJECTION_RE = re.compile(
249
+ r"\bignore\s+(?:all\s+|any\s+)?(?:the\s+)?(?:previous|prior|above|earlier)\s+(?:instructions|prompts?|rules|context)"
250
+ r"|\b(?:note|message|instructions?)\s+(?:to|for)\s+(?:the\s+|any\s+|all\s+)?(?:ai|llm|gpt|chatgpt|claude|copilot|codex|assistant|language\s+model)s?\b"
251
+ # Addressing form only — the reviewer noun followed by a colon or comma,
252
+ # as in a salutation. Merely mentioning AI review in a comment is common.
253
+ r"|\b(?:ai|llm)\s+(?:code\s+|security\s+)?(?:reviewers?|auditors?|scanners?|agents?)\s*[:,]"
254
+ r"|\bmark\s+(?:this|these|it|every|all|each)\b[^\n]{0,60}?\b(?:refuted|false[\s-]positive|not\s+vulnerable)\b"
255
+ r"|\bdo\s+not\s+(?:report|flag|mention)\b[^\n]{0,40}?\b(?:this|vulnerabilit\w*|issues?|findings?|backdoor)\b"
256
+ r"|<\|im_(?:start|end)\|>|\[/?INST\]|<</?SYS>>"
257
+ r"|[\u202a-\u202e\u2066-\u2069\u200b\u200c\u2060]",
258
+ re.IGNORECASE,
259
+ )
260
+
261
+
262
+ _INVISIBLE_RE = re.compile(r"[\u202a-\u202e\u2066-\u2069\u200b\u200c\u2060]")
263
+
264
+
265
+ def _injection_evidence(matched: str) -> str:
266
+ if _INVISIBLE_RE.search(matched):
267
+ return "invisible/bidirectional Unicode control character " + matched.encode("unicode_escape").decode("ascii")
268
+ return "text addressed to automated reviewers (content withheld; open the line to review it)"
269
+
270
+
271
+ def find_prompt_injection(content: str, rel_file: str) -> list[CodeWeakness]:
272
+ """Lines carrying text aimed at an automated reviewer, one weakness per line."""
273
+ out: list[CodeWeakness] = []
274
+ seen: set[int] = set()
275
+ for match in PROMPT_INJECTION_RE.finditer(content):
276
+ line = line_number(content, match.start())
277
+ if line in seen:
278
+ continue
279
+ seen.add(line)
280
+ out.append(
281
+ CodeWeakness(
282
+ kind="prompt_injection_attempt",
283
+ file=rel_file,
284
+ line=line,
285
+ # Never echo the injected text itself: evidence flows into LLM
286
+ # passes. Invisible/bidi characters are shown escaped.
287
+ evidence_text=_injection_evidence(match.group(0)),
288
+ severity="low",
289
+ source_analyzer="weaknesses",
290
+ )
291
+ )
292
+ return out
293
+
294
+
295
+ def find_code_weaknesses(content: str, rel_file: str) -> list[CodeWeakness]:
296
+ """Return novel-class weaknesses in one file's ``content`` (deduped by
297
+ (kind, line))."""
298
+ seen: set[tuple[str, int]] = set()
299
+ out: list[CodeWeakness] = []
300
+ for kind, severity, pattern in _PATTERNS:
301
+ for match in pattern.finditer(content):
302
+ if _in_line_comment(content, match.start()):
303
+ continue # a pattern inside a `//` comment isn't real code (#94)
304
+ if pattern is _JS_BODY_BIND and _FIELD_ALLOWLIST.search(_rest_of_call(content, match.end())):
305
+ continue
306
+ if (
307
+ pattern is _JS_BODY_BIND
308
+ and content.startswith(".", match.start())
309
+ and _DIGEST_RECEIVER.search(call_receiver(content, match.start()))
310
+ ):
311
+ continue
312
+ line = line_number(content, match.start())
313
+ key = (kind, line)
314
+ if key in seen:
315
+ continue
316
+ seen.add(key)
317
+ out.append(
318
+ CodeWeakness(
319
+ kind=kind, # type: ignore[arg-type]
320
+ file=rel_file,
321
+ line=line,
322
+ evidence_text=_snippet(content, match.start()),
323
+ severity=severity, # type: ignore[arg-type]
324
+ source_analyzer="weaknesses",
325
+ )
326
+ )
327
+ out.extend(find_graphql_weaknesses(content, rel_file))
328
+ out.extend(find_prompt_injection(content, rel_file))
329
+ return out
330
+
331
+
332
+ def _rest_of_call(content: str, offset: int) -> str:
333
+ """The rest of the call ``offset`` sits inside, up to its closing paren
334
+ (bounded, so a hostile file with no closing paren stays cheap)."""
335
+ depth = 1
336
+ end = min(len(content), offset + _CALL_SCAN_LIMIT)
337
+ for i in range(offset, end):
338
+ ch = content[i]
339
+ if ch in "([{":
340
+ depth += 1
341
+ elif ch in ")]}":
342
+ depth -= 1
343
+ if depth == 0:
344
+ return content[offset:i]
345
+ return content[offset:end]
346
+
347
+
348
+ def _in_line_comment(content: str, offset: int) -> bool:
349
+ """True if ``offset`` falls after a `//` line comment on its line. Cheap
350
+ heuristic (ignores `//` inside strings), used only to drop obvious
351
+ comment-embedded matches like `// x.__proto__.y = z`."""
352
+ line_start = content.rfind("\n", 0, offset) + 1
353
+ return "//" in content[line_start:offset]
354
+
355
+
356
+ def _snippet(content: str, offset: int, radius: int = 120) -> str:
357
+ start = max(0, content.rfind("\n", 0, offset) + 1)
358
+ end = content.find("\n", offset)
359
+ if end == -1:
360
+ end = len(content)
361
+ line = content[start:end].strip()
362
+ return line[:radius] + ("…" if len(line) > radius else "")
363
+
364
+
365
+ __all__ = ["PROMPT_INJECTION_RE", "find_code_weaknesses", "find_graphql_weaknesses", "find_prompt_injection"]