pyattackforge 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """PyAttackForge SDK."""
2
+
3
+ from .client import AttackForgeClient
4
+
5
+ __all__ = ["AttackForgeClient"]
pyattackforge/cache.py ADDED
@@ -0,0 +1,33 @@
1
+ """Small TTL cache used by the SDK to reduce API calls."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+ import time
6
+
7
+
8
+ @dataclass
9
+ class CacheEntry:
10
+ value: Any
11
+ expires_at: float
12
+
13
+
14
+ class TTLCache:
15
+ def __init__(self, default_ttl: float = 300.0) -> None:
16
+ self._default_ttl = default_ttl
17
+ self._data: Dict[str, CacheEntry] = {}
18
+
19
+ def get(self, key: str) -> Optional[Any]:
20
+ entry = self._data.get(key)
21
+ if not entry:
22
+ return None
23
+ if entry.expires_at < time.monotonic():
24
+ self._data.pop(key, None)
25
+ return None
26
+ return entry.value
27
+
28
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
29
+ ttl_value = self._default_ttl if ttl is None else ttl
30
+ self._data[key] = CacheEntry(value=value, expires_at=time.monotonic() + ttl_value)
31
+
32
+ def clear(self) -> None:
33
+ self._data.clear()
@@ -0,0 +1,155 @@
1
+ """High-level AttackForge client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Sequence, Dict, Any
6
+ import time
7
+
8
+ from .config import ClientConfig, config_from_env
9
+ from .exceptions import APIError
10
+ from .transport import AttackForgeTransport
11
+ from .resources import (
12
+ AssetsResource,
13
+ ProjectsResource,
14
+ FindingsResource,
15
+ WriteupsResource,
16
+ TestcasesResource,
17
+ TestsuitesResource,
18
+ NotesResource,
19
+ UsersResource,
20
+ ReportsResource,
21
+ )
22
+
23
+
24
+ class AttackForgeClient:
25
+ """Facade client exposing resource groups for the AttackForge SSAPI."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ api_key: Optional[str] = None,
31
+ base_url: Optional[str] = None,
32
+ config: Optional[ClientConfig] = None,
33
+ timeout: Optional[float] = None,
34
+ max_retries: Optional[int] = None,
35
+ backoff_factor: Optional[float] = None,
36
+ http2: Optional[bool] = None,
37
+ ) -> None:
38
+ if config is None:
39
+ if base_url and api_key:
40
+ config = ClientConfig(
41
+ base_url=base_url,
42
+ api_key=api_key,
43
+ timeout=30.0 if timeout is None else timeout,
44
+ max_retries=3 if max_retries is None else max_retries,
45
+ backoff_factor=0.5 if backoff_factor is None else backoff_factor,
46
+ http2=True if http2 is None else http2,
47
+ )
48
+ else:
49
+ config = config_from_env()
50
+ self._transport = AttackForgeTransport(config)
51
+
52
+ self.assets = AssetsResource(self._transport)
53
+ self.projects = ProjectsResource(self._transport)
54
+ self.findings = FindingsResource(self._transport)
55
+ self.writeups = WriteupsResource(self._transport)
56
+ self.testcases = TestcasesResource(self._transport)
57
+ self.testsuites = TestsuitesResource(self._transport)
58
+ self.notes = NotesResource(self._transport)
59
+ self.users = UsersResource(self._transport)
60
+ self.reports = ReportsResource(self._transport)
61
+
62
+ def close(self) -> None:
63
+ self._transport.close()
64
+
65
+ def __enter__(self) -> "AttackForgeClient":
66
+ return self
67
+
68
+ def __exit__(self, exc_type, exc, tb) -> None:
69
+ self.close()
70
+
71
+ def link_vulnerability_to_testcases(
72
+ self,
73
+ project_id: str,
74
+ vulnerability_id: str,
75
+ testcase_ids: Sequence[str],
76
+ *,
77
+ verify: bool = True,
78
+ attempts: int = 3,
79
+ delay: float = 1.0,
80
+ ) -> Dict[str, Any]:
81
+ """
82
+ Link a vulnerability to project testcases, updating both sides (vulnerability + testcase).
83
+
84
+ Best-effort: errors on either side do not raise by default. Returns linkage metadata.
85
+ """
86
+ candidates = [value for value in testcase_ids if value]
87
+ if not candidates:
88
+ return {"action": "noop", "linked_testcases": []}
89
+
90
+ linked_testcases = set()
91
+ try:
92
+ vuln = self.findings.get_vulnerability(vulnerability_id)
93
+ linked_testcases = self.findings.extract_linked_testcase_ids(vuln)
94
+ except APIError:
95
+ linked_testcases = set()
96
+
97
+ updated = sorted(linked_testcases.union(candidates))
98
+ if set(updated) != linked_testcases:
99
+ payload = {"linked_testcases": updated, "projectId": project_id}
100
+ try:
101
+ self.findings.update_vulnerability(vulnerability_id, payload)
102
+ except APIError:
103
+ pass
104
+
105
+ for testcase_id in candidates:
106
+ try:
107
+ testcases_data = self.testcases.get_project_testcases(project_id)
108
+ except APIError:
109
+ continue
110
+ testcase = self.testcases.find_project_testcase_entry(testcases_data, testcase_id)
111
+ if not testcase:
112
+ continue
113
+ existing = self.testcases.extract_linked_vulnerability_ids(testcase)
114
+ if vulnerability_id in existing:
115
+ continue
116
+ payload = {"linked_vulnerabilities": sorted(existing.union({vulnerability_id}))}
117
+ try:
118
+ self.testcases.update_testcase(project_id, testcase_id, payload)
119
+ except APIError:
120
+ pass
121
+
122
+ if not verify:
123
+ return {"linked_testcases": updated, "verified": False}
124
+
125
+ verified = False
126
+ for _ in range(max(attempts, 1)):
127
+ try:
128
+ vuln = self.findings.get_vulnerability(vulnerability_id)
129
+ linked_testcases = self.findings.extract_linked_testcase_ids(vuln)
130
+ except APIError:
131
+ linked_testcases = set()
132
+ missing = [tc_id for tc_id in candidates if tc_id not in linked_testcases]
133
+ if missing:
134
+ time.sleep(delay)
135
+ continue
136
+ try:
137
+ testcases_data = self.testcases.get_project_testcases(project_id)
138
+ except APIError:
139
+ time.sleep(delay)
140
+ continue
141
+ ok = True
142
+ for tc_id in candidates:
143
+ testcase = self.testcases.find_project_testcase_entry(testcases_data, tc_id)
144
+ if not testcase:
145
+ ok = False
146
+ break
147
+ linked_vulns = self.testcases.extract_linked_vulnerability_ids(testcase)
148
+ if vulnerability_id not in linked_vulns:
149
+ ok = False
150
+ break
151
+ if ok:
152
+ verified = True
153
+ break
154
+ time.sleep(delay)
155
+ return {"linked_testcases": updated, "verified": verified}
@@ -0,0 +1,61 @@
1
+ """Configuration helpers."""
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from typing import Optional
6
+
7
+ from .exceptions import ConfigError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ClientConfig:
12
+ base_url: str
13
+ api_key: str
14
+ ui_base_url: Optional[str] = None
15
+ ui_token: Optional[str] = None
16
+ timeout: float = 30.0
17
+ max_retries: int = 3
18
+ backoff_factor: float = 0.5
19
+ user_agent: str = "pyattackforge/0.0.1"
20
+ http2: bool = True
21
+ # Default visibility for newly created findings. False = pending/hidden.
22
+ default_findings_visible: bool = False
23
+ # Default substatus custom field for newly created findings.
24
+ default_findings_substatus_key: Optional[str] = "substatus"
25
+ default_findings_substatus_value: Optional[str] = "Observed"
26
+
27
+
28
+ def config_from_env() -> ClientConfig:
29
+ base_url = os.getenv("ATTACKFORGE_BASE_URL")
30
+ api_key = os.getenv("ATTACKFORGE_API_KEY")
31
+ if not base_url or not api_key:
32
+ raise ConfigError("ATTACKFORGE_BASE_URL and ATTACKFORGE_API_KEY are required")
33
+ ui_base_url = os.getenv("ATTACKFORGE_UI_BASE_URL")
34
+ ui_token = os.getenv("ATTACKFORGE_UI_TOKEN")
35
+ visible_env = os.getenv("ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT")
36
+ substatus_key_env = os.getenv("ATTACKFORGE_FINDINGS_SUBSTATUS_KEY")
37
+ substatus_value_env = os.getenv("ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE")
38
+ default_visible = False
39
+ if visible_env is not None:
40
+ default_visible = visible_env.strip().lower() in {"1", "true", "yes", "y", "visible"}
41
+ return ClientConfig(
42
+ base_url=base_url,
43
+ api_key=api_key,
44
+ ui_base_url=ui_base_url,
45
+ ui_token=ui_token,
46
+ default_findings_visible=default_visible,
47
+ default_findings_substatus_key=_normalize_default_substatus(substatus_key_env, "substatus"),
48
+ default_findings_substatus_value=_normalize_default_substatus(substatus_value_env, "Observed"),
49
+ )
50
+
51
+
52
+ def _normalize_default_substatus(value: Optional[str], default: str) -> Optional[str]:
53
+ if value is None:
54
+ return default
55
+ cleaned = value.strip()
56
+ if not cleaned:
57
+ return None
58
+ lowered = cleaned.lower()
59
+ if lowered in {"none", "null", "false", "0", "off", "disable", "disabled"}:
60
+ return None
61
+ return cleaned
@@ -0,0 +1,21 @@
1
+ """SDK exception types."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class AttackForgeError(Exception):
7
+ """Base exception for SDK errors."""
8
+
9
+
10
+ class ConfigError(AttackForgeError):
11
+ """Raised when required configuration is missing or invalid."""
12
+
13
+
14
+ class APIError(AttackForgeError):
15
+ """Raised for non-successful HTTP responses."""
16
+
17
+ def __init__(self, status_code: int, message: str, payload: Optional[dict] = None) -> None:
18
+ super().__init__(f"HTTP {status_code}: {message}")
19
+ self.status_code = status_code
20
+ self.message = message
21
+ self.payload = payload or {}
@@ -0,0 +1,23 @@
1
+ """Resource modules."""
2
+
3
+ from .assets import AssetsResource
4
+ from .projects import ProjectsResource
5
+ from .findings import FindingsResource
6
+ from .writeups import WriteupsResource
7
+ from .testcases import TestcasesResource
8
+ from .testsuites import TestsuitesResource
9
+ from .notes import NotesResource
10
+ from .users import UsersResource
11
+ from .reports import ReportsResource
12
+
13
+ __all__ = [
14
+ "AssetsResource",
15
+ "ProjectsResource",
16
+ "FindingsResource",
17
+ "WriteupsResource",
18
+ "TestcasesResource",
19
+ "TestsuitesResource",
20
+ "NotesResource",
21
+ "UsersResource",
22
+ "ReportsResource",
23
+ ]
@@ -0,0 +1,39 @@
1
+ """Resource: assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ..cache import TTLCache
8
+ from .base import BaseResource
9
+
10
+
11
+ class AssetsResource(BaseResource):
12
+ """Assets API resource wrapper."""
13
+
14
+ def __init__(self, transport) -> None: # type: ignore[override]
15
+ super().__init__(transport)
16
+ self._cache = TTLCache(default_ttl=300.0)
17
+
18
+ def create_asset_in_library(self, payload: Dict[str, Any]) -> Any:
19
+ return self._post("/api/ss/library/asset", json=payload)
20
+
21
+ def update_asset_in_library(self, asset_id: str, payload: Dict[str, Any]) -> Any:
22
+ return self._put(f"/api/ss/library/asset/{asset_id}", json=payload)
23
+
24
+ def get_assets(self, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
25
+ cache_key = "assets:list"
26
+ if params is None and not force_refresh:
27
+ cached = self._cache.get(cache_key)
28
+ if cached is not None:
29
+ return cached
30
+ data = self._get("/api/ss/assets", params=params)
31
+ if params is None:
32
+ self._cache.set(cache_key, data)
33
+ return data
34
+
35
+ def get_asset_in_library(self, asset_id: str) -> Any:
36
+ return self._get("/api/ss/library/asset", params={"id": asset_id})
37
+
38
+ def get_asset_library_assets(self, payload: Dict[str, Any]) -> Any:
39
+ return self._post("/api/ss/library/assets", json=payload)
@@ -0,0 +1,33 @@
1
+ """Base resource helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ..transport import AttackForgeTransport
8
+
9
+
10
+ class BaseResource:
11
+ def __init__(self, transport: AttackForgeTransport) -> None:
12
+ self._transport = transport
13
+
14
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
15
+ return self._transport.request("GET", path, params=params).data
16
+
17
+ def _post(self, path: str, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Any:
18
+ return self._transport.request("POST", path, json=json, params=params).data
19
+
20
+ def _put(self, path: str, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Any:
21
+ return self._transport.request("PUT", path, json=json, params=params).data
22
+
23
+ def _delete(
24
+ self,
25
+ path: str,
26
+ *,
27
+ params: Optional[Dict[str, Any]] = None,
28
+ json: Optional[Dict[str, Any]] = None,
29
+ ) -> Any:
30
+ return self._transport.request("DELETE", path, params=params, json=json).data
31
+
32
+ def _post_files(self, path: str, files: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Any:
33
+ return self._transport.request("POST", path, files=files, params=params).data