codedd-cli 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 (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,324 @@
1
+ """
2
+ Local vulnerability validation for CLI audits.
3
+
4
+ Runs source-aware validation on the user's machine for flagged findings and
5
+ returns structured conclusions compatible with the server upsert endpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Callable, Optional
15
+
16
+ import httpx
17
+
18
+ from codedd_cli.llm.key_manager import PROVIDER_MODELS
19
+
20
+ _ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
21
+ _OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
22
+ _LLM_TIMEOUT = 120.0
23
+ _MAX_FILE_CHARS = 120_000
24
+ _MAX_EVIDENCE_CHARS = 24_000
25
+
26
+
27
+ @dataclass
28
+ class ValidationCandidate:
29
+ file_path: str
30
+ local_path: str
31
+ hypothesis: str
32
+ flag_color: str
33
+ stored_time_to_fix_hours: Optional[float] = None
34
+
35
+
36
+ @dataclass
37
+ class ValidationOutcome:
38
+ file_path: str
39
+ conclusion: str
40
+ impact: str
41
+ confidence: float
42
+ recommended_action: str
43
+ revised_time_to_fix_hours: Optional[float]
44
+ provider_used: Optional[str] = None
45
+ error: Optional[str] = None
46
+
47
+ @property
48
+ def success(self) -> bool:
49
+ return self.error is None
50
+
51
+
52
+ def _parse_line_hints(hypothesis: str) -> list[int]:
53
+ if not hypothesis:
54
+ return []
55
+ line_numbers: list[int] = []
56
+ patterns = [
57
+ r"\bline\s+(\d+)",
58
+ r"\blines?\s+(\d+)",
59
+ r"\b(\d+):\d+",
60
+ ]
61
+ for pattern in patterns:
62
+ for match in re.finditer(pattern, hypothesis, re.IGNORECASE):
63
+ try:
64
+ ln = int(match.group(1))
65
+ except (TypeError, ValueError):
66
+ continue
67
+ if 1 <= ln <= 200_000:
68
+ line_numbers.append(ln)
69
+ return sorted(set(line_numbers))
70
+
71
+
72
+ def _build_evidence(content: str, line_hints: list[int]) -> str:
73
+ lines = content.splitlines()
74
+ if not lines:
75
+ return ""
76
+
77
+ if line_hints:
78
+ chunks: list[str] = []
79
+ for ln in line_hints[:5]:
80
+ start = max(1, ln - 20)
81
+ end = min(len(lines), ln + 20)
82
+ snippet = "\n".join(f"{idx}|{lines[idx - 1]}" for idx in range(start, end + 1))
83
+ chunks.append(f"Snippet around line {ln}:\n{snippet}")
84
+ evidence = "\n\n".join(chunks)
85
+ else:
86
+ # Fall back to head + tail for broad context.
87
+ head = "\n".join(f"{idx}|{lines[idx - 1]}" for idx in range(1, min(len(lines), 140) + 1))
88
+ tail_start = max(1, len(lines) - 120)
89
+ tail = "\n".join(f"{idx}|{lines[idx - 1]}" for idx in range(tail_start, len(lines) + 1))
90
+ evidence = f"Head snippet:\n{head}\n\nTail snippet:\n{tail}"
91
+
92
+ if len(evidence) > _MAX_EVIDENCE_CHARS:
93
+ evidence = evidence[:_MAX_EVIDENCE_CHARS]
94
+ return evidence
95
+
96
+
97
+ def _extract_json(text: str) -> dict:
98
+ payload = text.strip()
99
+ try:
100
+ obj = json.loads(payload)
101
+ return obj if isinstance(obj, dict) else {}
102
+ except Exception:
103
+ pass
104
+ match = re.search(r"\{[\s\S]*\}", payload)
105
+ if not match:
106
+ return {}
107
+ try:
108
+ obj = json.loads(match.group(0))
109
+ return obj if isinstance(obj, dict) else {}
110
+ except Exception:
111
+ return {}
112
+
113
+
114
+ class LocalVulnerabilityValidator:
115
+ """
116
+ Source-aware vulnerability validator for CLI audits.
117
+
118
+ Uses local source files + the flagged hypothesis to produce a structured
119
+ validation result similar to the server's vulnerability_validation output.
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ anthropic_key: str | None,
125
+ openai_key: str | None,
126
+ provider_preference: str = "both",
127
+ system_prompt: Optional[str] = None,
128
+ on_debug: Optional[Callable[[str], None]] = None,
129
+ ) -> None:
130
+ self._anthropic_key = anthropic_key
131
+ self._openai_key = openai_key
132
+ self._pref = provider_preference
133
+ self._on_debug = on_debug
134
+ self._system_prompt = system_prompt or (
135
+ "You are a security validation agent. "
136
+ "Assess whether the reported vulnerability is valid based on provided source snippets. "
137
+ "Respond with JSON only."
138
+ )
139
+
140
+ self._anthropic = None
141
+ self._openai = None
142
+ if anthropic_key:
143
+ self._anthropic = httpx.Client(
144
+ base_url="https://api.anthropic.com",
145
+ headers={
146
+ "x-api-key": anthropic_key,
147
+ "anthropic-version": "2023-06-01",
148
+ "content-type": "application/json",
149
+ },
150
+ timeout=_LLM_TIMEOUT,
151
+ )
152
+ if openai_key:
153
+ self._openai = httpx.Client(
154
+ base_url="https://api.openai.com",
155
+ headers={
156
+ "Authorization": f"Bearer {openai_key}",
157
+ "Content-Type": "application/json",
158
+ },
159
+ timeout=_LLM_TIMEOUT,
160
+ )
161
+
162
+ def close(self) -> None:
163
+ if self._anthropic:
164
+ self._anthropic.close()
165
+ if self._openai:
166
+ self._openai.close()
167
+
168
+ def __enter__(self):
169
+ return self
170
+
171
+ def __exit__(self, *_):
172
+ self.close()
173
+
174
+ def _debug(self, msg: str) -> None:
175
+ if self._on_debug:
176
+ self._on_debug(msg)
177
+
178
+ def _provider_order(self) -> list[str]:
179
+ has_a = self._anthropic is not None
180
+ has_o = self._openai is not None
181
+ if self._pref == "anthropic":
182
+ return [p for p in ("anthropic", "openai") if (p == "anthropic" and has_a) or (p == "openai" and has_o)]
183
+ if self._pref == "openai":
184
+ return [p for p in ("openai", "anthropic") if (p == "openai" and has_o) or (p == "anthropic" and has_a)]
185
+ order = []
186
+ if has_a:
187
+ order.append("anthropic")
188
+ if has_o:
189
+ order.append("openai")
190
+ return order
191
+
192
+ def _call_anthropic(self, user_prompt: str) -> tuple[bool, str]:
193
+ if not self._anthropic:
194
+ return False, ""
195
+ model = PROVIDER_MODELS.get("anthropic", "claude-sonnet-4-6")
196
+ payload = {
197
+ "model": model,
198
+ "max_tokens": 1800,
199
+ "temperature": 0.0,
200
+ "system": self._system_prompt,
201
+ "messages": [{"role": "user", "content": user_prompt}],
202
+ }
203
+ try:
204
+ resp = self._anthropic.post(_ANTHROPIC_MESSAGES_URL, json=payload)
205
+ if resp.status_code != 200:
206
+ return False, ""
207
+ body = resp.json()
208
+ text = (body.get("content") or [{}])[0].get("text", "")
209
+ return bool(text), text or ""
210
+ except Exception:
211
+ return False, ""
212
+
213
+ def _call_openai(self, combined_prompt: str) -> tuple[bool, str]:
214
+ if not self._openai:
215
+ return False, ""
216
+ model = PROVIDER_MODELS.get("openai", "gpt-5.2")
217
+ payload = {
218
+ "model": model,
219
+ "temperature": 0.0,
220
+ "messages": [{"role": "user", "content": combined_prompt}],
221
+ }
222
+ try:
223
+ resp = self._openai.post(_OPENAI_CHAT_URL, json=payload)
224
+ if resp.status_code != 200:
225
+ return False, ""
226
+ body = resp.json()
227
+ text = ((body.get("choices") or [{}])[0].get("message") or {}).get("content", "")
228
+ return bool(text), text or ""
229
+ except Exception:
230
+ return False, ""
231
+
232
+ def _validate_single(self, c: ValidationCandidate) -> ValidationOutcome:
233
+ try:
234
+ content = Path(c.local_path).read_text(encoding="utf-8", errors="replace")
235
+ except Exception as exc:
236
+ return ValidationOutcome(
237
+ file_path=c.file_path,
238
+ conclusion="Inconclusive",
239
+ impact="N/A",
240
+ confidence=0.0,
241
+ recommended_action="N/A",
242
+ revised_time_to_fix_hours=c.stored_time_to_fix_hours,
243
+ error=f"Cannot read local file: {exc}",
244
+ )
245
+
246
+ if len(content) > _MAX_FILE_CHARS:
247
+ content = content[:_MAX_FILE_CHARS]
248
+
249
+ line_hints = _parse_line_hints(c.hypothesis)
250
+ evidence = _build_evidence(content, line_hints)
251
+ schema = {
252
+ "conclusion": "Valid | Not Vulnerable | Inconclusive",
253
+ "confidence": "0.0-1.0",
254
+ "impact": "brief impact statement",
255
+ "recommended_action": "actionable remediation guidance",
256
+ "revised_estimate_hours": "number in hours",
257
+ }
258
+ user_prompt = (
259
+ f"VULNERABILITY CLAIM: {c.hypothesis}\n"
260
+ f"FLAG COLOR: {c.flag_color}\n"
261
+ f"TARGET FILE: {c.file_path}\n"
262
+ f"STORED TIME TO FIX (hours): {c.stored_time_to_fix_hours}\n\n"
263
+ f"EVIDENCE:\n{evidence}\n\n"
264
+ f"Return JSON only with schema: {json.dumps(schema)}"
265
+ )
266
+
267
+ providers = self._provider_order()
268
+ for provider in providers:
269
+ ok = False
270
+ text = ""
271
+ if provider == "anthropic":
272
+ ok, text = self._call_anthropic(user_prompt)
273
+ else:
274
+ combined = f"{self._system_prompt}\n\n{user_prompt}"
275
+ ok, text = self._call_openai(combined)
276
+
277
+ if not ok or not text:
278
+ continue
279
+
280
+ parsed = _extract_json(text)
281
+ if not parsed:
282
+ continue
283
+
284
+ conclusion = str(parsed.get("conclusion", "Inconclusive")).strip() or "Inconclusive"
285
+ impact = str(parsed.get("impact", "N/A")).strip() or "N/A"
286
+ action = str(parsed.get("recommended_action", "N/A")).strip() or "N/A"
287
+ try:
288
+ confidence = float(parsed.get("confidence", 0.5))
289
+ except (TypeError, ValueError):
290
+ confidence = 0.5
291
+ confidence = max(0.0, min(1.0, confidence))
292
+ revised = parsed.get("revised_estimate_hours", c.stored_time_to_fix_hours)
293
+ try:
294
+ revised = float(revised) if revised is not None else None
295
+ except (TypeError, ValueError):
296
+ revised = c.stored_time_to_fix_hours
297
+
298
+ return ValidationOutcome(
299
+ file_path=c.file_path,
300
+ conclusion=conclusion,
301
+ impact=impact,
302
+ confidence=confidence,
303
+ recommended_action=action,
304
+ revised_time_to_fix_hours=revised,
305
+ provider_used=provider,
306
+ )
307
+
308
+ return ValidationOutcome(
309
+ file_path=c.file_path,
310
+ conclusion="Inconclusive",
311
+ impact="Insufficient evidence for deterministic validation.",
312
+ confidence=0.25,
313
+ recommended_action="Review the flagged file manually and run targeted security tests.",
314
+ revised_time_to_fix_hours=c.stored_time_to_fix_hours,
315
+ error="LLM validation failed or unparsable response",
316
+ )
317
+
318
+ def validate(self, candidates: list[ValidationCandidate]) -> list[ValidationOutcome]:
319
+ outcomes: list[ValidationOutcome] = []
320
+ for c in candidates:
321
+ self._debug(f"Validating finding locally: {c.file_path}")
322
+ outcomes.append(self._validate_single(c))
323
+ return outcomes
324
+
@@ -0,0 +1,4 @@
1
+ from codedd_cli.auth.token_manager import TokenManager
2
+ from codedd_cli.auth.session import require_auth
3
+
4
+ __all__ = ["TokenManager", "require_auth"]
@@ -0,0 +1,41 @@
1
+ """
2
+ Session helpers for ensuring the user is authenticated before running
3
+ commands that require a token.
4
+ """
5
+
6
+ import functools
7
+ from typing import Callable
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from codedd_cli.auth.token_manager import TokenManager
13
+ from codedd_cli.config.settings import ConfigManager
14
+
15
+ console = Console()
16
+
17
+
18
+ def require_auth(func: Callable) -> Callable:
19
+ """
20
+ Decorator that aborts a CLI command when the user is not authenticated.
21
+
22
+ It checks both the config file (for session metadata) and the keyring
23
+ (for the actual token). If either is missing it prints a helpful
24
+ message and exits with code 1.
25
+ """
26
+
27
+ @functools.wraps(func)
28
+ def wrapper(*args, **kwargs):
29
+ cfg = ConfigManager()
30
+ token = TokenManager.retrieve()
31
+
32
+ if not cfg.is_authenticated or not token:
33
+ console.print(
34
+ "[bold red]Not authenticated.[/bold red] "
35
+ "Run [bold cyan]codedd auth login[/bold cyan] first."
36
+ )
37
+ raise typer.Exit(code=1)
38
+
39
+ return func(*args, **kwargs)
40
+
41
+ return wrapper
@@ -0,0 +1,87 @@
1
+ """
2
+ Secure token storage and retrieval.
3
+
4
+ The CLI token is stored in the operating system's credential store via
5
+ the ``keyring`` library (Windows Credential Locker, macOS Keychain,
6
+ Linux Secret Service / KWallet). A non-sensitive hint (last 4 chars)
7
+ is kept in the TOML config file for display purposes.
8
+
9
+ Falls back to the ``CODEDD_API_TOKEN`` environment variable when no
10
+ keyring entry exists (useful for CI/CD and headless environments).
11
+ """
12
+
13
+ import os
14
+ from typing import Optional
15
+
16
+ import keyring
17
+
18
+ from codedd_cli.config.constants import (
19
+ CLI_TOKEN_PREFIX,
20
+ KEYRING_SERVICE,
21
+ KEYRING_TOKEN_KEY,
22
+ )
23
+
24
+
25
+ class TokenManager:
26
+ """Manage CLI token persistence in the OS keychain."""
27
+
28
+ @staticmethod
29
+ def store(token: str) -> None:
30
+ """
31
+ Persist a CLI token in the OS keychain.
32
+
33
+ Args:
34
+ token: The raw ``codedd_cli_...`` token string.
35
+
36
+ Raises:
37
+ keyring.errors.KeyringError: If the keychain is unavailable.
38
+ """
39
+ keyring.set_password(KEYRING_SERVICE, KEYRING_TOKEN_KEY, token)
40
+
41
+ @staticmethod
42
+ def retrieve() -> Optional[str]:
43
+ """
44
+ Retrieve the CLI token.
45
+
46
+ Resolution order:
47
+ 1. ``CODEDD_API_TOKEN`` environment variable
48
+ 2. OS keychain entry
49
+
50
+ Returns:
51
+ The token string, or None if not found.
52
+ """
53
+ # Environment variable takes precedence (CI/CD support)
54
+ env_token = os.environ.get("CODEDD_API_TOKEN")
55
+ if env_token and env_token.startswith(CLI_TOKEN_PREFIX):
56
+ return env_token
57
+
58
+ try:
59
+ token = keyring.get_password(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
60
+ return token if token else None
61
+ except Exception:
62
+ return None
63
+
64
+ @staticmethod
65
+ def delete() -> None:
66
+ """
67
+ Remove the CLI token from the OS keychain.
68
+
69
+ Silently succeeds if no entry exists.
70
+ """
71
+ try:
72
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
73
+ except keyring.errors.PasswordDeleteError:
74
+ pass # Already absent
75
+ except Exception:
76
+ pass
77
+
78
+ @staticmethod
79
+ def token_hint(token: str) -> str:
80
+ """
81
+ Derive a non-sensitive hint from a token for display.
82
+
83
+ Example: ``codedd_cli_...xYz9``
84
+ """
85
+ if len(token) > 8:
86
+ return f"{CLI_TOKEN_PREFIX}...{token[-4:]}"
87
+ return "***"
codedd_cli/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Root CLI application.
3
+
4
+ Defines the top-level ``codedd`` command group and registers sub-command
5
+ modules for ``auth``, ``audits``, ``config``.
6
+ """
7
+
8
+ import click
9
+ import typer
10
+ from typer.core import TyperGroup
11
+
12
+ from codedd_cli import __version__
13
+ from codedd_cli.utils.display import print_banner
14
+
15
+
16
+ class _CodeDDTyperGroup(TyperGroup):
17
+ """
18
+ Custom Typer group that prints the CodeDD banner before the root help.
19
+ Only top-level ``codedd --help`` (or ``codedd`` with no args) shows the banner.
20
+ """
21
+
22
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
23
+ if ctx.parent is None:
24
+ print_banner()
25
+ super().format_help(ctx, formatter)
26
+
27
+
28
+ app = typer.Typer(
29
+ name="codedd",
30
+ help="CodeDD CLI — run code audits from your terminal.",
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ cls=_CodeDDTyperGroup,
34
+ )
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ typer.echo(f"codedd-cli {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: bool = typer.Option(
46
+ False,
47
+ "--version",
48
+ "-v",
49
+ help="Show the CLI version and exit.",
50
+ callback=_version_callback,
51
+ is_eager=True,
52
+ ),
53
+ ) -> None:
54
+ """CodeDD CLI — run code audits from your terminal."""
55
+
56
+
57
+ # ---- Register sub-command groups ----
58
+
59
+ from codedd_cli.commands.auth_cmd import auth_app # noqa: E402
60
+ from codedd_cli.commands.audits_cmd import audits_app # noqa: E402
61
+ from codedd_cli.commands.config_cmd import config_app # noqa: E402
62
+ from codedd_cli.commands.scope_cmd import scope_app # noqa: E402
63
+ from codedd_cli.commands.audit_cmd import audit_app # noqa: E402
64
+
65
+ app.add_typer(auth_app, name="auth", help="Authenticate with the CodeDD platform.")
66
+ app.add_typer(audits_app, name="audits", help="List and select audits.")
67
+ app.add_typer(scope_app, name="scope", help="Manage local directories for the active audit.")
68
+ app.add_typer(audit_app, name="audit", help="Start and manage audits.")
69
+ app.add_typer(config_app, name="config", help="View and update CLI configuration.")
@@ -0,0 +1 @@
1
+