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.
Files changed (96) hide show
  1. {attackmap-0.3.0/src/attackmap.egg-info → attackmap-0.3.2}/PKG-INFO +3 -3
  2. {attackmap-0.3.0 → attackmap-0.3.2}/README.md +2 -2
  3. {attackmap-0.3.0 → attackmap-0.3.2}/pyproject.toml +1 -1
  4. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/__init__.py +1 -1
  5. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/scanner.py +22 -4
  6. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/srcpaths.py +38 -1
  7. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/taint.py +64 -1
  8. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/weaknesses.py +23 -2
  9. {attackmap-0.3.0 → attackmap-0.3.2/src/attackmap.egg-info}/PKG-INFO +3 -3
  10. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_scanner.py +20 -0
  11. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_srcpaths.py +63 -1
  12. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_taint.py +76 -3
  13. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_weaknesses.py +21 -0
  14. {attackmap-0.3.0 → attackmap-0.3.2}/LICENSE +0 -0
  15. {attackmap-0.3.0 → attackmap-0.3.2}/setup.cfg +0 -0
  16. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzer.py +0 -0
  17. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzer_contracts.py +0 -0
  18. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/analyzers.py +0 -0
  19. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/anomalies.py +0 -0
  20. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/asset_model.py +0 -0
  21. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/attack_taxonomy.py +0 -0
  22. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/authz.py +0 -0
  23. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/cli.py +0 -0
  24. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/config_scanner.py +0 -0
  25. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/context_pack.py +0 -0
  26. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/control_model.py +0 -0
  27. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/crypto.py +0 -0
  28. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/cve.py +0 -0
  29. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/defensive_review.py +0 -0
  30. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/detection_opportunities.py +0 -0
  31. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/diagrams.py +0 -0
  32. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/diff.py +0 -0
  33. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/exploitability.py +0 -0
  34. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/graph.py +0 -0
  35. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/insights.py +0 -0
  36. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/llm_review.py +0 -0
  37. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/merge.py +0 -0
  38. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/models.py +0 -0
  39. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/progress.py +0 -0
  40. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/recon_models.py +0 -0
  41. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/recon_to_analysis.py +0 -0
  42. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/report.py +0 -0
  43. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_eval.py +0 -0
  44. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_json.py +0 -0
  45. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/review_prompts.py +0 -0
  46. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sarif.py +0 -0
  47. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sbom.py +0 -0
  48. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/__init__.py +0 -0
  49. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/contracts.py +0 -0
  50. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/sdk/models.py +0 -0
  51. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/security_overlay.py +0 -0
  52. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/suggest.py +0 -0
  53. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/threat_model.py +0 -0
  54. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/topology.py +0 -0
  55. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap/webhardening.py +0 -0
  56. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/SOURCES.txt +0 -0
  57. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/dependency_links.txt +0 -0
  58. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/entry_points.txt +0 -0
  59. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/requires.txt +0 -0
  60. {attackmap-0.3.0 → attackmap-0.3.2}/src/attackmap.egg-info/top_level.txt +0 -0
  61. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzer.py +0 -0
  62. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzer_metadata_schema.py +0 -0
  63. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_analyzers.py +0 -0
  64. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_anomalies.py +0 -0
  65. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_artifact_schemas.py +0 -0
  66. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_authz.py +0 -0
  67. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_config_scanner.py +0 -0
  68. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_context_pack.py +0 -0
  69. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_crypto.py +0 -0
  70. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_cve.py +0 -0
  71. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_defensive_review.py +0 -0
  72. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_diagrams.py +0 -0
  73. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_diff.py +0 -0
  74. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_exploitability.py +0 -0
  75. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_graph.py +0 -0
  76. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_hunt.py +0 -0
  77. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_injection_sinks.py +0 -0
  78. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_llm_review.py +0 -0
  79. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_merge.py +0 -0
  80. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_progress.py +0 -0
  81. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_provenance.py +0 -0
  82. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_recon_to_analysis.py +0 -0
  83. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_report.py +0 -0
  84. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_eval.py +0 -0
  85. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_json.py +0 -0
  86. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_review_prompts.py +0 -0
  87. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_sarif.py +0 -0
  88. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_sbom.py +0 -0
  89. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_security_overlay.py +0 -0
  90. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_shared_contract_imports.py +0 -0
  91. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_signal_v2.py +0 -0
  92. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_suggest.py +0 -0
  93. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_threat_intel.py +0 -0
  94. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_threat_model.py +0 -0
  95. {attackmap-0.3.0 → attackmap-0.3.2}/tests/test_topology.py +0 -0
  96. {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.0
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
  [![Python: 3.11+](https://img.shields.io/badge/Python-3.11%2B-blue.svg)](https://www.python.org/)
76
76
  [![PyPI](https://img.shields.io/pypi/v/attackmap.svg)](https://pypi.org/project/attackmap/)
77
77
 
78
- > **Status: beta (v0.3.0).** Core engine and 14 analyzer plugins are published
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.0) — published and validated on real codebases, but
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
  [![Python: 3.11+](https://img.shields.io/badge/Python-3.11%2B-blue.svg)](https://www.python.org/)
17
17
  [![PyPI](https://img.shields.io/pypi/v/attackmap.svg)](https://pypi.org/project/attackmap/)
18
18
 
19
- > **Status: beta (v0.3.0).** Core engine and 14 analyzer plugins are published
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.0) — published and validated on real codebases, but
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.0"
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.0"
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, so skip
598
- # test/spec files by default (#67; ATTACKMAP_INCLUDE_TESTS opts in).
599
- if not is_test_file(relative):
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
- __all__ = ["is_test_file", "is_infra_route"]
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 = {".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv"}
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
- _rx(r"\[\s*['\"]__proto__['\"]\s*\]|\.__proto__\s*[=.]|['\"]constructor['\"]\s*\]\s*\[\s*['\"]prototype['\"]"),
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.0
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
  [![Python: 3.11+](https://img.shields.io/badge/Python-3.11%2B-blue.svg)](https://www.python.org/)
76
76
  [![PyPI](https://img.shields.io/pypi/v/attackmap.svg)](https://pypi.org/project/attackmap/)
77
77
 
78
- > **Status: beta (v0.3.0).** Core engine and 14 analyzer plugins are published
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.0) — published and validated on real codebases, but
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('1+1')\n", encoding="utf-8"
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
- "eval": "def go(): eval('1+1')\n",
240
- "exec": "def go(): exec('a=1')\n",
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