attackmap 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {attackmap-0.3.0/src/attackmap.egg-info → attackmap-0.3.2}/PKG-INFO +3 -3
- {attackmap-0.3.0 → attackmap-0.3.2}/README.md +2 -2
- {attackmap-0.3.0 → attackmap-0.3.2}/pyproject.toml +1 -1
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/__init__.py +1 -1
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/scanner.py +22 -4
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/srcpaths.py +38 -1
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/taint.py +64 -1
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/weaknesses.py +23 -2
- {attackmap-0.3.0 → attackmap-0.3.2/src/attackmap.egg-info}/PKG-INFO +3 -3
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_scanner.py +20 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_srcpaths.py +63 -1
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_taint.py +76 -3
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_weaknesses.py +21 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/LICENSE +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/setup.cfg +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzers.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/anomalies.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/authz.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/cli.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/control_model.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/crypto.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/cve.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/diff.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/exploitability.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/graph.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/insights.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/llm_review.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/merge.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/models.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/progress.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/report.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_json.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sarif.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sbom.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/suggest.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/threat_model.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/topology.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/SOURCES.txt +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/requires.txt +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzer.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzers.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_anomalies.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_authz.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_config_scanner.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_context_pack.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_crypto.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_cve.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_defensive_review.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_diagrams.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_diff.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_exploitability.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_graph.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_hunt.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_llm_review.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_merge.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_progress.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_provenance.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_report.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_eval.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_json.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_prompts.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_sarif.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_sbom.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_security_overlay.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_signal_v2.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_suggest.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_threat_intel.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_threat_model.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_topology.py +0 -0
- {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_webhardening.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: attackmap
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
|
|
5
5
|
Author: AttackMap Contributors
|
|
6
6
|
Author-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
@@ -75,7 +75,7 @@ managers who need to triage an unfamiliar codebase.
|
|
|
75
75
|
[](https://www.python.org/)
|
|
76
76
|
[](https://pypi.org/project/attackmap/)
|
|
77
77
|
|
|
78
|
-
> **Status: beta (v0.3.
|
|
78
|
+
> **Status: beta (v0.3.2).** Core engine and 14 analyzer plugins are published
|
|
79
79
|
> to PyPI, Homebrew, and GHCR and validated against real-world codebases.
|
|
80
80
|
> AttackMap is heuristic by design — findings are confidence-tiered evidence,
|
|
81
81
|
> not proof. See [Project status](#project-status) for what's solid and what's
|
|
@@ -541,7 +541,7 @@ introduces a new HIGH finding.
|
|
|
541
541
|
|
|
542
542
|
## Project status
|
|
543
543
|
|
|
544
|
-
AttackMap is **beta** (v0.3.
|
|
544
|
+
AttackMap is **beta** (v0.3.2) — published and validated on real codebases, but
|
|
545
545
|
pre-1.0 and heuristic.
|
|
546
546
|
|
|
547
547
|
**Solid today:**
|
|
@@ -16,7 +16,7 @@ managers who need to triage an unfamiliar codebase.
|
|
|
16
16
|
[](https://www.python.org/)
|
|
17
17
|
[](https://pypi.org/project/attackmap/)
|
|
18
18
|
|
|
19
|
-
> **Status: beta (v0.3.
|
|
19
|
+
> **Status: beta (v0.3.2).** Core engine and 14 analyzer plugins are published
|
|
20
20
|
> to PyPI, Homebrew, and GHCR and validated against real-world codebases.
|
|
21
21
|
> AttackMap is heuristic by design — findings are confidence-tiered evidence,
|
|
22
22
|
> not proof. See [Project status](#project-status) for what's solid and what's
|
|
@@ -482,7 +482,7 @@ introduces a new HIGH finding.
|
|
|
482
482
|
|
|
483
483
|
## Project status
|
|
484
484
|
|
|
485
|
-
AttackMap is **beta** (v0.3.
|
|
485
|
+
AttackMap is **beta** (v0.3.2) — published and validated on real codebases, but
|
|
486
486
|
pre-1.0 and heuristic.
|
|
487
487
|
|
|
488
488
|
**Solid today:**
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.3.
|
|
2
|
+
__version__ = "0.3.2"
|
|
@@ -14,7 +14,7 @@ from .anomalies import find_anomalies
|
|
|
14
14
|
from .authz import analyze_authz
|
|
15
15
|
from .crypto import find_crypto_weaknesses
|
|
16
16
|
from .sbom import analyze_sbom
|
|
17
|
-
from .srcpaths import is_test_file
|
|
17
|
+
from .srcpaths import is_test_file, is_vendored_file
|
|
18
18
|
from .weaknesses import find_code_weaknesses
|
|
19
19
|
from .webhardening import find_web_hardening_issues
|
|
20
20
|
from .sdk.models import AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
|
|
@@ -594,9 +594,11 @@ def scan_repo(
|
|
|
594
594
|
|
|
595
595
|
_append_hardcoded_secret_hints(result, relative, content)
|
|
596
596
|
|
|
597
|
-
# The weakness passes below are noise in test scaffolding
|
|
598
|
-
#
|
|
599
|
-
|
|
597
|
+
# The weakness passes below are noise in test scaffolding (#67) and in
|
|
598
|
+
# vendored/minified third-party code (#95) — a bug in a dependency the
|
|
599
|
+
# project doesn't maintain isn't this repo's finding. Skip both by
|
|
600
|
+
# default (ATTACKMAP_INCLUDE_TESTS / ATTACKMAP_INCLUDE_VENDORED opt in).
|
|
601
|
+
if not is_test_file(relative) and not is_vendored_file(relative):
|
|
600
602
|
# Insecure crypto / weak randomness (#70). Content is already
|
|
601
603
|
# read, so this rides the per-file pass rather than re-walking.
|
|
602
604
|
result.crypto_weaknesses.extend(find_crypto_weaknesses(content, relative))
|
|
@@ -724,6 +726,8 @@ def _append_hardcoded_secret_hints(result: ScanResult, relative: str, content: s
|
|
|
724
726
|
continue
|
|
725
727
|
if not _looks_like_secret_candidate(literal):
|
|
726
728
|
continue
|
|
729
|
+
if _looks_like_charset(literal):
|
|
730
|
+
continue # base64/base32/hex alphabet constant, not a secret (#96)
|
|
727
731
|
if _shannon_entropy(literal) < _ENTROPY_THRESHOLD:
|
|
728
732
|
continue
|
|
729
733
|
line = _line_of(content, pos)
|
|
@@ -743,6 +747,20 @@ def _append_hardcoded_secret_hints(result: ScanResult, relative: str, content: s
|
|
|
743
747
|
)
|
|
744
748
|
|
|
745
749
|
|
|
750
|
+
# Sequential alphabet runs that mark a well-known charset/alphabet constant
|
|
751
|
+
# (base64/base32/base16 digit strings) — high-entropy but not a secret (#96).
|
|
752
|
+
_CHARSET_MARKERS = (
|
|
753
|
+
"abcdefghijklmnop", # lowercase alphabet run (base64/base32/base36)
|
|
754
|
+
"ABCDEFGHIJKLMNOP", # uppercase alphabet run
|
|
755
|
+
"0123456789abcdef", # lower hex
|
|
756
|
+
"0123456789ABCDEF", # upper hex
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _looks_like_charset(value: str) -> bool:
|
|
761
|
+
return any(marker in value for marker in _CHARSET_MARKERS)
|
|
762
|
+
|
|
763
|
+
|
|
746
764
|
def _is_jwt_shape(literal: str) -> bool:
|
|
747
765
|
"""Verify the header segment of a candidate JWT decodes to a JSON
|
|
748
766
|
object with an `alg` field. Cheap and precise enough to keep the
|
|
@@ -80,4 +80,41 @@ def is_infra_route(path: str) -> bool:
|
|
|
80
80
|
return _INFRA_ROUTE_RE.search(path) is not None
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# Directory segments that hold third-party / vendored code the project doesn't
|
|
84
|
+
# maintain. Flagging weaknesses here is noise about someone else's dependency.
|
|
85
|
+
_VENDORED_DIR_SEGMENTS = frozenset(
|
|
86
|
+
{
|
|
87
|
+
"node_modules",
|
|
88
|
+
"bower_components",
|
|
89
|
+
"vendor",
|
|
90
|
+
"vendored",
|
|
91
|
+
"third_party",
|
|
92
|
+
"third-party",
|
|
93
|
+
"thirdparty",
|
|
94
|
+
"external",
|
|
95
|
+
"externals",
|
|
96
|
+
"site-packages",
|
|
97
|
+
"jspm_packages",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Minified / bundled build artifacts — not human-authored source.
|
|
102
|
+
_MINIFIED_FILE_RE = re.compile(r"\.min\.(?:js|css|mjs|cjs)$|\.bundle\.js$", re.IGNORECASE)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_vendored_file(rel_path: str) -> bool:
|
|
106
|
+
"""Return True if ``rel_path`` is vendored third-party or minified/bundled
|
|
107
|
+
code (e.g. ``UserInterface/External/three.js``, ``vendor/…``, ``*.min.js``).
|
|
108
|
+
|
|
109
|
+
Honors ``ATTACKMAP_INCLUDE_VENDORED`` to scan it anyway (e.g. auditing a
|
|
110
|
+
pinned/forked dependency)."""
|
|
111
|
+
if os.environ.get("ATTACKMAP_INCLUDE_VENDORED"):
|
|
112
|
+
return False
|
|
113
|
+
norm = rel_path.replace("\\", "/")
|
|
114
|
+
parts = norm.split("/")
|
|
115
|
+
if any(segment.lower() in _VENDORED_DIR_SEGMENTS for segment in parts[:-1]):
|
|
116
|
+
return True
|
|
117
|
+
return _MINIFIED_FILE_RE.search(parts[-1]) is not None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["is_test_file", "is_infra_route", "is_vendored_file"]
|
|
@@ -268,7 +268,13 @@ def analyze_taint(scan: ScanResult, root: str | Path | None = None) -> list[Tain
|
|
|
268
268
|
def _index_repo(root: Path) -> dict[str, Path]:
|
|
269
269
|
"""Return {rel_path: abs_path} for every taint-relevant source file."""
|
|
270
270
|
out: dict[str, Path] = {}
|
|
271
|
-
skip_dirs = {
|
|
271
|
+
skip_dirs = {
|
|
272
|
+
".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv",
|
|
273
|
+
# Vendored third-party trees (#95) — import-walking someone else's
|
|
274
|
+
# bundled library adds sinks the project doesn't own.
|
|
275
|
+
"bower_components", "vendor", "third_party", "third-party",
|
|
276
|
+
"external", "externals", "jspm_packages", "site-packages",
|
|
277
|
+
}
|
|
272
278
|
for path in root.rglob("*"):
|
|
273
279
|
if not path.is_file() or path.suffix not in _SUPPORTED_SUFFIXES:
|
|
274
280
|
continue
|
|
@@ -391,6 +397,59 @@ def _resolve_js_target(target: Path, files: dict[str, Path], root: Path) -> str
|
|
|
391
397
|
return None
|
|
392
398
|
|
|
393
399
|
|
|
400
|
+
# Sink families that fire on reachability alone (no request-token gate),
|
|
401
|
+
# because the operation is dangerous regardless of the argument's shape.
|
|
402
|
+
# But that over-fires when the argument is provably STATIC/local — e.g.
|
|
403
|
+
# `yaml.load(fs.readFileSync('./swagger.yml'))` loads a shipped config at
|
|
404
|
+
# boot, not attacker bytes. Left ungated, one such call in a hub module
|
|
405
|
+
# fans out to a chain from every route (observed: 113/123 chains on a real
|
|
406
|
+
# app collapsing to one benign `yaml.load` of a static file). We suppress
|
|
407
|
+
# these kinds when the call argument is a bare string literal or a
|
|
408
|
+
# literal-path file read with no dynamic/attacker component.
|
|
409
|
+
_STATIC_GATED_KINDS = frozenset({"unsafe_deserialization", "eval", "exec"})
|
|
410
|
+
|
|
411
|
+
# A quoted string literal appears in the argument.
|
|
412
|
+
_ARG_STRING_LITERAL = re.compile(r"""['"][^'"]*['"]""")
|
|
413
|
+
# Signals the argument is (or may be) dynamic / attacker-influenced:
|
|
414
|
+
# string concatenation, template/f-string interpolation, or a request /
|
|
415
|
+
# runtime-input container. If any appears, we do NOT treat the arg as static.
|
|
416
|
+
_ARG_DYNAMIC = re.compile(
|
|
417
|
+
r"\+|\$\{|`|\b(?:req|request|body|query|params|payload|argv|input|stdin)\b|process\."
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _extract_call_arg(content: str, open_paren: int, cap: int = 400) -> str:
|
|
422
|
+
"""Return the text inside the balanced parens starting at ``open_paren``
|
|
423
|
+
(the index of the ``(``), bounded to ``cap`` chars."""
|
|
424
|
+
depth = 0
|
|
425
|
+
end = min(len(content), open_paren + cap)
|
|
426
|
+
for i in range(open_paren, end):
|
|
427
|
+
ch = content[i]
|
|
428
|
+
if ch == "(":
|
|
429
|
+
depth += 1
|
|
430
|
+
elif ch == ")":
|
|
431
|
+
depth -= 1
|
|
432
|
+
if depth == 0:
|
|
433
|
+
return content[open_paren + 1 : i]
|
|
434
|
+
return content[open_paren + 1 : end]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _is_static_local_arg(content: str, match: re.Match[str]) -> bool:
|
|
438
|
+
"""True if the sink call's argument is provably static/local — a string
|
|
439
|
+
literal or a literal-path file read — with no dynamic/attacker component."""
|
|
440
|
+
paren = content.find("(", match.start())
|
|
441
|
+
if paren == -1:
|
|
442
|
+
return False
|
|
443
|
+
arg = _extract_call_arg(content, paren)
|
|
444
|
+
if not _ARG_STRING_LITERAL.search(arg):
|
|
445
|
+
return False # no literal → provenance unknown → keep flagging
|
|
446
|
+
# Strip quoted spans before the dynamic check so a `+`/`${`/token that is
|
|
447
|
+
# DATA inside a literal (e.g. `eval('1 + 1')`, `yaml.load('a: b+c')`)
|
|
448
|
+
# doesn't read as concatenation/interpolation of the argument.
|
|
449
|
+
residual = _ARG_STRING_LITERAL.sub("", arg)
|
|
450
|
+
return _ARG_DYNAMIC.search(residual) is None
|
|
451
|
+
|
|
452
|
+
|
|
394
453
|
def _find_sinks(
|
|
395
454
|
files: dict[str, Path], root: Path
|
|
396
455
|
) -> dict[str, list[tuple[str, int, str]]]:
|
|
@@ -404,6 +463,10 @@ def _find_sinks(
|
|
|
404
463
|
hits: list[tuple[str, int, str]] = []
|
|
405
464
|
for kind, pattern in _SINK_PATTERNS:
|
|
406
465
|
for match in pattern.finditer(content):
|
|
466
|
+
# Suppress ungated "dangerous-regardless" sinks whose argument
|
|
467
|
+
# is a static literal / local-file read (#88 follow-up).
|
|
468
|
+
if kind in _STATIC_GATED_KINDS and _is_static_local_arg(content, match):
|
|
469
|
+
continue
|
|
407
470
|
line = content.count("\n", 0, match.start()) + 1
|
|
408
471
|
snippet = _line_snippet(content, match.start())
|
|
409
472
|
hits.append((kind, line, snippet))
|
|
@@ -32,8 +32,19 @@ _PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
|
|
|
32
32
|
(
|
|
33
33
|
"prototype_pollution",
|
|
34
34
|
"high",
|
|
35
|
-
# Literal __proto__ / constructor.prototype write.
|
|
36
|
-
|
|
35
|
+
# Literal __proto__ / constructor.prototype *write* (assignment target).
|
|
36
|
+
# Requiring a trailing `=` (not `==`) avoids flagging prototype-chain
|
|
37
|
+
# *reads* like `x.prototype.__proto__.constructor` (#94).
|
|
38
|
+
_rx(
|
|
39
|
+
r"(?:"
|
|
40
|
+
r"\.__proto__"
|
|
41
|
+
r"|\[\s*['\"]__proto__['\"]\s*\]"
|
|
42
|
+
r"|\[\s*['\"]constructor['\"]\s*\]\s*\[\s*['\"]prototype['\"]\s*\]"
|
|
43
|
+
r"|\.constructor\s*\.\s*prototype"
|
|
44
|
+
r")"
|
|
45
|
+
r"(?:\s*\.\s*\w+|\s*\[[^\]]*\])*" # optional further member/subscript access
|
|
46
|
+
r"\s*=(?!=)"
|
|
47
|
+
),
|
|
37
48
|
),
|
|
38
49
|
(
|
|
39
50
|
"prototype_pollution",
|
|
@@ -158,6 +169,8 @@ def find_code_weaknesses(content: str, rel_file: str) -> list[CodeWeakness]:
|
|
|
158
169
|
out: list[CodeWeakness] = []
|
|
159
170
|
for kind, severity, pattern in _PATTERNS:
|
|
160
171
|
for match in pattern.finditer(content):
|
|
172
|
+
if _in_line_comment(content, match.start()):
|
|
173
|
+
continue # a pattern inside a `//` comment isn't real code (#94)
|
|
161
174
|
line = content.count("\n", 0, match.start()) + 1
|
|
162
175
|
key = (kind, line)
|
|
163
176
|
if key in seen:
|
|
@@ -176,6 +189,14 @@ def find_code_weaknesses(content: str, rel_file: str) -> list[CodeWeakness]:
|
|
|
176
189
|
return out
|
|
177
190
|
|
|
178
191
|
|
|
192
|
+
def _in_line_comment(content: str, offset: int) -> bool:
|
|
193
|
+
"""True if ``offset`` falls after a `//` line comment on its line. Cheap
|
|
194
|
+
heuristic (ignores `//` inside strings), used only to drop obvious
|
|
195
|
+
comment-embedded matches like `// x.__proto__.y = z`."""
|
|
196
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
197
|
+
return "//" in content[line_start:offset]
|
|
198
|
+
|
|
199
|
+
|
|
179
200
|
def _snippet(content: str, offset: int, radius: int = 120) -> str:
|
|
180
201
|
start = max(0, content.rfind("\n", 0, offset) + 1)
|
|
181
202
|
end = content.find("\n", offset)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: attackmap
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
|
|
5
5
|
Author: AttackMap Contributors
|
|
6
6
|
Author-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
@@ -75,7 +75,7 @@ managers who need to triage an unfamiliar codebase.
|
|
|
75
75
|
[](https://www.python.org/)
|
|
76
76
|
[](https://pypi.org/project/attackmap/)
|
|
77
77
|
|
|
78
|
-
> **Status: beta (v0.3.
|
|
78
|
+
> **Status: beta (v0.3.2).** Core engine and 14 analyzer plugins are published
|
|
79
79
|
> to PyPI, Homebrew, and GHCR and validated against real-world codebases.
|
|
80
80
|
> AttackMap is heuristic by design — findings are confidence-tiered evidence,
|
|
81
81
|
> not proof. See [Project status](#project-status) for what's solid and what's
|
|
@@ -541,7 +541,7 @@ introduces a new HIGH finding.
|
|
|
541
541
|
|
|
542
542
|
## Project status
|
|
543
543
|
|
|
544
|
-
AttackMap is **beta** (v0.3.
|
|
544
|
+
AttackMap is **beta** (v0.3.2) — published and validated on real codebases, but
|
|
545
545
|
pre-1.0 and heuristic.
|
|
546
546
|
|
|
547
547
|
**Solid today:**
|
|
@@ -803,3 +803,23 @@ def refresh():
|
|
|
803
803
|
# separate exposures). But the same line must not double-emit.
|
|
804
804
|
lines = [h.line for h in aws_hints]
|
|
805
805
|
assert len(lines) == len(set(lines))
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def test_base64_alphabet_constant_not_flagged_as_secret(tmp_path: Path) -> None:
|
|
809
|
+
"""A base64/hex alphabet constant is high-entropy but not a secret (#96)."""
|
|
810
|
+
(tmp_path / "sourcemap.js").write_text(
|
|
811
|
+
'var base64Digits = '
|
|
812
|
+
'"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";\n',
|
|
813
|
+
encoding="utf-8",
|
|
814
|
+
)
|
|
815
|
+
result = scan_repo(tmp_path)
|
|
816
|
+
assert not [s for s in result.secret_hints if s.kind == "high_entropy"]
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def test_real_high_entropy_secret_still_flagged(tmp_path: Path) -> None:
|
|
820
|
+
(tmp_path / "cfg.js").write_text(
|
|
821
|
+
'const apiToken = "s3cr3tR4nd0mK3yZ9xQvB2mNw8LpT4hJ7";\n',
|
|
822
|
+
encoding="utf-8",
|
|
823
|
+
)
|
|
824
|
+
result = scan_repo(tmp_path)
|
|
825
|
+
assert any(s.kind == "high_entropy" for s in result.secret_hints)
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from attackmap.scanner import scan_repo
|
|
10
|
-
from attackmap.srcpaths import is_test_file
|
|
10
|
+
from attackmap.srcpaths import is_test_file, is_vendored_file
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
# ---------------------------------------------------------------------------
|
|
@@ -58,6 +58,68 @@ def test_include_tests_env_disables_exclusion(monkeypatch: pytest.MonkeyPatch) -
|
|
|
58
58
|
assert is_test_file("pkg/foo.test.ts") is False
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# is_vendored_file classification (#95)
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize(
|
|
67
|
+
"rel",
|
|
68
|
+
[
|
|
69
|
+
"node_modules/lodash/merge.js",
|
|
70
|
+
"UserInterface/External/three.js/three.js",
|
|
71
|
+
"vendor/github.com/x/y.go",
|
|
72
|
+
"third_party/zlib/zlib.js",
|
|
73
|
+
"src/bower_components/jquery/jquery.js",
|
|
74
|
+
"app/static/app.min.js",
|
|
75
|
+
"app/main.bundle.js",
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
def test_vendored_paths_detected(rel: str) -> None:
|
|
79
|
+
assert is_vendored_file(rel) is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
"rel",
|
|
84
|
+
[
|
|
85
|
+
"src/app.js",
|
|
86
|
+
"UserInterface/Models/PropertyPath.js", # not under External/
|
|
87
|
+
"services/external_api.py", # 'external_api' file, not an 'external' dir
|
|
88
|
+
"vendored_notes.md", # not a dir segment
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
def test_non_vendored_paths_not_detected(rel: str) -> None:
|
|
92
|
+
assert is_vendored_file(rel) is False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_include_vendored_env_disables_exclusion(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
96
|
+
monkeypatch.setenv("ATTACKMAP_INCLUDE_VENDORED", "1")
|
|
97
|
+
assert is_vendored_file("node_modules/x/y.js") is False
|
|
98
|
+
assert is_vendored_file("a/b.min.js") is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_weakness_in_vendored_file_excluded_by_default(tmp_path: Path) -> None:
|
|
102
|
+
ext = tmp_path / "External" / "three.js"
|
|
103
|
+
ext.mkdir(parents=True)
|
|
104
|
+
# A ReDoS-shaped regex living in a vendored library.
|
|
105
|
+
(ext / "three.js").write_text(
|
|
106
|
+
"const re = /((?:WC+[\\/:])*)/\n", encoding="utf-8"
|
|
107
|
+
)
|
|
108
|
+
scan = scan_repo(tmp_path)
|
|
109
|
+
assert not [w for w in scan.code_weaknesses if w.kind == "redos"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_weakness_in_vendored_file_included_with_env(
|
|
113
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
114
|
+
) -> None:
|
|
115
|
+
monkeypatch.setenv("ATTACKMAP_INCLUDE_VENDORED", "1")
|
|
116
|
+
ext = tmp_path / "External"
|
|
117
|
+
ext.mkdir(parents=True)
|
|
118
|
+
(ext / "lib.js").write_text("const re = /^(a+)+$/\n", encoding="utf-8")
|
|
119
|
+
scan = scan_repo(tmp_path)
|
|
120
|
+
assert any(w.kind == "redos" for w in scan.code_weaknesses)
|
|
121
|
+
|
|
122
|
+
|
|
61
123
|
# ---------------------------------------------------------------------------
|
|
62
124
|
# Integration: weakness passes skip test files by default
|
|
63
125
|
# ---------------------------------------------------------------------------
|
|
@@ -216,7 +216,7 @@ def test_analyze_taint_uses_scan_routes_directly(tmp_path: Path) -> None:
|
|
|
216
216
|
"import worker\ndef h(): return worker.run()\n", encoding="utf-8"
|
|
217
217
|
)
|
|
218
218
|
(tmp_path / "worker.py").write_text(
|
|
219
|
-
"def run(): eval(
|
|
219
|
+
"def run(x): eval(x)\n", encoding="utf-8"
|
|
220
220
|
)
|
|
221
221
|
scan = ScanResult(
|
|
222
222
|
root=str(tmp_path),
|
|
@@ -228,6 +228,77 @@ def test_analyze_taint_uses_scan_routes_directly(tmp_path: Path) -> None:
|
|
|
228
228
|
assert chains[0].route_path == "/synthetic"
|
|
229
229
|
|
|
230
230
|
|
|
231
|
+
def test_static_local_deserialization_not_flagged(tmp_path: Path) -> None:
|
|
232
|
+
"""`yaml.load` of a static local file (shipped config at boot) is not an
|
|
233
|
+
unsafe deserialization of untrusted data — must not seed a chain. This is
|
|
234
|
+
the juice-shop `yaml.load(readFileSync('./swagger.yml'))` fan-out FP."""
|
|
235
|
+
(tmp_path / "server.js").write_text(
|
|
236
|
+
"const fs = require('fs')\n"
|
|
237
|
+
"const yaml = require('js-yaml')\n"
|
|
238
|
+
"const express = require('express')\n"
|
|
239
|
+
"const app = express()\n"
|
|
240
|
+
"const swaggerDocument = yaml.load(fs.readFileSync('./swagger.yml', 'utf8'))\n"
|
|
241
|
+
"app.get('/status', (req, res) => res.json(swaggerDocument))\n",
|
|
242
|
+
encoding="utf-8",
|
|
243
|
+
)
|
|
244
|
+
scan = scan_repo(tmp_path)
|
|
245
|
+
assert not [c for c in scan.taint_chains if c.sink_kind == "unsafe_deserialization"]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_dynamic_deserialization_still_flagged(tmp_path: Path) -> None:
|
|
249
|
+
# request-derived bytes into yaml.load → real, still flagged.
|
|
250
|
+
(tmp_path / "app.py").write_text(
|
|
251
|
+
"from flask import Flask, request\n"
|
|
252
|
+
"import yaml\n"
|
|
253
|
+
"app = Flask(__name__)\n"
|
|
254
|
+
"@app.route('/load', methods=['POST'])\n"
|
|
255
|
+
"def load():\n"
|
|
256
|
+
" return yaml.load(request.data)\n",
|
|
257
|
+
encoding="utf-8",
|
|
258
|
+
)
|
|
259
|
+
scan = scan_repo(tmp_path)
|
|
260
|
+
assert any(c.sink_kind == "unsafe_deserialization" for c in scan.taint_chains)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_concatenated_path_deserialization_still_flagged(tmp_path: Path) -> None:
|
|
264
|
+
# literal path + a variable (potential traversal) is NOT purely static.
|
|
265
|
+
(tmp_path / "app.py").write_text(
|
|
266
|
+
"from flask import Flask, request\n"
|
|
267
|
+
"import yaml\n"
|
|
268
|
+
"app = Flask(__name__)\n"
|
|
269
|
+
"@app.route('/hint/<key>')\n"
|
|
270
|
+
"def hint(key):\n"
|
|
271
|
+
" return yaml.load(open('./data/' + key + '.yml').read())\n",
|
|
272
|
+
encoding="utf-8",
|
|
273
|
+
)
|
|
274
|
+
scan = scan_repo(tmp_path)
|
|
275
|
+
assert any(c.sink_kind == "unsafe_deserialization" for c in scan.taint_chains)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_constant_eval_not_flagged_but_variable_eval_is(tmp_path: Path) -> None:
|
|
279
|
+
# Separate files so hop-0 file-locality doesn't cross-link the routes.
|
|
280
|
+
(tmp_path / "constant.py").write_text(
|
|
281
|
+
"from flask import Flask\n"
|
|
282
|
+
"app = Flask(__name__)\n"
|
|
283
|
+
"@app.route('/const')\n"
|
|
284
|
+
"def c():\n"
|
|
285
|
+
" return eval('1 + 1')\n", # constant → benign, suppressed
|
|
286
|
+
encoding="utf-8",
|
|
287
|
+
)
|
|
288
|
+
(tmp_path / "dynamic.py").write_text(
|
|
289
|
+
"from flask import Flask, request\n"
|
|
290
|
+
"app = Flask(__name__)\n"
|
|
291
|
+
"@app.route('/dyn')\n"
|
|
292
|
+
"def d():\n"
|
|
293
|
+
" return eval(request.args['x'])\n", # variable → dangerous
|
|
294
|
+
encoding="utf-8",
|
|
295
|
+
)
|
|
296
|
+
scan = scan_repo(tmp_path)
|
|
297
|
+
eval_routes = {c.route_path for c in scan.taint_chains if c.sink_kind == "eval"}
|
|
298
|
+
assert "/dyn" in eval_routes
|
|
299
|
+
assert "/const" not in eval_routes
|
|
300
|
+
|
|
301
|
+
|
|
231
302
|
@pytest.mark.parametrize("sink_kind", ["eval", "exec", "subprocess_shell", "dynamic_open"])
|
|
232
303
|
def test_all_sink_kinds_are_detectable(tmp_path: Path, sink_kind: str) -> None:
|
|
233
304
|
(tmp_path / "route.py").write_text(
|
|
@@ -236,8 +307,10 @@ def test_all_sink_kinds_are_detectable(tmp_path: Path, sink_kind: str) -> None:
|
|
|
236
307
|
encoding="utf-8",
|
|
237
308
|
)
|
|
238
309
|
bodies = {
|
|
239
|
-
|
|
240
|
-
|
|
310
|
+
# eval/exec use a variable arg (not a constant) — a constant eval is
|
|
311
|
+
# benign and is now suppressed by the static-arg gate (see below).
|
|
312
|
+
"eval": "def go(x): eval(x)\n",
|
|
313
|
+
"exec": "def go(x): exec(x)\n",
|
|
241
314
|
"subprocess_shell": "import subprocess\ndef go(): subprocess.run('ls', shell=True)\n",
|
|
242
315
|
"dynamic_open": "def go(req): return open(req.path)\n",
|
|
243
316
|
}
|
|
@@ -40,6 +40,27 @@ def test_safe_merge_of_constant_not_flagged() -> None:
|
|
|
40
40
|
assert "prototype_pollution" not in _kinds("_.merge(target, { a: 1 })\n")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def test_prototype_chain_read_not_flagged() -> None:
|
|
44
|
+
# Reading up the prototype chain (parent-constructor lookup) is not a write.
|
|
45
|
+
assert "prototype_pollution" not in _kinds(
|
|
46
|
+
"constructor = constructor.prototype.__proto__.constructor;\n"
|
|
47
|
+
)
|
|
48
|
+
assert "prototype_pollution" not in _kinds("const p = obj.__proto__.foo\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_proto_in_comment_not_flagged() -> None:
|
|
52
|
+
assert "prototype_pollution" not in _kinds(
|
|
53
|
+
'// The path for "foo.__proto__.bar.__proto__.x" is shown as "foo.bar.x".\n'
|
|
54
|
+
)
|
|
55
|
+
# even a write-shaped example inside a line comment is ignored
|
|
56
|
+
assert "prototype_pollution" not in _kinds("// obj.__proto__.polluted = true\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_proto_write_still_flagged() -> None:
|
|
60
|
+
assert "prototype_pollution" in _kinds("target.__proto__.polluted = true\n")
|
|
61
|
+
assert "prototype_pollution" in _kinds("obj['__proto__']['x'] = 1\n")
|
|
62
|
+
|
|
63
|
+
|
|
43
64
|
# ---------------------------------------------------------------------------
|
|
44
65
|
# Mass assignment
|
|
45
66
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|