icsf-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.
- backend/__init__.py +7 -0
- backend/cli.py +202 -0
- backend/cli_api.py +409 -0
- backend/config.py +76 -0
- backend/diag_auth.py +16 -0
- backend/logging_config.py +18 -0
- backend/main.py +1644 -0
- icsf_cli-0.1.0.dist-info/METADATA +1095 -0
- icsf_cli-0.1.0.dist-info/RECORD +12 -0
- icsf_cli-0.1.0.dist-info/WHEEL +5 -0
- icsf_cli-0.1.0.dist-info/entry_points.txt +2 -0
- icsf_cli-0.1.0.dist-info/top_level.txt +1 -0
backend/__init__.py
ADDED
backend/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ICSF Command-Line Interface entrypoint.
|
|
3
|
+
|
|
4
|
+
This module exposes a small CLI wrapper around the core `cli_api` helpers.
|
|
5
|
+
Users install the package and then run:
|
|
6
|
+
|
|
7
|
+
icsf run --credentials backend/credentials.yaml --csv report.csv --auto-pr --run-tests
|
|
8
|
+
|
|
9
|
+
to execute an end-to-end flow that mirrors the web application:
|
|
10
|
+
mapping → baseline testing → fixing → validation testing → PR creation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from . import cli_api
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _configure_logging(verbose: bool) -> None:
|
|
25
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=level,
|
|
28
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="icsf",
|
|
35
|
+
description="ICSF – Intelligent Code Security & Fixing Platform (CLI)",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
|
|
40
|
+
# End-to-end command: map → baseline → fix → validate → PR.
|
|
41
|
+
run_parser = subparsers.add_parser(
|
|
42
|
+
"run",
|
|
43
|
+
help="Run end-to-end mapping, fixing, testing, and PR creation.",
|
|
44
|
+
)
|
|
45
|
+
run_parser.add_argument(
|
|
46
|
+
"--credentials",
|
|
47
|
+
type=str,
|
|
48
|
+
default=None,
|
|
49
|
+
help=(
|
|
50
|
+
"Path to credentials YAML containing GitHub token/username/email. "
|
|
51
|
+
"If omitted, backend/credentials.yaml is used."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
run_parser.add_argument(
|
|
55
|
+
"--csv",
|
|
56
|
+
type=str,
|
|
57
|
+
required=True,
|
|
58
|
+
help="Path to vulnerability CSV report.",
|
|
59
|
+
)
|
|
60
|
+
run_parser.add_argument(
|
|
61
|
+
"--auto-pr",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="Automatically create a single batch PR per repository.",
|
|
64
|
+
)
|
|
65
|
+
run_parser.add_argument(
|
|
66
|
+
"--no-auto-pr",
|
|
67
|
+
dest="auto_pr",
|
|
68
|
+
action="store_false",
|
|
69
|
+
help="Do not create PRs (default is off unless --auto-pr is set).",
|
|
70
|
+
)
|
|
71
|
+
run_parser.set_defaults(auto_pr=False)
|
|
72
|
+
|
|
73
|
+
run_parser.add_argument(
|
|
74
|
+
"--run-tests",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Run baseline + validation testing via Atlas.",
|
|
77
|
+
)
|
|
78
|
+
run_parser.add_argument(
|
|
79
|
+
"--no-run-tests",
|
|
80
|
+
dest="run_tests",
|
|
81
|
+
action="store_false",
|
|
82
|
+
help="Skip all testing (baseline & validation).",
|
|
83
|
+
)
|
|
84
|
+
run_parser.set_defaults(run_tests=True)
|
|
85
|
+
|
|
86
|
+
run_parser.add_argument(
|
|
87
|
+
"--repo-filter",
|
|
88
|
+
type=str,
|
|
89
|
+
default=None,
|
|
90
|
+
help=(
|
|
91
|
+
"Only process repositories whose name or full_name contains this "
|
|
92
|
+
"substring (case-insensitive)."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
run_parser.add_argument(
|
|
96
|
+
"--output",
|
|
97
|
+
type=str,
|
|
98
|
+
default=None,
|
|
99
|
+
help="Optional path to write a full JSON report of the run.",
|
|
100
|
+
)
|
|
101
|
+
run_parser.add_argument(
|
|
102
|
+
"-v",
|
|
103
|
+
"--verbose",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Enable verbose logging for debugging.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return parser
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _handle_run(args: argparse.Namespace) -> int:
|
|
112
|
+
_configure_logging(verbose=args.verbose)
|
|
113
|
+
logger = logging.getLogger("icsf.cli")
|
|
114
|
+
|
|
115
|
+
credentials_path: Optional[Path]
|
|
116
|
+
if args.credentials:
|
|
117
|
+
credentials_path = Path(args.credentials).resolve()
|
|
118
|
+
else:
|
|
119
|
+
# Fallback to backend/credentials.yaml relative to this file.
|
|
120
|
+
credentials_path = (
|
|
121
|
+
Path(__file__).resolve().parent / "credentials.yaml"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
csv_path = Path(args.csv).resolve()
|
|
125
|
+
if not csv_path.exists():
|
|
126
|
+
logger.error("CSV file not found: %s", csv_path)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
logger.info("ICSF CLI – end-to-end run starting")
|
|
130
|
+
logger.info("Credentials: %s", credentials_path)
|
|
131
|
+
logger.info("CSV report: %s", csv_path)
|
|
132
|
+
|
|
133
|
+
async def _run() -> int:
|
|
134
|
+
try:
|
|
135
|
+
summary = await cli_api.run_end_to_end_cli(
|
|
136
|
+
credentials_path=credentials_path,
|
|
137
|
+
csv_path=csv_path,
|
|
138
|
+
auto_create_pr=args.auto_pr,
|
|
139
|
+
run_tests=args.run_tests,
|
|
140
|
+
repo_filter=args.repo_filter,
|
|
141
|
+
min_severity=None, # TODO: wire severity filtering if desired
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
144
|
+
logger.exception("End-to-end run failed: %s", exc)
|
|
145
|
+
return 1
|
|
146
|
+
|
|
147
|
+
total_repos = summary.get("total_repos_with_vulns", 0)
|
|
148
|
+
processed = summary.get("total_repos_processed", 0)
|
|
149
|
+
logger.info(
|
|
150
|
+
"End-to-end run completed – mapped repos: %d, processed repos: %d",
|
|
151
|
+
total_repos,
|
|
152
|
+
processed,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Print a very small human-readable summary to stdout.
|
|
156
|
+
print("\n=== ICSF CLI Summary ===")
|
|
157
|
+
print(f"Repositories with vulnerabilities: {total_repos}")
|
|
158
|
+
print(f"Repositories processed: {processed}")
|
|
159
|
+
|
|
160
|
+
# Surface PR URLs if present.
|
|
161
|
+
results = summary.get("results", []) or []
|
|
162
|
+
pr_count = 0
|
|
163
|
+
for repo_result in results:
|
|
164
|
+
repo = repo_result.get("repo", {}) or {}
|
|
165
|
+
full_name = repo.get("full_name") or repo.get("name")
|
|
166
|
+
batch_results = repo_result.get("batch_results") or {}
|
|
167
|
+
batch_pr = batch_results.get("batch_pr_result") or {}
|
|
168
|
+
if batch_pr.get("success"):
|
|
169
|
+
pr_count += 1
|
|
170
|
+
pr_url = batch_pr.get("pr_url", "N/A")
|
|
171
|
+
pr_number = batch_pr.get("pr_number", "N/A")
|
|
172
|
+
print(f"- {full_name}: PR #{pr_number} -> {pr_url}")
|
|
173
|
+
|
|
174
|
+
if pr_count == 0 and args.auto_pr:
|
|
175
|
+
print("No PRs were created (auto-pr enabled but none succeeded).")
|
|
176
|
+
|
|
177
|
+
# Optionally write full JSON report.
|
|
178
|
+
if args.output:
|
|
179
|
+
out_path = Path(args.output).resolve()
|
|
180
|
+
out_path.write_text(cli_api.to_pretty_json(summary), encoding="utf-8")
|
|
181
|
+
print(f"\nFull report written to: {out_path}")
|
|
182
|
+
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
return asyncio.run(_run())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
189
|
+
parser = build_parser()
|
|
190
|
+
args = parser.parse_args(argv)
|
|
191
|
+
|
|
192
|
+
if args.command == "run":
|
|
193
|
+
return _handle_run(args)
|
|
194
|
+
|
|
195
|
+
# Fallback – should not be reached because subparsers are required.
|
|
196
|
+
parser.print_help()
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__": # pragma: no cover
|
|
201
|
+
raise SystemExit(main())
|
|
202
|
+
|
backend/cli_api.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli_api.py
|
|
3
|
+
|
|
4
|
+
Core Python API used by the ICSF CLI.
|
|
5
|
+
|
|
6
|
+
This module is intentionally **web-framework agnostic** – it reuses the same
|
|
7
|
+
services that the FastAPI backend uses (GitHubService, VulnerabilityService,
|
|
8
|
+
BatchFixService, Atlas testing service) but exposes them as simple functions
|
|
9
|
+
that can be called from a command-line entrypoint.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import asdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
# Ensure the backend directory is on sys.path so absolute imports like
|
|
25
|
+
# `config`, `services.*`, and `models.*` work the same way they do in main.py
|
|
26
|
+
BACKEND_DIR = Path(__file__).resolve().parent
|
|
27
|
+
if str(BACKEND_DIR) not in sys.path:
|
|
28
|
+
sys.path.append(str(BACKEND_DIR))
|
|
29
|
+
|
|
30
|
+
from config import Config
|
|
31
|
+
from services.github_service import GitHubService
|
|
32
|
+
from services.vulnerability_service import VulnerabilityService
|
|
33
|
+
from services.bedrock_service import BedrockService
|
|
34
|
+
from services.batch_fix_service import BatchFixService
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_github_credentials_from_file(
|
|
40
|
+
credentials_path: Optional[Path],
|
|
41
|
+
) -> Tuple[str, Optional[str], Optional[str]]:
|
|
42
|
+
"""
|
|
43
|
+
Load GitHub token/username/email for CLI usage.
|
|
44
|
+
|
|
45
|
+
Precedence:
|
|
46
|
+
1. Explicit credentials YAML path if provided
|
|
47
|
+
2. Fallback to Config.get_github_credentials() (backend/credentials.yaml)
|
|
48
|
+
"""
|
|
49
|
+
token: Optional[str]
|
|
50
|
+
username: Optional[str]
|
|
51
|
+
email: Optional[str]
|
|
52
|
+
|
|
53
|
+
if credentials_path is not None:
|
|
54
|
+
if not credentials_path.exists():
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
f"Credentials file not found: {credentials_path}"
|
|
57
|
+
)
|
|
58
|
+
raw = credentials_path.read_text(encoding="utf-8")
|
|
59
|
+
data = yaml.safe_load(raw) or {}
|
|
60
|
+
gh = data.get("github", {}) or {}
|
|
61
|
+
token = gh.get("token")
|
|
62
|
+
username = gh.get("username")
|
|
63
|
+
email = gh.get("email")
|
|
64
|
+
logger.info(
|
|
65
|
+
"Loaded GitHub credentials from %s (username=%s, email=%s)",
|
|
66
|
+
credentials_path,
|
|
67
|
+
username or "N/A",
|
|
68
|
+
email or "N/A",
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
creds = Config.get_github_credentials(force_reload=True)
|
|
72
|
+
token = creds.get("token")
|
|
73
|
+
username = creds.get("username")
|
|
74
|
+
email = creds.get("email")
|
|
75
|
+
logger.info(
|
|
76
|
+
"Loaded GitHub credentials from default backend/credentials.yaml "
|
|
77
|
+
"(username=%s, email=%s)",
|
|
78
|
+
username or "N/A",
|
|
79
|
+
email or "N/A",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not token:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"GitHub token is required but was not found in credentials."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return token, username, email
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def fetch_repositories_for_cli(
|
|
91
|
+
token: str,
|
|
92
|
+
username: Optional[str],
|
|
93
|
+
email: Optional[str],
|
|
94
|
+
) -> List[Dict[str, Any]]:
|
|
95
|
+
"""
|
|
96
|
+
Fetch repositories the same way the FastAPI endpoint does, but for CLI use.
|
|
97
|
+
"""
|
|
98
|
+
github_service = GitHubService(token)
|
|
99
|
+
|
|
100
|
+
# Resolve which identifier to use.
|
|
101
|
+
if username:
|
|
102
|
+
final_username = username
|
|
103
|
+
logger.info("Using GitHub username: %s", final_username)
|
|
104
|
+
elif email:
|
|
105
|
+
logger.info(
|
|
106
|
+
"Username not provided; resolving from email via GitHub API: %s",
|
|
107
|
+
email,
|
|
108
|
+
)
|
|
109
|
+
final_username = await github_service.get_username_from_email(email)
|
|
110
|
+
if not final_username:
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
"Unable to resolve GitHub username from email; "
|
|
113
|
+
"provide username in credentials.yaml or via CLI."
|
|
114
|
+
)
|
|
115
|
+
logger.info("Resolved username from email: %s", final_username)
|
|
116
|
+
else:
|
|
117
|
+
# As a fallback, rely on the authenticated user info.
|
|
118
|
+
logger.info(
|
|
119
|
+
"Neither username nor email provided; using authenticated user."
|
|
120
|
+
)
|
|
121
|
+
user_info = await github_service.verify_token_and_get_user(None)
|
|
122
|
+
final_username = user_info.get("login")
|
|
123
|
+
if not final_username:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"Authenticated GitHub user could not be determined."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
logger.info("Verifying GitHub token and fetching user info for %s", final_username)
|
|
129
|
+
user_info = await github_service.verify_token_and_get_user(final_username)
|
|
130
|
+
authenticated_username = user_info.get("login") or final_username
|
|
131
|
+
logger.info("Authenticated as: %s", authenticated_username)
|
|
132
|
+
|
|
133
|
+
repos = await github_service.get_all_repositories(
|
|
134
|
+
final_username,
|
|
135
|
+
include_private=True,
|
|
136
|
+
authenticated_username=authenticated_username,
|
|
137
|
+
include_orgs=True,
|
|
138
|
+
)
|
|
139
|
+
logger.info("Fetched %d repositories from GitHub", len(repos))
|
|
140
|
+
return repos
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def map_vulnerabilities_from_csv_for_cli(
|
|
144
|
+
csv_path: Path,
|
|
145
|
+
repositories: List[Dict[str, Any]],
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Parse a vulnerability CSV and map vulnerabilities to repositories/files.
|
|
149
|
+
|
|
150
|
+
This reuses VulnerabilityService.parse_csv_file and
|
|
151
|
+
VulnerabilityService.map_vulnerabilities_to_repos, mirroring the
|
|
152
|
+
`/api/vulnerabilities/map` endpoint, but runs entirely in-process.
|
|
153
|
+
"""
|
|
154
|
+
if not csv_path.exists():
|
|
155
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
156
|
+
|
|
157
|
+
file_bytes = csv_path.read_bytes()
|
|
158
|
+
df, error = VulnerabilityService.parse_csv_file(file_bytes, csv_path.name)
|
|
159
|
+
if error:
|
|
160
|
+
raise RuntimeError(f"Error parsing CSV: {error}")
|
|
161
|
+
if df is None or df.empty:
|
|
162
|
+
raise RuntimeError("CSV file is empty or invalid")
|
|
163
|
+
|
|
164
|
+
# For the initial CLI implementation we let VulnerabilityService clone
|
|
165
|
+
# repositories on demand for accurate file-path matching.
|
|
166
|
+
mapped_vulns, unmatched_vulns = VulnerabilityService.map_vulnerabilities_to_repos(
|
|
167
|
+
vulnerabilities_df=df,
|
|
168
|
+
repositories=repositories,
|
|
169
|
+
repo_files_map=None,
|
|
170
|
+
clone_repos=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Normalize keys to strings for JSON-compatibility.
|
|
174
|
+
mapped_response: Dict[str, Any] = {}
|
|
175
|
+
for repo_id, data in mapped_vulns.items():
|
|
176
|
+
mapped_response[str(repo_id)] = {
|
|
177
|
+
"repo": data["repo"],
|
|
178
|
+
"vulnerabilities": data["vulnerabilities"],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"mapped_vulnerabilities": mapped_response,
|
|
183
|
+
"unmatched_vulnerabilities": unmatched_vulns,
|
|
184
|
+
"total_mapped": len(mapped_response),
|
|
185
|
+
"total_unmatched": len(unmatched_vulns),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _ensure_repo_cloned_for_cli(
|
|
190
|
+
repo: Dict[str, Any],
|
|
191
|
+
token: Optional[str],
|
|
192
|
+
backend_root: Path,
|
|
193
|
+
) -> Path:
|
|
194
|
+
"""
|
|
195
|
+
Ensure a repository is cloned locally and return the local path.
|
|
196
|
+
|
|
197
|
+
This mirrors the cloning logic used in the FastAPI `/api/fixes/batch`
|
|
198
|
+
endpoint, but is safe for CLI use.
|
|
199
|
+
"""
|
|
200
|
+
from subprocess import run, CalledProcessError
|
|
201
|
+
|
|
202
|
+
repo_name = repo.get("name") or "repo"
|
|
203
|
+
full_name = repo.get("full_name") or repo_name
|
|
204
|
+
clone_url = repo.get("clone_url") or repo.get("html_url", "")
|
|
205
|
+
|
|
206
|
+
if not clone_url:
|
|
207
|
+
raise RuntimeError(f"No clone URL available for repository: {full_name}")
|
|
208
|
+
|
|
209
|
+
temp_clone_dir = backend_root / "temp_cloned_repos" / repo_name
|
|
210
|
+
temp_clone_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
|
|
212
|
+
git_dir = temp_clone_dir / ".git"
|
|
213
|
+
if temp_clone_dir.exists() and git_dir.exists():
|
|
214
|
+
logger.info(
|
|
215
|
+
"Reusing existing clone for %s at %s", full_name, temp_clone_dir
|
|
216
|
+
)
|
|
217
|
+
return temp_clone_dir
|
|
218
|
+
|
|
219
|
+
# Normalize HTTPS clone URL and inject token if available.
|
|
220
|
+
repo_clone_url = clone_url
|
|
221
|
+
if "github.com" in repo_clone_url and not repo_clone_url.endswith(".git"):
|
|
222
|
+
repo_clone_url = repo_clone_url + ".git"
|
|
223
|
+
if token and repo_clone_url.startswith("https://") and "github.com" in repo_clone_url:
|
|
224
|
+
# https://TOKEN@github.com/owner/repo.git
|
|
225
|
+
repo_clone_url = repo_clone_url.replace("https://", f"https://{token}@")
|
|
226
|
+
|
|
227
|
+
if temp_clone_dir.exists():
|
|
228
|
+
# Stale/non-git directory – remove it before cloning.
|
|
229
|
+
import shutil
|
|
230
|
+
|
|
231
|
+
shutil.rmtree(temp_clone_dir, ignore_errors=True)
|
|
232
|
+
|
|
233
|
+
logger.info("Cloning %s into %s ...", repo_clone_url, temp_clone_dir)
|
|
234
|
+
try:
|
|
235
|
+
completed = run(
|
|
236
|
+
[
|
|
237
|
+
"git",
|
|
238
|
+
"clone",
|
|
239
|
+
"--depth",
|
|
240
|
+
"1",
|
|
241
|
+
"--single-branch",
|
|
242
|
+
"--filter=blob:none",
|
|
243
|
+
"--quiet",
|
|
244
|
+
repo_clone_url,
|
|
245
|
+
str(temp_clone_dir),
|
|
246
|
+
],
|
|
247
|
+
capture_output=True,
|
|
248
|
+
text=True,
|
|
249
|
+
timeout=120,
|
|
250
|
+
shell=False,
|
|
251
|
+
)
|
|
252
|
+
except CalledProcessError as e:
|
|
253
|
+
raise RuntimeError(f"Failed to clone repository: {e}") from e
|
|
254
|
+
except Exception as e: # pragma: no cover - defensive
|
|
255
|
+
raise RuntimeError(f"Failed to clone repository: {e}") from e
|
|
256
|
+
|
|
257
|
+
if completed.returncode != 0:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"Failed to clone repository {full_name}: {completed.stderr or completed.stdout}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
logger.info("Clone complete for %s", full_name)
|
|
263
|
+
return temp_clone_dir
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def run_end_to_end_for_repo(
|
|
267
|
+
repo: Dict[str, Any],
|
|
268
|
+
vulnerabilities: List[Dict[str, Any]],
|
|
269
|
+
token: str,
|
|
270
|
+
*,
|
|
271
|
+
auto_create_pr: bool,
|
|
272
|
+
run_tests: bool,
|
|
273
|
+
base_branch: str = "main",
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""
|
|
276
|
+
Run the full batch pipeline for a single repository:
|
|
277
|
+
- clone (or reuse clone)
|
|
278
|
+
- baseline testing (Atlas)
|
|
279
|
+
- multi-agent fixes (BatchFixService)
|
|
280
|
+
- validation testing
|
|
281
|
+
- PR creation (single batch PR)
|
|
282
|
+
"""
|
|
283
|
+
backend_root = Path(__file__).resolve().parent
|
|
284
|
+
repo_path = _ensure_repo_cloned_for_cli(repo, token, backend_root)
|
|
285
|
+
|
|
286
|
+
bedrock_cfg = Config.get_bedrock_config()
|
|
287
|
+
bedrock_service = BedrockService(
|
|
288
|
+
access_key=bedrock_cfg["access_key"],
|
|
289
|
+
secret_key=bedrock_cfg["secret_key"],
|
|
290
|
+
region=bedrock_cfg["region"],
|
|
291
|
+
)
|
|
292
|
+
batch_service = BatchFixService(bedrock_service)
|
|
293
|
+
|
|
294
|
+
repo_name: str = repo.get("name", "")
|
|
295
|
+
full_name: str = repo.get("full_name", repo_name)
|
|
296
|
+
owner: Optional[str] = None
|
|
297
|
+
if full_name and "/" in full_name:
|
|
298
|
+
owner = full_name.split("/")[0]
|
|
299
|
+
|
|
300
|
+
results = await batch_service.fix_batch_vulnerabilities(
|
|
301
|
+
vulnerabilities=vulnerabilities,
|
|
302
|
+
repo_path=str(repo_path),
|
|
303
|
+
repo_name=repo_name,
|
|
304
|
+
clone_url=repo.get("clone_url") or repo.get("html_url", ""),
|
|
305
|
+
token=token,
|
|
306
|
+
max_concurrent=2,
|
|
307
|
+
auto_create_pr=auto_create_pr,
|
|
308
|
+
repo_owner=owner,
|
|
309
|
+
base_branch=base_branch,
|
|
310
|
+
run_tests_after_fix=run_tests,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
"repo": repo,
|
|
315
|
+
"batch_results": results,
|
|
316
|
+
"local_repo_path": str(repo_path),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def run_end_to_end_cli(
|
|
321
|
+
credentials_path: Optional[Path],
|
|
322
|
+
csv_path: Path,
|
|
323
|
+
*,
|
|
324
|
+
auto_create_pr: bool,
|
|
325
|
+
run_tests: bool,
|
|
326
|
+
repo_filter: Optional[str] = None,
|
|
327
|
+
min_severity: Optional[str] = None,
|
|
328
|
+
) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
High-level orchestration used by `icsf run`:
|
|
331
|
+
- load credentials
|
|
332
|
+
- fetch repositories
|
|
333
|
+
- map CSV vulnerabilities to repos/files
|
|
334
|
+
- for each repo (optionally filtered), run batch fixes + tests + PR
|
|
335
|
+
"""
|
|
336
|
+
token, username, email = load_github_credentials_from_file(credentials_path)
|
|
337
|
+
|
|
338
|
+
repos = await fetch_repositories_for_cli(token, username, email)
|
|
339
|
+
mapping = map_vulnerabilities_from_csv_for_cli(csv_path, repos)
|
|
340
|
+
|
|
341
|
+
mapped = mapping.get("mapped_vulnerabilities", {})
|
|
342
|
+
repo_id_to_repo: Dict[int, Dict[str, Any]] = {}
|
|
343
|
+
for r in repos:
|
|
344
|
+
if "id" in r:
|
|
345
|
+
repo_id_to_repo[int(r["id"])] = r
|
|
346
|
+
|
|
347
|
+
# Optionally filter by repo name / full_name substring.
|
|
348
|
+
def _repo_matches_filter(repo_dict: Dict[str, Any]) -> bool:
|
|
349
|
+
if not repo_filter:
|
|
350
|
+
return True
|
|
351
|
+
name = (repo_dict.get("name") or "").lower()
|
|
352
|
+
full_name = (repo_dict.get("full_name") or "").lower()
|
|
353
|
+
return repo_filter.lower() in name or repo_filter.lower() in full_name
|
|
354
|
+
|
|
355
|
+
end_to_end_results: List[Dict[str, Any]] = []
|
|
356
|
+
total_fixable_repos = 0
|
|
357
|
+
|
|
358
|
+
# Iterate over mapped repos and run the full batch pipeline for each.
|
|
359
|
+
for repo_id_str, payload in mapped.items():
|
|
360
|
+
try:
|
|
361
|
+
repo_id = int(repo_id_str)
|
|
362
|
+
except ValueError:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
repo = repo_id_to_repo.get(repo_id) or payload.get("repo")
|
|
366
|
+
if not repo:
|
|
367
|
+
continue
|
|
368
|
+
if not _repo_matches_filter(repo):
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
vulnerabilities = payload.get("vulnerabilities", [])
|
|
372
|
+
if not vulnerabilities:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
total_fixable_repos += 1
|
|
376
|
+
logger.info(
|
|
377
|
+
"Running end-to-end pipeline for repository %s with %d mapped vulnerabilities",
|
|
378
|
+
repo.get("full_name") or repo.get("name"),
|
|
379
|
+
len(vulnerabilities),
|
|
380
|
+
)
|
|
381
|
+
repo_result = await run_end_to_end_for_repo(
|
|
382
|
+
repo=repo,
|
|
383
|
+
vulnerabilities=vulnerabilities,
|
|
384
|
+
token=token,
|
|
385
|
+
auto_create_pr=auto_create_pr,
|
|
386
|
+
run_tests=run_tests,
|
|
387
|
+
)
|
|
388
|
+
end_to_end_results.append(repo_result)
|
|
389
|
+
|
|
390
|
+
summary = {
|
|
391
|
+
"total_repos_with_vulns": len(mapped),
|
|
392
|
+
"total_repos_processed": total_fixable_repos,
|
|
393
|
+
"mapping": mapping,
|
|
394
|
+
"repos": repos,
|
|
395
|
+
"results": end_to_end_results,
|
|
396
|
+
}
|
|
397
|
+
return summary
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def to_pretty_json(data: Any) -> str:
|
|
401
|
+
"""Helper for dumping CLI reports."""
|
|
402
|
+
def _default(o: Any) -> Any:
|
|
403
|
+
try:
|
|
404
|
+
return asdict(o) # dataclasses
|
|
405
|
+
except Exception:
|
|
406
|
+
return str(o)
|
|
407
|
+
|
|
408
|
+
return json.dumps(data, indent=2, default=_default)
|
|
409
|
+
|
backend/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
# Load environment variables from .env file
|
|
7
|
+
# This is usually done at the project level, but main.py and tests also do it.
|
|
8
|
+
# We do it here as well to ensure attributes are populated when imported.
|
|
9
|
+
load_dotenv(override=True)
|
|
10
|
+
|
|
11
|
+
class Config:
|
|
12
|
+
# AWS Credentials
|
|
13
|
+
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "").strip() or None
|
|
14
|
+
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "").strip() or None
|
|
15
|
+
AWS_REGION = os.getenv("AWS_REGION", "us-east-1").strip() or "us-east-1"
|
|
16
|
+
AWS_SESSION_TOKEN = os.getenv("AWS_SESSION_TOKEN", "").strip() or None
|
|
17
|
+
|
|
18
|
+
# Model IDs
|
|
19
|
+
BEDROCK_MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "").strip() or "anthropic.claude-3-5-sonnet-20240620-v1:0"
|
|
20
|
+
BEDROCK_EMBED_MODEL_ID = os.getenv("BEDROCK_EMBED_MODEL_ID", "").strip() or "amazon.titan-embed-text-v1"
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def get_github_credentials(force_reload=False):
|
|
24
|
+
"""
|
|
25
|
+
Load GitHub credentials from credentials.yaml.
|
|
26
|
+
"""
|
|
27
|
+
import logging
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# In a real scenario, force_reload might clear a cache, but here we just read the file.
|
|
31
|
+
credentials_path = Path(__file__).parent / "credentials.yaml"
|
|
32
|
+
|
|
33
|
+
if not credentials_path.exists():
|
|
34
|
+
logger.warning(f"GitHub credentials file not found at: {credentials_path}")
|
|
35
|
+
return {"token": None, "username": None, "email": None}
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with open(credentials_path, "r") as f:
|
|
39
|
+
data = yaml.safe_load(f)
|
|
40
|
+
github_data = data.get("github", {})
|
|
41
|
+
token = github_data.get("token")
|
|
42
|
+
logger.info(f"Successfully loaded GitHub credentials from {credentials_path}")
|
|
43
|
+
if token:
|
|
44
|
+
logger.info(f"GitHub token loaded (ends with: ...{token[-4:] if len(token) > 4 else '***'})")
|
|
45
|
+
else:
|
|
46
|
+
logger.warning("GitHub token is missing in credentials.yaml")
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"token": token,
|
|
50
|
+
"username": github_data.get("username"),
|
|
51
|
+
"email": github_data.get("email")
|
|
52
|
+
}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Error loading GitHub credentials from {credentials_path}: {str(e)}")
|
|
55
|
+
return {"token": None, "username": None, "email": None}
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def validate_bedrock_credentials():
|
|
59
|
+
"""
|
|
60
|
+
Validate that AWS credentials are provided.
|
|
61
|
+
Returns (is_valid, error_msg).
|
|
62
|
+
"""
|
|
63
|
+
if not Config.AWS_ACCESS_KEY_ID or not Config.AWS_SECRET_ACCESS_KEY:
|
|
64
|
+
return False, "Missing AWS credentials. Please check your .env file."
|
|
65
|
+
return True, ""
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def get_bedrock_config():
|
|
69
|
+
"""
|
|
70
|
+
Return Bedrock configuration as a dictionary.
|
|
71
|
+
"""
|
|
72
|
+
return {
|
|
73
|
+
"access_key": Config.AWS_ACCESS_KEY_ID,
|
|
74
|
+
"secret_key": Config.AWS_SECRET_ACCESS_KEY,
|
|
75
|
+
"region": Config.AWS_REGION
|
|
76
|
+
}
|