attackmap 0.1.0__tar.gz → 0.1.1__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 (60) hide show
  1. {attackmap-0.1.0/src/attackmap.egg-info → attackmap-0.1.1}/PKG-INFO +23 -16
  2. {attackmap-0.1.0 → attackmap-0.1.1}/README.md +9 -2
  3. {attackmap-0.1.0 → attackmap-0.1.1}/pyproject.toml +14 -14
  4. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzers.py +24 -56
  5. attackmap-0.1.1/src/attackmap/merge.py +91 -0
  6. attackmap-0.1.1/src/attackmap/sdk/__init__.py +105 -0
  7. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/threat_model.py +101 -34
  8. {attackmap-0.1.0 → attackmap-0.1.1/src/attackmap.egg-info}/PKG-INFO +23 -16
  9. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/SOURCES.txt +2 -0
  10. attackmap-0.1.1/src/attackmap.egg-info/requires.txt +26 -0
  11. attackmap-0.1.1/tests/test_merge.py +188 -0
  12. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_threat_model.py +180 -1
  13. attackmap-0.1.0/src/attackmap/sdk/__init__.py +0 -41
  14. attackmap-0.1.0/src/attackmap.egg-info/requires.txt +0 -26
  15. {attackmap-0.1.0 → attackmap-0.1.1}/LICENSE +0 -0
  16. {attackmap-0.1.0 → attackmap-0.1.1}/setup.cfg +0 -0
  17. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/__init__.py +0 -0
  18. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzer.py +0 -0
  19. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzer_contracts.py +0 -0
  20. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/asset_model.py +0 -0
  21. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/attack_taxonomy.py +0 -0
  22. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/cli.py +0 -0
  23. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/context_pack.py +0 -0
  24. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/control_model.py +0 -0
  25. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/defensive_review.py +0 -0
  26. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/detection_opportunities.py +0 -0
  27. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/graph.py +0 -0
  28. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/insights.py +0 -0
  29. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/llm_review.py +0 -0
  30. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/models.py +0 -0
  31. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/recon_models.py +0 -0
  32. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/recon_to_analysis.py +0 -0
  33. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/report.py +0 -0
  34. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_eval.py +0 -0
  35. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_json.py +0 -0
  36. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_prompts.py +0 -0
  37. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/scanner.py +0 -0
  38. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/sdk/contracts.py +0 -0
  39. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/sdk/models.py +0 -0
  40. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/security_overlay.py +0 -0
  41. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/dependency_links.txt +0 -0
  42. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/entry_points.txt +0 -0
  43. {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/top_level.txt +0 -0
  44. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_analyzer.py +0 -0
  45. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_analyzers.py +0 -0
  46. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_artifact_schemas.py +0 -0
  47. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_context_pack.py +0 -0
  48. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_defensive_review.py +0 -0
  49. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_graph.py +0 -0
  50. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_llm_review.py +0 -0
  51. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_recon_to_analysis.py +0 -0
  52. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_report.py +0 -0
  53. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_eval.py +0 -0
  54. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_json.py +0 -0
  55. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_prompts.py +0 -0
  56. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_scanner.py +0 -0
  57. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_security_overlay.py +0 -0
  58. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_shared_contract_imports.py +0 -0
  59. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_signal_v2.py +0 -0
  60. {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_threat_intel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.1.0
3
+ Version: 0.1.1
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>
@@ -38,19 +38,19 @@ Provides-Extra: llm
38
38
  Requires-Dist: anthropic>=0.40.0; extra == "llm"
39
39
  Provides-Extra: all
40
40
  Requires-Dist: attackmap[llm]; extra == "all"
41
- Requires-Dist: attackmap-analyzer-python; extra == "all"
42
- Requires-Dist: attackmap-analyzer-rust; extra == "all"
43
- Requires-Dist: attackmap-analyzer-go; extra == "all"
44
- Requires-Dist: attackmap-analyzer-java-spring; extra == "all"
45
- Requires-Dist: attackmap-analyzer-dotnet; extra == "all"
46
- Requires-Dist: attackmap-analyzer-terraform; extra == "all"
47
- Requires-Dist: attackmap-analyzer-c; extra == "all"
48
- Requires-Dist: attackmap-analyzer-cpp; extra == "all"
49
- Requires-Dist: attackmap-analyzer-node-service; extra == "all"
50
- Requires-Dist: attackmap-analyzer-atproto; extra == "all"
51
- Requires-Dist: attackmap-analyzer-php-web; extra == "all"
52
- Requires-Dist: attackmap-analyzer-php-laminas; extra == "all"
53
- Requires-Dist: attackmap-analyzer-omeka-s; extra == "all"
41
+ Requires-Dist: attackmap-analyzer-python>=0.1.0; extra == "all"
42
+ Requires-Dist: attackmap-analyzer-rust>=0.1.0; extra == "all"
43
+ Requires-Dist: attackmap-analyzer-go>=0.1.0; extra == "all"
44
+ Requires-Dist: attackmap-analyzer-java-spring>=0.1.0; extra == "all"
45
+ Requires-Dist: attackmap-analyzer-dotnet>=0.1.0; extra == "all"
46
+ Requires-Dist: attackmap-analyzer-terraform>=0.1.0; extra == "all"
47
+ Requires-Dist: attackmap-analyzer-c>=0.1.0; extra == "all"
48
+ Requires-Dist: attackmap-analyzer-cpp>=0.1.0; extra == "all"
49
+ Requires-Dist: attackmap-analyzer-node-service>=0.2.0; extra == "all"
50
+ Requires-Dist: attackmap-analyzer-atproto>=0.1.0; extra == "all"
51
+ Requires-Dist: attackmap-analyzer-php-web>=0.1.0; extra == "all"
52
+ Requires-Dist: attackmap-analyzer-php-laminas>=0.1.0; extra == "all"
53
+ Requires-Dist: attackmap-analyzer-omeka-s>=0.1.0; extra == "all"
54
54
  Provides-Extra: dev
55
55
  Requires-Dist: pytest>=8.0.0; extra == "dev"
56
56
  Requires-Dist: build>=1.2.0; extra == "dev"
@@ -107,7 +107,7 @@ Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm
107
107
  ```bash
108
108
  pip install attackmap # core only
109
109
  pip install "attackmap[llm]" # add LLM narrative support
110
- pip install "attackmap[all]" # core + LLM + all 11 analyzer plugins
110
+ pip install "attackmap[all]" # core + LLM + all 13 analyzer plugins
111
111
  ```
112
112
 
113
113
  You can also install individual analyzer plugins on demand:
@@ -187,7 +187,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
187
187
 
188
188
  ## Supported ecosystems
189
189
 
190
- Eleven official analyzer plugins, each distributable as a separate package:
190
+ Thirteen official analyzer plugins, each distributable as a separate package:
191
191
 
192
192
  | Plugin | Coverage |
193
193
  |---|---|
@@ -205,6 +205,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
205
205
 
206
206
  `pip install "attackmap[all]"` installs every official plugin.
207
207
 
208
+ ### Building your own analyzer
209
+
210
+ The plugin contract is documented in code at
211
+ [`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
212
+ scaffolding, testing, and publishing instructions is in
213
+ [`docs/external-analyzers.md`](docs/external-analyzers.md).
214
+
208
215
  ---
209
216
 
210
217
  ## CLI reference
@@ -49,7 +49,7 @@ Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm
49
49
  ```bash
50
50
  pip install attackmap # core only
51
51
  pip install "attackmap[llm]" # add LLM narrative support
52
- pip install "attackmap[all]" # core + LLM + all 11 analyzer plugins
52
+ pip install "attackmap[all]" # core + LLM + all 13 analyzer plugins
53
53
  ```
54
54
 
55
55
  You can also install individual analyzer plugins on demand:
@@ -129,7 +129,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
129
129
 
130
130
  ## Supported ecosystems
131
131
 
132
- Eleven official analyzer plugins, each distributable as a separate package:
132
+ Thirteen official analyzer plugins, each distributable as a separate package:
133
133
 
134
134
  | Plugin | Coverage |
135
135
  |---|---|
@@ -147,6 +147,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
147
147
 
148
148
  `pip install "attackmap[all]"` installs every official plugin.
149
149
 
150
+ ### Building your own analyzer
151
+
152
+ The plugin contract is documented in code at
153
+ [`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
154
+ scaffolding, testing, and publishing instructions is in
155
+ [`docs/external-analyzers.md`](docs/external-analyzers.md).
156
+
150
157
  ---
151
158
 
152
159
  ## CLI reference
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap"
7
- version = "0.1.0"
7
+ version = "0.1.1"
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"
@@ -59,19 +59,19 @@ llm = [
59
59
  ]
60
60
  all = [
61
61
  "attackmap[llm]",
62
- "attackmap-analyzer-python",
63
- "attackmap-analyzer-rust",
64
- "attackmap-analyzer-go",
65
- "attackmap-analyzer-java-spring",
66
- "attackmap-analyzer-dotnet",
67
- "attackmap-analyzer-terraform",
68
- "attackmap-analyzer-c",
69
- "attackmap-analyzer-cpp",
70
- "attackmap-analyzer-node-service",
71
- "attackmap-analyzer-atproto",
72
- "attackmap-analyzer-php-web",
73
- "attackmap-analyzer-php-laminas",
74
- "attackmap-analyzer-omeka-s",
62
+ "attackmap-analyzer-python>=0.1.0",
63
+ "attackmap-analyzer-rust>=0.1.0",
64
+ "attackmap-analyzer-go>=0.1.0",
65
+ "attackmap-analyzer-java-spring>=0.1.0",
66
+ "attackmap-analyzer-dotnet>=0.1.0",
67
+ "attackmap-analyzer-terraform>=0.1.0",
68
+ "attackmap-analyzer-c>=0.1.0",
69
+ "attackmap-analyzer-cpp>=0.1.0",
70
+ "attackmap-analyzer-node-service>=0.2.0",
71
+ "attackmap-analyzer-atproto>=0.1.0",
72
+ "attackmap-analyzer-php-web>=0.1.0",
73
+ "attackmap-analyzer-php-laminas>=0.1.0",
74
+ "attackmap-analyzer-omeka-s>=0.1.0",
75
75
  ]
76
76
  dev = [
77
77
  "pytest>=8.0.0",
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable, Iterable
3
+ from collections.abc import Iterable
4
4
  from dataclasses import dataclass
5
5
  from importlib.metadata import entry_points
6
6
  import json
@@ -8,15 +8,14 @@ import logging
8
8
  from pathlib import Path
9
9
  import subprocess
10
10
  import sys
11
- from typing import Protocol, TypeVar
12
-
13
- _T = TypeVar("_T")
14
- _K = TypeVar("_K")
11
+ from typing import Protocol
15
12
  from urllib.error import URLError
16
13
  from urllib.request import urlopen
17
14
 
18
15
  from pydantic import BaseModel, Field
19
16
 
17
+ from .merge import MERGE_SCHEMA, initial_seen, merge_into
18
+
20
19
  from .sdk.contracts import (
21
20
  AnalyzerMetadata,
22
21
  AnalyzerProtocol,
@@ -166,23 +165,22 @@ def get_builtin_analyzers() -> tuple[FileAnalyzer, ...]:
166
165
 
167
166
 
168
167
  def merge_analyzer_signals(scan: ScanResult, signals: AnalyzerSignals) -> None:
168
+ """Apply file-by-file analyzer signals onto a running scan result.
169
+
170
+ `AnalyzerSignals` is the narrow per-file shape used by the built-in
171
+ micro-analyzers in this module. Dedup follows :data:`merge.MERGE_SCHEMA`
172
+ for the field families it covers; routes, external calls, and secrets
173
+ have always been straight extend (intentional: per-file analyzers emit
174
+ one batch per file, dedup happens at the result-level merge step).
175
+ """
169
176
  scan.routes.extend(signals.routes)
170
177
  scan.external_calls.extend(signals.external_calls)
171
178
  scan.secret_hints.extend(signals.secret_hints)
172
179
 
173
- seen_databases = {(hint.kind, hint.file) for hint in scan.databases}
174
- for hint in signals.databases:
175
- key = (hint.kind, hint.file)
176
- if key not in seen_databases:
177
- scan.databases.append(hint)
178
- seen_databases.add(key)
179
-
180
- seen_auth_hints = {(hint.hint, hint.file) for hint in scan.auth_hints}
181
- for hint in signals.auth_hints:
182
- key = (hint.hint, hint.file)
183
- if key not in seen_auth_hints:
184
- scan.auth_hints.append(hint)
185
- seen_auth_hints.add(key)
180
+ rules = {r.attr: r for r in MERGE_SCHEMA}
181
+ for attr in ("databases", "auth_hints"):
182
+ rule = rules[attr]
183
+ merge_into(scan, getattr(signals, attr), rule, initial_seen(scan, rule))
186
184
 
187
185
 
188
186
  # Backward-compatible alias for existing imports from attackmap.analyzers.
@@ -505,6 +503,11 @@ def merge_analyzer_results(
505
503
  results: Iterable[AnalyzerResult],
506
504
  root: str | Path | None = None,
507
505
  ) -> AnalyzerResult:
506
+ """Combine multiple whole-repo analyzer results into one.
507
+
508
+ Field-by-field merge behavior is declared in :data:`merge.MERGE_SCHEMA`
509
+ and applied uniformly here. See `merge.py` for the rules.
510
+ """
508
511
  result_list = list(results)
509
512
  if not result_list:
510
513
  resolved_root = Path(root).resolve() if root is not None else Path(".").resolve()
@@ -512,50 +515,15 @@ def merge_analyzer_results(
512
515
 
513
516
  resolved_root = Path(root).resolve() if root is not None else Path(result_list[0].root).resolve()
514
517
  merged = AnalyzerResult(root=str(resolved_root))
515
- route_keys: set[tuple[str, str, str]] = set()
516
- external_keys: set[tuple[str, str]] = set()
517
- database_keys: set[tuple[str, str]] = set()
518
- auth_keys: set[tuple[str, str]] = set()
519
- service_keys: set[tuple[str, str]] = set()
520
- edge_keys: set[tuple[str, str]] = set()
521
- entrypoint_keys: set[tuple[str, str]] = set()
522
- protocol_keys: set[tuple[str, str]] = set()
523
- framework_keys: set[tuple[str, str]] = set()
524
- secret_keys: set[tuple[str, str]] = set()
518
+ seen_by_attr: dict[str, set] = {rule.attr: set() for rule in MERGE_SCHEMA}
525
519
 
526
520
  for result in result_list:
527
521
  merged.files_scanned += result.files_scanned
528
522
  for language in result.languages:
529
523
  if language not in merged.languages:
530
524
  merged.languages.append(language)
531
- _merge_unique_items(merged.routes, route_keys, result.routes, lambda item: (item.path, item.method, item.file))
532
- _merge_unique_items(merged.external_calls, external_keys, result.external_calls, lambda item: (item.target, item.file))
533
- _merge_unique_items(merged.databases, database_keys, result.databases, lambda item: (item.kind, item.file))
534
- _merge_unique_items(merged.auth_hints, auth_keys, result.auth_hints, lambda item: (item.hint, item.file))
535
- _merge_unique_items(merged.service_hints, service_keys, result.service_hints, lambda item: (item.hint, item.file))
536
- _merge_unique_items(merged.edge_hints, edge_keys, result.edge_hints, lambda item: (item.hint, item.file))
537
- _merge_unique_items(
538
- merged.entrypoint_hints, entrypoint_keys, result.entrypoint_hints, lambda item: (item.hint, item.file)
539
- )
540
- _merge_unique_items(merged.protocol_hints, protocol_keys, result.protocol_hints, lambda item: (item.hint, item.file))
541
- _merge_unique_items(
542
- merged.framework_hints, framework_keys, result.framework_hints, lambda item: (item.hint, item.file)
543
- )
544
- _merge_unique_items(merged.secret_hints, secret_keys, result.secret_hints, lambda item: (item.name, item.file))
525
+ for rule in MERGE_SCHEMA:
526
+ merge_into(merged, getattr(result, rule.attr), rule, seen_by_attr[rule.attr])
545
527
 
546
528
  merged.languages.sort()
547
529
  return merged
548
-
549
-
550
- def _merge_unique_items(
551
- destination: list[_T],
552
- seen: set[_K],
553
- items: Iterable[_T],
554
- key_fn: Callable[[_T], _K],
555
- ) -> None:
556
- for item in items:
557
- key = key_fn(item)
558
- if key in seen:
559
- continue
560
- seen.add(key)
561
- destination.append(item)
@@ -0,0 +1,91 @@
1
+ """Centralized merge behavior for analyzer outputs.
2
+
3
+ This module is the single source of truth for **how** analyzer-emitted
4
+ signals are combined into a single ScanResult. It is loaded by both the
5
+ public `merge_analyzer_results` (whole-repo analyzers) and the internal
6
+ `merge_analyzer_signals` (file-by-file built-in micro-analyzers).
7
+
8
+ A new structured signal type is added in one place: append a row to
9
+ :data:`MERGE_SCHEMA` describing the field name and its dedup key. All
10
+ merge / dedup behavior follows automatically.
11
+
12
+ ## Merge rules
13
+
14
+ - **Deterministic ordering**: first-seen wins. The order signals appear
15
+ in the merged result is the order they first appeared across the input
16
+ results, walked in input order. We do not reorder.
17
+ - **Deduplication**: each list field has a stable key extracted from the
18
+ signal itself (e.g. `(path, method, file)` for routes). Two signals
19
+ that hash to the same key are treated as duplicates; only the first is
20
+ kept.
21
+ - **`languages`**: union, then sorted alphabetically for stable display.
22
+ - **`files_scanned`**: summed across results.
23
+ - **`root`**: taken from the explicit `root` argument when supplied,
24
+ otherwise from the first result.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Callable, Iterable
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class MergeRule:
36
+ """How one list field on `ScanResult` is merged across analyzer outputs.
37
+
38
+ - `attr`: the attribute name on `ScanResult` (e.g. `"routes"`).
39
+ - `key`: a callable that returns a hashable dedup key for one signal.
40
+ """
41
+
42
+ attr: str
43
+ key: Callable[[Any], tuple]
44
+
45
+ def __post_init__(self) -> None:
46
+ if not self.attr.isidentifier():
47
+ raise ValueError(f"MergeRule.attr must be a valid identifier, got {self.attr!r}")
48
+
49
+
50
+ # The complete schema of list fields on `ScanResult` that get merged
51
+ # across analyzer outputs. Order is preserved in the merged result;
52
+ # duplicates (by key) are suppressed.
53
+ MERGE_SCHEMA: tuple[MergeRule, ...] = (
54
+ MergeRule("routes", lambda item: (item.path, item.method, item.file)),
55
+ MergeRule("external_calls", lambda item: (item.target, item.file)),
56
+ MergeRule("databases", lambda item: (item.kind, item.file)),
57
+ MergeRule("auth_hints", lambda item: (item.hint, item.file)),
58
+ MergeRule("service_hints", lambda item: (item.hint, item.file)),
59
+ MergeRule("edge_hints", lambda item: (item.hint, item.file)),
60
+ MergeRule("entrypoint_hints", lambda item: (item.hint, item.file)),
61
+ MergeRule("protocol_hints", lambda item: (item.hint, item.file)),
62
+ MergeRule("framework_hints", lambda item: (item.hint, item.file)),
63
+ MergeRule("secret_hints", lambda item: (item.name, item.file)),
64
+ )
65
+
66
+
67
+ def merge_into(destination: Any, items: Iterable[Any], rule: MergeRule, seen: set) -> None:
68
+ """Append `items` onto `destination.<rule.attr>` deduping against `seen`.
69
+
70
+ `seen` is mutated to include the keys of items that were appended.
71
+ Pre-populate `seen` with keys from the existing destination if you
72
+ want to merge across multiple sources without rescanning the list.
73
+ """
74
+ target = getattr(destination, rule.attr)
75
+ for item in items:
76
+ k = rule.key(item)
77
+ if k in seen:
78
+ continue
79
+ seen.add(k)
80
+ target.append(item)
81
+
82
+
83
+ def initial_seen(destination: Any, rule: MergeRule) -> set:
84
+ """Compute the `seen` set for items already on `destination.<rule.attr>`.
85
+
86
+ Used by callers that merge into a non-empty destination.
87
+ """
88
+ return {rule.key(item) for item in getattr(destination, rule.attr)}
89
+
90
+
91
+ __all__ = ["MergeRule", "MERGE_SCHEMA", "merge_into", "initial_seen"]
@@ -0,0 +1,105 @@
1
+ """Public, stable contract surface for AttackMap analyzers.
2
+
3
+ Anything you can import from ``attackmap.sdk`` is part of the stable analyzer
4
+ contract. Anything you cannot is an internal implementation detail of the
5
+ ``attackmap`` package and may change without a major-version bump.
6
+
7
+ ## What an analyzer is
8
+
9
+ An analyzer is a Python object that satisfies :class:`AnalyzerProtocol`:
10
+
11
+ - exposes ``metadata: AnalyzerMetadata``
12
+ - exposes a ``name`` property
13
+ - implements ``detect(root) -> bool`` — does this repo look like something
14
+ the analyzer should run on?
15
+ - implements ``analyze(root) -> AnalyzerResult`` — extract structured
16
+ signals (routes, external calls, hints, …) from the repo
17
+
18
+ External analyzers register themselves via the ``attackmap.analyzers``
19
+ Python entry-point group. See the README of any official analyzer (e.g.
20
+ ``attackmap-analyzer-python``) for a working example.
21
+
22
+ ## Responsibilities
23
+
24
+ Analyzers emit **structured signals** — small, evidence-bearing data points
25
+ with a file/line citation. They do *not* generate findings, attack paths,
26
+ or reports. Specifically:
27
+
28
+ | Owned by analyzers | Owned by core |
29
+ |---|---|
30
+ | Detecting whether they should run | Discovering and loading analyzers |
31
+ | Extracting routes, external calls, databases | Merging results across analyzers |
32
+ | Emitting auth/secret/service/edge/protocol/framework/entrypoint hints | Building the system graph |
33
+ | Surface-level normalization (path canonicalization, etc.) | Generating findings + severity |
34
+ | | Generating attack paths and threat-model output |
35
+ | | Rendering CLI / JSON / markdown reports |
36
+
37
+ If you find yourself wanting to add finding generation or report rendering
38
+ to an analyzer, the answer is almost always "emit a richer signal and let
39
+ core do the reasoning."
40
+
41
+ ## Merge semantics
42
+
43
+ When multiple analyzers run against the same repo, their results are
44
+ merged by `attackmap.analyzers.merge_analyzer_results`. The schema is
45
+ declared in `attackmap.merge.MERGE_SCHEMA`; the rules are:
46
+
47
+ - **Order**: first-seen wins. Order across analyzers is the order
48
+ `entry_points()` returns them in (Python's discovery order, by entry
49
+ point name).
50
+ - **Dedup**: each list field has a stable tuple key, e.g. routes are
51
+ deduped by ``(path, method, file)`` and auth hints by ``(hint, file)``.
52
+ - **Languages**: union, sorted for stable display.
53
+ - **Files scanned**: summed.
54
+
55
+ Two analyzers that both emit a route for the same ``(path, method, file)``
56
+ produce one route in the merged result — whichever was emitted first.
57
+
58
+ ## Versioning
59
+
60
+ The names exported below are stable across minor releases of the
61
+ ``attackmap`` package. Breaking changes to this surface only happen on
62
+ a major-version bump; deprecations get one release of overlap.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ from .contracts import (
68
+ AnalyzerMetadata,
69
+ AnalyzerProtocol,
70
+ AnalyzerRepositoryModule,
71
+ AnalyzerResult,
72
+ normalize_analyzer_metadata,
73
+ )
74
+ from .models import (
75
+ AuthHint,
76
+ DatabaseHint,
77
+ EdgeHint,
78
+ EntrypointHint,
79
+ ExternalCall,
80
+ FrameworkHint,
81
+ ProtocolHint,
82
+ Route,
83
+ ScanResult,
84
+ SecretHint,
85
+ ServiceHint,
86
+ )
87
+
88
+ __all__ = [
89
+ "AnalyzerResult",
90
+ "AnalyzerMetadata",
91
+ "AnalyzerRepositoryModule",
92
+ "AnalyzerProtocol",
93
+ "normalize_analyzer_metadata",
94
+ "Route",
95
+ "ExternalCall",
96
+ "DatabaseHint",
97
+ "AuthHint",
98
+ "ServiceHint",
99
+ "EdgeHint",
100
+ "EntrypointHint",
101
+ "ProtocolHint",
102
+ "FrameworkHint",
103
+ "SecretHint",
104
+ "ScanResult",
105
+ ]