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,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .asset_model import detect_assets
6
+ from .attack_taxonomy import annotate_findings, annotate_insights
7
+ from .control_model import detect_controls
8
+ from .detection_opportunities import generate_detection_opportunities
9
+ from .insights import generate_insights
10
+ from .models import (
11
+ Asset,
12
+ AttackPath,
13
+ AttackSurface,
14
+ Control,
15
+ DetectionOpportunity,
16
+ Finding,
17
+ Insight,
18
+ ScanResult,
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SecurityOverlay:
24
+ """Cross-cutting security view layered on top of raw scan signals.
25
+
26
+ Bundles:
27
+ * the asset inventory (`assets`),
28
+ * the defensive control map (`controls`, present + absent),
29
+ * the set of cross-cutting insights connecting them (`insights`),
30
+ * defender-facing detection-engineering hints (`detection_opportunities`),
31
+ * findings with ATT&CK technique mappings layered in (`findings`).
32
+
33
+ Computed once per analyze run and consumed by the JSON report, the
34
+ markdown review, and the LLM prompt pack.
35
+ """
36
+
37
+ assets: list[Asset]
38
+ controls: list[Control]
39
+ insights: list[Insight]
40
+ detection_opportunities: list[DetectionOpportunity]
41
+ findings: list[Finding]
42
+
43
+
44
+ def build_security_overlay(
45
+ scan: ScanResult,
46
+ attack_surfaces: list[AttackSurface],
47
+ findings: list[Finding],
48
+ attack_paths: list[AttackPath],
49
+ ) -> SecurityOverlay:
50
+ assets = detect_assets(scan)
51
+ controls = detect_controls(scan, attack_surfaces, assets)
52
+ raw_insights = generate_insights(
53
+ scan,
54
+ attack_surfaces,
55
+ [finding.title for finding in findings],
56
+ attack_paths,
57
+ assets,
58
+ controls,
59
+ )
60
+ insights = annotate_insights(raw_insights)
61
+ annotated_findings = annotate_findings(findings)
62
+ detection_opportunities = generate_detection_opportunities(
63
+ insights, annotated_findings, scan, attack_surfaces
64
+ )
65
+ return SecurityOverlay(
66
+ assets=assets,
67
+ controls=controls,
68
+ insights=insights,
69
+ detection_opportunities=detection_opportunities,
70
+ findings=annotated_findings,
71
+ )
attackmap/srcpaths.py ADDED
@@ -0,0 +1,301 @@
1
+ """Source-path classification helpers (#67).
2
+
3
+ A single source of truth for "is this a test/spec file?", used to keep the
4
+ heuristic detection passes (taint, crypto, web-hardening, novel-vuln)
5
+ from flagging patterns that live in test scaffolding — a recurring
6
+ false-positive source on real repos (e.g. `salt = Math.random()` inside a
7
+ crypto package's own `*.test.ts`, or an `exec` in an integration test).
8
+
9
+ Set ``ATTACKMAP_INCLUDE_TESTS=1`` to opt back in (e.g. for supply-chain /
10
+ test-quality reviews where test code is in scope).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import bisect
16
+ import functools
17
+ import os
18
+ import re
19
+
20
+ # Any path segment equal to one of these marks the file as test code.
21
+ # Every JavaScript/TypeScript source suffix the core scans (#227). Shared by
22
+ # the scanner, analyzers, taint, authz and fusion passes so none of them
23
+ # silently drops ES-module or CommonJS files.
24
+ JS_TS_SUFFIXES = frozenset({".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"})
25
+
26
+ _TEST_DIR_SEGMENTS = frozenset(
27
+ {
28
+ "tests", "test", "__tests__", "spec", "specs", "testing", "e2e", "__mocks__", "fixtures",
29
+ # Test-only packages by name (#345): gitea's `models/unittest` (its mock
30
+ # HTTP server and test DB), civetweb's `unittest/`, Go's `testdata/`
31
+ # (ignored by the go tool), and helper packages only tests import.
32
+ # Deliberately not `mock`/`mocks`: an app can serve a real `/api/mock`
33
+ # route from `app/api/mock/` (dub), and mock-server products ship
34
+ # their engine there.
35
+ "unittest", "unittests", "unit_tests", "testdata",
36
+ "testutil", "testutils", "test_utils", "testhelpers", "test_helpers",
37
+ }
38
+ )
39
+
40
+ # Filename shapes across ecosystems: foo.test.ts, foo.spec.js, foo_test.go,
41
+ # test_foo.py, foo_spec.rb, conftest.py, FooTest.java, FooTests.cs.
42
+ _TEST_FILE_RE = re.compile(
43
+ r"""
44
+ (?:\.(?:test|spec)\.[a-z0-9]+$) # foo.test.ts / foo.spec.js
45
+ | (?:_(?:test|spec)\.[a-z0-9]+$) # foo_test.go / foo_spec.rb
46
+ | (?:^test_[^/]*\.py$) # test_foo.py
47
+ | (?:^conftest\.py$) # pytest conftest
48
+ | (?:Tests?\.(?:java|kt|cs)$) # FooTest.java / FooTests.cs
49
+ """,
50
+ re.IGNORECASE | re.VERBOSE,
51
+ )
52
+
53
+
54
+ def is_test_file(rel_path: str) -> bool:
55
+ """Return True if ``rel_path`` looks like test/spec/fixture code.
56
+
57
+ Honors ``ATTACKMAP_INCLUDE_TESTS`` — when set (to any non-empty
58
+ value), nothing is treated as a test file, so the heuristic passes
59
+ scan test code too.
60
+ """
61
+ if os.environ.get("ATTACKMAP_INCLUDE_TESTS"):
62
+ return False
63
+ norm = rel_path.replace("\\", "/")
64
+ parts = norm.split("/")
65
+ # Any parent directory named like a test dir.
66
+ if any(segment.lower() in _TEST_DIR_SEGMENTS for segment in parts[:-1]):
67
+ return True
68
+ return _TEST_FILE_RE.search(parts[-1]) is not None
69
+
70
+
71
+ # Conventional infrastructure / static routes that don't run application logic
72
+ # on untrusted input: robots.txt, sitemap, favicon, `.well-known/*`, health /
73
+ # liveness / readiness probes, metrics, ping/version/status. A taint chain or
74
+ # exploitability score anchored on one of these is almost always import-walk
75
+ # over-linking (the handler returns static bytes and never touches the sink),
76
+ # so the detectors skip them. Central home so taint (#85) and exploitability
77
+ # (#79) share one definition.
78
+ _INFRA_ROUTE_RE = re.compile(
79
+ r"""
80
+ ^/?(?:
81
+ robots\.txt$ | sitemap(?:\.xml)?$ | favicon\.ico$ | \.well-known(?:/|$)
82
+ | (?:.*/)?_?health(?:z|check)?$ | (?:.*/)?livez$ | (?:.*/)?readyz$
83
+ | metrics$ | ping$ | version$ | status$
84
+ )
85
+ """,
86
+ re.IGNORECASE | re.VERBOSE,
87
+ )
88
+
89
+
90
+ def is_infra_route(path: str) -> bool:
91
+ """Return True if ``path`` is a conventional infra/static endpoint.
92
+
93
+ Honors ``ATTACKMAP_INCLUDE_INFRA_ROUTES`` — set it to scan these anyway
94
+ (e.g. auditing a health endpoint that really does hit a datastore)."""
95
+ if os.environ.get("ATTACKMAP_INCLUDE_INFRA_ROUTES"):
96
+ return False
97
+ return _INFRA_ROUTE_RE.search(path) is not None
98
+
99
+
100
+ # Directory segments that hold third-party / vendored code the project doesn't
101
+ # maintain. Flagging weaknesses here is noise about someone else's dependency.
102
+ _VENDORED_DIR_SEGMENTS = frozenset(
103
+ {
104
+ "node_modules",
105
+ "bower_components",
106
+ "vendor",
107
+ "vendored",
108
+ "third_party",
109
+ "third-party",
110
+ "thirdparty",
111
+ "external",
112
+ "externals",
113
+ "site-packages",
114
+ "jspm_packages",
115
+ }
116
+ )
117
+
118
+ # Minified / bundled build artifacts and generated TypeScript declaration
119
+ # stubs (`*.d.ts`) — not human-authored, executable source.
120
+ _MINIFIED_FILE_RE = re.compile(
121
+ r"\.min\.(?:js|css|mjs|cjs)$|\.bundle\.js$|\.d\.ts$", re.IGNORECASE
122
+ )
123
+
124
+
125
+ def is_vendored_file(rel_path: str) -> bool:
126
+ """Return True if ``rel_path`` is vendored third-party or minified/bundled
127
+ code (e.g. ``UserInterface/External/three.js``, ``vendor/…``, ``*.min.js``).
128
+
129
+ Honors ``ATTACKMAP_INCLUDE_VENDORED`` to scan it anyway (e.g. auditing a
130
+ pinned/forked dependency)."""
131
+ if os.environ.get("ATTACKMAP_INCLUDE_VENDORED"):
132
+ return False
133
+ norm = rel_path.replace("\\", "/")
134
+ parts = norm.split("/")
135
+ if any(segment.lower() in _VENDORED_DIR_SEGMENTS for segment in parts[:-1]):
136
+ return True
137
+ return _MINIFIED_FILE_RE.search(parts[-1]) is not None
138
+
139
+
140
+ __all__ = ["is_test_file", "is_infra_route", "is_vendored_file"]
141
+
142
+
143
+ # --- Locations cited in evidence text (#213, #214) ---------------------------
144
+ #
145
+ # Fallback for findings that don't carry structured `Finding.locations` (e.g.
146
+ # from third-party plugins): lift `file[:line]` out of evidence prose. One
147
+ # shared implementation for SARIF and suppression so the two can't drift.
148
+
149
+ _EXTENSIONLESS_FILES = frozenset(
150
+ {"Dockerfile", "Containerfile", "Makefile", "Jenkinsfile", "Procfile", "Gemfile", "Rakefile", "Vagrantfile"}
151
+ )
152
+ _PATH_CHARS = r"[\w@()\[\]./\\+~-]"
153
+ # `src/app.py:12 — …` or `[src/app.py:12]`. The path starts at a token
154
+ # boundary and is matched possessively (`:` isn't a path char), so long
155
+ # bracket/paren runs stay linear (#236 regex budget).
156
+ _LEADING_LOC = re.compile(rf"(?<!{_PATH_CHARS})(?P<path>{_PATH_CHARS}++):(?P<line>\d+)\b")
157
+ # `… in src/app.py`, `… at lib/x.hpp:7`, `… in `Dockerfile``.
158
+ _PROSE_LOC = re.compile(rf"\b(?:in|at)\s+`?(?P<path>{_PATH_CHARS}++)`?(?::(?P<line>\d+))?")
159
+ _HAS_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,10}$")
160
+
161
+
162
+ def _clean_candidate(path: str) -> str | None:
163
+ path = path.replace("\\", "/")
164
+ # A leading bracket/paren that opens a `[file:line]` wrapper, not a
165
+ # route-group segment like `(auth)/page.tsx`.
166
+ while path[:1] in "[(" and path.count(path[0]) > path.count("]" if path[0] == "[" else ")"):
167
+ path = path[1:]
168
+ # Trailing sentence punctuation, and a closing paren/bracket that isn't
169
+ # part of a balanced route-group segment like `(auth)`.
170
+ while path and path[-1] in ".,;:":
171
+ path = path[:-1]
172
+ while path.endswith(")") and path.count("(") < path.count(")"):
173
+ path = path[:-1]
174
+ while path.endswith("]") and path.count("[") < path.count("]"):
175
+ path = path[:-1]
176
+ if not path or "://" in path or path.startswith(("/", "~")):
177
+ return None
178
+ name = path.rsplit("/", 1)[-1]
179
+ if name in _EXTENSIONLESS_FILES or name.split(".", 1)[0] in _EXTENSIONLESS_FILES:
180
+ return path
181
+ if not _HAS_EXTENSION.search(name) or name.startswith("."):
182
+ return path if name.startswith(".env") else None
183
+ # Route paths (`/users/x`) start with `/` and were rejected above; a bare
184
+ # dotted word like `v1.2` has no slash and a digit-only extension.
185
+ if "/" not in path and re.search(r"\.\d+$", name):
186
+ return None
187
+ return path
188
+
189
+
190
+ def evidence_locations(evidence: list[str]) -> list[tuple[str, int | None]]:
191
+ """Ordered, de-duplicated ``(file, line)`` pairs cited in evidence lines."""
192
+ out: list[tuple[str, int | None]] = []
193
+ seen: set[tuple[str, int | None]] = set()
194
+ for text in evidence:
195
+ positioned: list[tuple[int, str, int | None]] = []
196
+ for match in _LEADING_LOC.finditer(text):
197
+ path = _clean_candidate(match.group("path"))
198
+ if path:
199
+ positioned.append((match.start("path"), path, int(match.group("line"))))
200
+ for match in _PROSE_LOC.finditer(text):
201
+ path = _clean_candidate(match.group("path"))
202
+ if path:
203
+ line = match.group("line")
204
+ positioned.append((match.start("path"), path, int(line) if line else None))
205
+ # Text order, so the first cited location is the primary one.
206
+ found = [(path, line) for _, path, line in sorted(positioned, key=lambda t: t[0])]
207
+ for item in found:
208
+ # Prefer the line-bearing form when the same file is cited both ways.
209
+ if item[1] is None and any(f == item[0] and l is not None for f, l in found):
210
+ continue
211
+ if item not in seen:
212
+ seen.add(item)
213
+ out.append(item)
214
+ # Across evidence lines too: drop a line-less citation of a file that is
215
+ # also cited with a line (e.g. "import path: app.py" next to "sink at
216
+ # app.py:23"), so a finding never carries a redundant `line: null` location.
217
+ with_line = {path for path, line in out if line is not None}
218
+ return [(path, line) for path, line in out if line is not None or path not in with_line]
219
+
220
+
221
+ # --- Directories never scanned (#215) ----------------------------------------
222
+ #
223
+ # One set for every core walker. Matched against directory names *relative to
224
+ # the scanned root* — never against the absolute path, so a checkout that
225
+ # lives under e.g. /opt/atlassian/pipelines/agent/build is still scanned.
226
+ SKIP_DIRS = frozenset(
227
+ {
228
+ "node_modules",
229
+ ".git",
230
+ ".attackmap", # CLI cache / output dir
231
+ ".attackmap-gui", # the macOS GUI's report output dir
232
+ ".venv",
233
+ "venv",
234
+ "__pycache__",
235
+ "dist",
236
+ "build",
237
+ ".next",
238
+ ".turbo",
239
+ "out",
240
+ "target",
241
+ ".tox",
242
+ }
243
+ )
244
+
245
+
246
+ def is_skipped_dir(name: str) -> bool:
247
+ """`walk_repo(prune=...)` callback: never descend into these."""
248
+ return name in SKIP_DIRS
249
+
250
+
251
+ def in_skipped_dir(rel: "str | os.PathLike[str]") -> bool:
252
+ """True when a *repo-relative* file path sits under a skipped directory."""
253
+ from pathlib import PurePath
254
+
255
+ return any(part in SKIP_DIRS for part in PurePath(rel).parts[:-1])
256
+
257
+
258
+ # --- Offset -> line number (#218) ---------------------------------------------
259
+ #
260
+ # `content.count("\n", 0, offset)` is O(offset) per call; detectors call it once
261
+ # per match, which is O(n^2) on a large file with many matches (a 3 MB file took
262
+ # 34 s, 90% of it here). The line-start index is built once per file content
263
+ # and looked up by binary search.
264
+
265
+ @functools.lru_cache(maxsize=8)
266
+ def _line_starts(content: str) -> tuple[int, ...]:
267
+ starts = [0]
268
+ find = content.find
269
+ pos = find("\n")
270
+ while pos != -1:
271
+ starts.append(pos + 1)
272
+ pos = find("\n", pos + 1)
273
+ return tuple(starts)
274
+
275
+
276
+ def line_number(content: str, offset: int) -> int:
277
+ """1-indexed line number of ``offset`` within ``content``."""
278
+ if offset <= 0:
279
+ return 1
280
+ return bisect.bisect_right(_line_starts(content), offset)
281
+
282
+
283
+ def split_lines(content: str, *, keepends: bool = False) -> list[str]:
284
+ """``content`` as a list of lines, split on ``"\n"`` only, so that index
285
+ ``n - 1`` is the line ``line_number()`` calls ``n``.
286
+
287
+ Never ``str.splitlines()`` for anything that is numbered or indexed by a
288
+ line number: it also breaks on ``\x0b``, ``\x0c``, ``\x1c``-``\x1e``,
289
+ ``\x85``, U+2028 and U+2029, so one such character in a comment shifts
290
+ every later line by one relative to the findings, which let a pull
291
+ request move a trusted ``attackmap:ignore`` directive onto the line it
292
+ added (GHSA-677m-4884-vg22).
293
+ """
294
+ lines = content.split("\n")
295
+ if lines and lines[-1] == "":
296
+ lines.pop()
297
+ if keepends:
298
+ last = len(lines) - 1
299
+ ends_with_newline = content.endswith("\n")
300
+ return [line + "\n" if i < last or ends_with_newline else line for i, line in enumerate(lines)]
301
+ return lines