apisec-code-bolt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,528 @@
1
+ """
2
+ Configuration management for apisec-code-bolt.
3
+
4
+ Uses Pydantic for type-safe configuration with validation.
5
+ Configuration can come from:
6
+ 1. Configuration file (.codebolt.yaml)
7
+ 2. Environment variables (CODEBOLT_*)
8
+ 3. CLI arguments (highest priority)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ from pydantic import BaseModel, Field, field_validator, model_validator
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ DEFAULT_API_URL = "https://api.apisec.ai"
20
+
21
+
22
+ # =============================================================================
23
+ # File Discovery Configuration
24
+ # =============================================================================
25
+
26
+
27
+ class FileDiscoveryConfig(BaseModel):
28
+ """Configuration for file discovery."""
29
+
30
+ # Always excluded directories (hardcoded for safety)
31
+ ALWAYS_EXCLUDE: frozenset[str] = frozenset(
32
+ {
33
+ # Version control
34
+ ".git",
35
+ ".svn",
36
+ ".hg",
37
+ # Dependencies
38
+ "node_modules",
39
+ "vendor",
40
+ "venv",
41
+ ".venv",
42
+ "env",
43
+ ".env",
44
+ # Build artifacts
45
+ "build",
46
+ "dist",
47
+ "target",
48
+ "out",
49
+ ".gradle",
50
+ "__pycache__",
51
+ ".pytest_cache",
52
+ ".mypy_cache",
53
+ ".ruff_cache",
54
+ "*.egg-info",
55
+ # IDE
56
+ ".idea",
57
+ ".vscode",
58
+ ".eclipse",
59
+ # Generated
60
+ "generated",
61
+ "gen",
62
+ }
63
+ )
64
+
65
+ # User-configurable exclusions (in addition to ALWAYS_EXCLUDE)
66
+ exclude_patterns: list[str] = Field(
67
+ default_factory=lambda: [
68
+ "tests/**",
69
+ "**/*_test.py",
70
+ "**/test_*.py",
71
+ "**/migrations/**",
72
+ "**/conftest.py",
73
+ ],
74
+ description="Glob patterns to exclude from analysis",
75
+ )
76
+
77
+ include_tests: bool = Field(
78
+ default=False,
79
+ description="Whether to analyze test files",
80
+ )
81
+
82
+ max_files: int = Field(
83
+ default=10_000,
84
+ ge=1,
85
+ le=100_000,
86
+ description="Maximum number of files to analyze",
87
+ )
88
+
89
+ max_file_size_mb: float = Field(
90
+ default=10.0,
91
+ ge=0.001,
92
+ le=100.0,
93
+ description="Maximum file size in MB (skip larger files)",
94
+ )
95
+
96
+ respect_gitignore: bool = Field(
97
+ default=True,
98
+ description="Whether to respect .gitignore patterns",
99
+ )
100
+
101
+ follow_symlinks: bool = Field(
102
+ default=False,
103
+ description="Whether to follow symbolic links",
104
+ )
105
+
106
+
107
+ # =============================================================================
108
+ # Data Flow Analysis Configuration
109
+ # =============================================================================
110
+
111
+
112
+ class DataFlowConfig(BaseModel):
113
+ """Configuration for data flow analysis."""
114
+
115
+ mode: Literal["intra_procedural", "inter_procedural"] = Field(
116
+ default="inter_procedural",
117
+ description="Data flow tracking mode",
118
+ )
119
+
120
+ max_depth: int = Field(
121
+ default=10,
122
+ ge=1,
123
+ le=50,
124
+ description="Maximum call depth for inter-procedural analysis",
125
+ )
126
+
127
+ track_through_libraries: bool = Field(
128
+ default=False,
129
+ description="Whether to trace into third-party library code",
130
+ )
131
+
132
+ track_lambdas: bool = Field(
133
+ default=True,
134
+ description="Whether to track data flow through lambdas/closures",
135
+ )
136
+
137
+ track_callbacks: bool = Field(
138
+ default=True,
139
+ description="Whether to track data flow through callbacks",
140
+ )
141
+
142
+ max_flows_per_origin: int = Field(
143
+ default=100,
144
+ ge=1,
145
+ le=1000,
146
+ description="Maximum data flows to track per origin (prevents explosion)",
147
+ )
148
+
149
+
150
+ # =============================================================================
151
+ # JVM Analyzer Configuration
152
+ # =============================================================================
153
+
154
+
155
+ class JvmConfig(BaseModel):
156
+ """Configuration for JVM (Java/Kotlin) analysis."""
157
+
158
+ enabled: bool = Field(
159
+ default=True,
160
+ description="Whether to analyze JVM files (requires Java runtime)",
161
+ )
162
+
163
+ java_home: Path | None = Field(
164
+ default=None,
165
+ description="Path to Java installation (auto-detected if not set)",
166
+ )
167
+
168
+ heap_mb: int = Field(
169
+ default=512,
170
+ ge=128,
171
+ le=8192,
172
+ description="JVM heap size in MB",
173
+ )
174
+
175
+ batch_size: int = Field(
176
+ default=200,
177
+ ge=10,
178
+ le=1000,
179
+ description="Number of files per JVM batch",
180
+ )
181
+
182
+ max_parallel_batches: int = Field(
183
+ default=4,
184
+ ge=1,
185
+ le=16,
186
+ description="Maximum parallel JVM processes",
187
+ )
188
+
189
+ timeout_per_batch_seconds: int = Field(
190
+ default=300,
191
+ ge=30,
192
+ le=3600,
193
+ description="Timeout per batch in seconds",
194
+ )
195
+
196
+
197
+ # =============================================================================
198
+ # Cloud Communication Configuration
199
+ # =============================================================================
200
+
201
+
202
+ class CloudConfig(BaseModel):
203
+ """Configuration for cloud communication."""
204
+
205
+ enabled: bool = Field(
206
+ default=True,
207
+ description="Whether to upload manifests to cloud",
208
+ )
209
+
210
+ api_url: str = Field(
211
+ default=DEFAULT_API_URL,
212
+ description="Cloud API base URL",
213
+ )
214
+
215
+ api_key: str | None = Field(
216
+ default=None,
217
+ description="API key for authentication (can also use CODEBOLT_API_KEY env var)",
218
+ )
219
+
220
+ timeout_seconds: int = Field(
221
+ default=60,
222
+ ge=10,
223
+ le=300,
224
+ description="HTTP request timeout in seconds",
225
+ )
226
+
227
+ retry_attempts: int = Field(
228
+ default=3,
229
+ ge=0,
230
+ le=10,
231
+ description="Number of retry attempts for failed requests",
232
+ )
233
+
234
+ verify_ssl: bool = Field(
235
+ default=True,
236
+ description="Whether to verify SSL certificates",
237
+ )
238
+
239
+
240
+ # =============================================================================
241
+ # Query API Configuration
242
+ # =============================================================================
243
+
244
+
245
+ class QueryApiConfig(BaseModel):
246
+ """Configuration for Query API (extended analysis)."""
247
+
248
+ enabled: bool = Field(
249
+ default=True,
250
+ description="Whether to enable Query API for verification queries",
251
+ )
252
+
253
+ max_wait_seconds: int = Field(
254
+ default=300,
255
+ ge=30,
256
+ le=1800,
257
+ description="Maximum time to wait for queries from cloud",
258
+ )
259
+
260
+ max_batches: int = Field(
261
+ default=10,
262
+ ge=1,
263
+ le=50,
264
+ description="Maximum number of query batches to process",
265
+ )
266
+
267
+ poll_timeout_seconds: int = Field(
268
+ default=30,
269
+ ge=5,
270
+ le=60,
271
+ description="Long-poll timeout for each request",
272
+ )
273
+
274
+
275
+ # =============================================================================
276
+ # Analysis Configuration
277
+ # =============================================================================
278
+
279
+
280
+ class AnalysisConfig(BaseModel):
281
+ """Configuration for the analysis process."""
282
+
283
+ timeout_seconds: int = Field(
284
+ default=3600,
285
+ ge=60,
286
+ le=14400,
287
+ description="Total analysis timeout in seconds",
288
+ )
289
+
290
+ framework_hints: list[str] = Field(
291
+ default_factory=list,
292
+ description="Framework hints to guide detection (e.g., ['fastapi', 'sqlalchemy'])",
293
+ )
294
+
295
+ # Sub-configurations
296
+ file_discovery: FileDiscoveryConfig = Field(
297
+ default_factory=FileDiscoveryConfig,
298
+ )
299
+
300
+ data_flow: DataFlowConfig = Field(
301
+ default_factory=DataFlowConfig,
302
+ )
303
+
304
+ jvm: JvmConfig = Field(
305
+ default_factory=JvmConfig,
306
+ )
307
+
308
+
309
+ # =============================================================================
310
+ # Output Configuration
311
+ # =============================================================================
312
+
313
+
314
+ class OutputConfig(BaseModel):
315
+ """Configuration for output generation."""
316
+
317
+ format: Literal["json", "yaml"] = Field(
318
+ default="json",
319
+ description="Output format",
320
+ )
321
+
322
+ pretty_print: bool = Field(
323
+ default=False,
324
+ description="Whether to pretty-print output",
325
+ )
326
+
327
+ output_file: Path | None = Field(
328
+ default=None,
329
+ description="Output file path (stdout if not set)",
330
+ )
331
+
332
+ include_source_snippets: bool = Field(
333
+ default=False,
334
+ description="NEVER enable in production - would include raw code",
335
+ )
336
+
337
+ @field_validator("include_source_snippets")
338
+ @classmethod
339
+ def warn_source_snippets(cls, v: bool) -> bool:
340
+ """Warn if source snippets are enabled."""
341
+ if v:
342
+ import warnings
343
+
344
+ warnings.warn(
345
+ "include_source_snippets=True would expose raw source code. "
346
+ "This should NEVER be enabled in production.",
347
+ UserWarning,
348
+ stacklevel=2,
349
+ )
350
+ return v
351
+
352
+
353
+ # =============================================================================
354
+ # Main Configuration
355
+ # =============================================================================
356
+
357
+
358
+ class CodeBoltConfig(BaseSettings):
359
+ """
360
+ Main configuration for apisec-code-bolt.
361
+
362
+ Configuration priority (highest to lowest):
363
+ 1. CLI arguments
364
+ 2. Environment variables (CODEBOLT_*)
365
+ 3. Configuration file (.codebolt.yaml)
366
+ 4. Defaults
367
+ """
368
+
369
+ model_config = SettingsConfigDict(
370
+ env_prefix="CODEBOLT_",
371
+ env_nested_delimiter="__",
372
+ case_sensitive=False,
373
+ extra="ignore",
374
+ )
375
+
376
+ # Project settings
377
+ project_root: Path = Field(
378
+ default_factory=Path.cwd,
379
+ description="Root directory of the project to analyze",
380
+ )
381
+
382
+ config_file: Path | None = Field(
383
+ default=None,
384
+ description="Path to configuration file",
385
+ )
386
+
387
+ # Sub-configurations
388
+ analysis: AnalysisConfig = Field(
389
+ default_factory=AnalysisConfig,
390
+ )
391
+
392
+ cloud: CloudConfig = Field(
393
+ default_factory=CloudConfig,
394
+ )
395
+
396
+ query_api: QueryApiConfig = Field(
397
+ default_factory=QueryApiConfig,
398
+ )
399
+
400
+ output: OutputConfig = Field(
401
+ default_factory=OutputConfig,
402
+ )
403
+
404
+ # Runtime flags
405
+ verbose: bool = Field(
406
+ default=False,
407
+ description="Enable verbose output",
408
+ )
409
+
410
+ quiet: bool = Field(
411
+ default=False,
412
+ description="Suppress non-essential output",
413
+ )
414
+
415
+ debug: bool = Field(
416
+ default=False,
417
+ description="Enable debug mode",
418
+ )
419
+
420
+ @model_validator(mode="after")
421
+ def validate_config(self) -> CodeBoltConfig:
422
+ """Validate configuration consistency."""
423
+ if self.verbose and self.quiet:
424
+ raise ValueError("Cannot enable both verbose and quiet modes")
425
+
426
+ if not self.project_root.exists():
427
+ raise ValueError(f"Project root does not exist: {self.project_root}")
428
+
429
+ if not self.project_root.is_dir():
430
+ raise ValueError(f"Project root is not a directory: {self.project_root}")
431
+
432
+ return self
433
+
434
+ @classmethod
435
+ def from_file(cls, config_path: Path) -> CodeBoltConfig:
436
+ """Load configuration from a YAML file."""
437
+ import yaml
438
+
439
+ if not config_path.exists():
440
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
441
+
442
+ with open(config_path) as f:
443
+ data = yaml.safe_load(f) or {}
444
+
445
+ return cls(**data)
446
+
447
+ @classmethod
448
+ def from_cli(
449
+ cls,
450
+ project_root: Path,
451
+ config_file: Path | None = None,
452
+ **overrides: Any,
453
+ ) -> CodeBoltConfig:
454
+ """
455
+ Create configuration from CLI arguments.
456
+
457
+ Merges config file (if provided) with CLI overrides.
458
+ """
459
+ base_config: dict[str, Any] = {}
460
+
461
+ # Load from config file if provided
462
+ if config_file and config_file.exists():
463
+ import yaml
464
+
465
+ with open(config_file) as f:
466
+ base_config = yaml.safe_load(f) or {}
467
+
468
+ # Apply CLI overrides
469
+ base_config["project_root"] = project_root
470
+ if config_file:
471
+ base_config["config_file"] = config_file
472
+
473
+ # Deep merge overrides
474
+ for key, value in overrides.items():
475
+ if value is not None:
476
+ cls._deep_set(base_config, key, value)
477
+
478
+ return cls(**base_config)
479
+
480
+ @staticmethod
481
+ def _deep_set(d: dict[str, Any], key: str, value: Any) -> None:
482
+ """Set a nested key in a dictionary."""
483
+ keys = key.split(".")
484
+ for k in keys[:-1]:
485
+ d = d.setdefault(k, {})
486
+ d[keys[-1]] = value
487
+
488
+
489
+ # =============================================================================
490
+ # Configuration Helpers
491
+ # =============================================================================
492
+
493
+
494
+ def get_default_config() -> CodeBoltConfig:
495
+ """Get default configuration."""
496
+ return CodeBoltConfig()
497
+
498
+
499
+ def load_config(
500
+ project_root: Path | None = None,
501
+ config_file: Path | None = None,
502
+ ) -> CodeBoltConfig:
503
+ """
504
+ Load configuration with auto-detection.
505
+
506
+ Looks for .codebolt.yaml in project root if config_file not specified.
507
+ """
508
+ root = project_root or Path.cwd()
509
+
510
+ # Auto-detect config file
511
+ if config_file is None:
512
+ for name in [
513
+ ".surface.yaml",
514
+ ".surface.yml",
515
+ ".codebolt.yaml",
516
+ ".codebolt.yml",
517
+ "codebolt.yaml",
518
+ "codebolt.yml",
519
+ ]:
520
+ candidate = root / name
521
+ if candidate.exists():
522
+ config_file = candidate
523
+ break
524
+
525
+ if config_file:
526
+ return CodeBoltConfig.from_file(config_file)
527
+
528
+ return CodeBoltConfig(project_root=root)
@@ -0,0 +1,65 @@
1
+ """API key credential storage for the CLI.
2
+
3
+ Stores credentials in ~/.config/apisec-code-bolt/credentials.json.
4
+ The file is created with restricted permissions (600).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import stat
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ CONFIG_DIR = Path.home() / ".config" / "apisec-code-bolt"
19
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
20
+
21
+
22
+ @dataclass
23
+ class StoredCredentials:
24
+ """Stored API credentials."""
25
+
26
+ api_key: str
27
+ api_url: str
28
+
29
+
30
+ def store_credentials(api_key: str, api_url: str) -> Path:
31
+ """Store API key and URL. Creates directory and file with restricted permissions."""
32
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
33
+ os.chmod(CONFIG_DIR, stat.S_IRWXU)
34
+ data = json.dumps({"api_key": api_key, "api_url": api_url}, indent=2)
35
+ fd = os.open(
36
+ str(CREDENTIALS_FILE),
37
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
38
+ 0o600,
39
+ )
40
+ with os.fdopen(fd, "w") as f:
41
+ f.write(data)
42
+ logger.info("Credentials stored at %s", CREDENTIALS_FILE)
43
+ return CREDENTIALS_FILE
44
+
45
+
46
+ def load_credentials() -> StoredCredentials | None:
47
+ """Load stored credentials. Returns None if not found."""
48
+ if not CREDENTIALS_FILE.exists():
49
+ return None
50
+ try:
51
+ data = json.loads(CREDENTIALS_FILE.read_text())
52
+ return StoredCredentials(
53
+ api_key=data.get("api_key", ""),
54
+ api_url=data.get("api_url", ""),
55
+ )
56
+ except Exception:
57
+ logger.warning("Failed to load credentials from %s", CREDENTIALS_FILE, exc_info=True)
58
+ return None
59
+
60
+
61
+ def clear_credentials() -> None:
62
+ """Remove stored credentials."""
63
+ if CREDENTIALS_FILE.exists():
64
+ CREDENTIALS_FILE.unlink()
65
+ logger.info("Credentials removed")