gitflow-analytics 3.12.6__py3-none-any.whl → 3.13.5__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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/cli.py +853 -129
- gitflow_analytics/cli_wizards/__init__.py +9 -3
- gitflow_analytics/cli_wizards/menu.py +798 -0
- gitflow_analytics/config/loader.py +3 -1
- gitflow_analytics/config/profiles.py +1 -2
- gitflow_analytics/core/data_fetcher.py +0 -2
- gitflow_analytics/core/identity.py +2 -0
- gitflow_analytics/extractors/tickets.py +3 -1
- gitflow_analytics/integrations/github_integration.py +1 -1
- gitflow_analytics/integrations/jira_integration.py +1 -1
- gitflow_analytics/qualitative/chatgpt_analyzer.py +15 -15
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +1 -1
- gitflow_analytics/qualitative/core/processor.py +1 -2
- gitflow_analytics/qualitative/enhanced_analyzer.py +24 -8
- gitflow_analytics/reports/narrative_writer.py +13 -9
- gitflow_analytics/security/reports/__init__.py +5 -0
- gitflow_analytics/security/reports/security_report.py +358 -0
- gitflow_analytics/ui/progress_display.py +14 -6
- gitflow_analytics/verify_activity.py +1 -1
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/METADATA +37 -1
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/RECORD +26 -23
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/WHEEL +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/top_level.txt +0 -0
|
@@ -968,7 +968,9 @@ class ConfigLoader:
|
|
|
968
968
|
(
|
|
969
969
|
cls._resolve_env_var(item)
|
|
970
970
|
if isinstance(item, str)
|
|
971
|
-
else cls._resolve_config_dict(item)
|
|
971
|
+
else cls._resolve_config_dict(item)
|
|
972
|
+
if isinstance(item, dict)
|
|
973
|
+
else item
|
|
972
974
|
)
|
|
973
975
|
for item in value
|
|
974
976
|
]
|
|
@@ -234,8 +234,7 @@ class ProfileManager:
|
|
|
234
234
|
if not profile_class:
|
|
235
235
|
available = ", ".join(cls._profiles.keys())
|
|
236
236
|
raise ValueError(
|
|
237
|
-
f"Unknown configuration profile: {profile_name}. "
|
|
238
|
-
f"Available profiles: {available}"
|
|
237
|
+
f"Unknown configuration profile: {profile_name}. Available profiles: {available}"
|
|
239
238
|
)
|
|
240
239
|
|
|
241
240
|
profile_settings = profile_class.get_settings()
|
|
@@ -192,7 +192,6 @@ class GitDataFetcher:
|
|
|
192
192
|
description=f"📊 Processing repository: {project_key}",
|
|
193
193
|
unit="steps",
|
|
194
194
|
) as repo_progress_ctx:
|
|
195
|
-
|
|
196
195
|
# Step 1: Fetch commits
|
|
197
196
|
progress.set_description(repo_progress_ctx, f"🔍 {project_key}: Fetching commits")
|
|
198
197
|
daily_commits = self._fetch_commits_by_day(
|
|
@@ -538,7 +537,6 @@ class GitDataFetcher:
|
|
|
538
537
|
unit="days",
|
|
539
538
|
nested=True,
|
|
540
539
|
) as day_progress_ctx:
|
|
541
|
-
|
|
542
540
|
for day_date in days_to_process:
|
|
543
541
|
# Update description to show current repository and day clearly
|
|
544
542
|
day_str = day_date.strftime("%Y-%m-%d")
|
|
@@ -621,6 +621,8 @@ class DeveloperIdentityResolver:
|
|
|
621
621
|
canonical_id = self.resolve_developer(commit["author_name"], commit["author_email"])
|
|
622
622
|
# Update the commit with the resolved canonical_id for later use in reports
|
|
623
623
|
commit["canonical_id"] = canonical_id
|
|
624
|
+
# Also add the canonical display name so reports show the correct name
|
|
625
|
+
commit["canonical_name"] = self.get_canonical_name(canonical_id)
|
|
624
626
|
|
|
625
627
|
stats_by_dev[canonical_id]["commits"] += 1
|
|
626
628
|
stats_by_dev[canonical_id]["story_points"] += commit.get("story_points", 0) or 0
|
|
@@ -503,7 +503,9 @@ class TicketExtractor:
|
|
|
503
503
|
:100
|
|
504
504
|
], # Increased from 60 to 100
|
|
505
505
|
"full_message": commit.get("message", ""),
|
|
506
|
-
"author": commit.get(
|
|
506
|
+
"author": commit.get(
|
|
507
|
+
"canonical_name", commit.get("author_name", "Unknown")
|
|
508
|
+
),
|
|
507
509
|
"author_email": commit.get("author_email", ""),
|
|
508
510
|
"canonical_id": commit.get("canonical_id", commit.get("author_email", "")),
|
|
509
511
|
"timestamp": commit.get("timestamp"),
|
|
@@ -101,7 +101,7 @@ class GitHubIntegration:
|
|
|
101
101
|
|
|
102
102
|
if cache_hits > 0 or cache_misses > 0:
|
|
103
103
|
print(
|
|
104
|
-
f" 📊 GitHub PR cache: {cache_hits} hits, {cache_misses} misses ({cache_hits/(cache_hits+cache_misses)*100:.1f}% hit rate)"
|
|
104
|
+
f" 📊 GitHub PR cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
|
|
105
105
|
if (cache_hits + cache_misses) > 0
|
|
106
106
|
else ""
|
|
107
107
|
)
|
|
@@ -186,7 +186,7 @@ class JIRAIntegration:
|
|
|
186
186
|
|
|
187
187
|
if cache_hits > 0 or cache_misses > 0:
|
|
188
188
|
print(
|
|
189
|
-
f" 📊 JIRA cache: {cache_hits} hits, {cache_misses} misses ({cache_hits/(cache_hits+cache_misses)*100:.1f}% hit rate)"
|
|
189
|
+
f" 📊 JIRA cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
# Fetch missing tickets from JIRA
|
|
@@ -169,16 +169,16 @@ class ChatGPTQualitativeAnalyzer:
|
|
|
169
169
|
def _create_executive_summary_prompt(self, summary_data: dict[str, Any]) -> str:
|
|
170
170
|
"""Create the prompt for ChatGPT."""
|
|
171
171
|
|
|
172
|
-
prompt = f"""Based on the following GitFlow Analytics data from the past {summary_data[
|
|
172
|
+
prompt = f"""Based on the following GitFlow Analytics data from the past {summary_data["period_weeks"]} weeks, provide a comprehensive executive summary with qualitative insights:
|
|
173
173
|
|
|
174
174
|
## Key Metrics:
|
|
175
|
-
- Total Commits: {summary_data[
|
|
176
|
-
- Active Developers: {summary_data[
|
|
177
|
-
- Lines Changed: {summary_data[
|
|
178
|
-
- Story Points Delivered: {summary_data[
|
|
179
|
-
- Ticket Coverage: {summary_data[
|
|
180
|
-
- Team Health Score: {summary_data[
|
|
181
|
-
- Velocity Trend: {summary_data[
|
|
175
|
+
- Total Commits: {summary_data["total_commits"]:,}
|
|
176
|
+
- Active Developers: {summary_data["total_developers"]}
|
|
177
|
+
- Lines Changed: {summary_data["lines_changed"]:,}
|
|
178
|
+
- Story Points Delivered: {summary_data["story_points"]}
|
|
179
|
+
- Ticket Coverage: {summary_data["ticket_coverage"]:.1f}%
|
|
180
|
+
- Team Health Score: {summary_data["team_health_score"]:.1f}/100 ({summary_data["team_health_rating"]})
|
|
181
|
+
- Velocity Trend: {summary_data["velocity_trend"]}
|
|
182
182
|
|
|
183
183
|
## Top Contributors:
|
|
184
184
|
"""
|
|
@@ -222,18 +222,18 @@ Report only statistical patterns, measurable trends, and process gaps. Use factu
|
|
|
222
222
|
|
|
223
223
|
return f"""## Executive Summary
|
|
224
224
|
|
|
225
|
-
Over the past {summary_data[
|
|
225
|
+
Over the past {summary_data["period_weeks"]} weeks, the development team generated {summary_data["total_commits"]:,} commits across {summary_data["total_developers"]} active developers.
|
|
226
226
|
|
|
227
|
-
The team health score measured {summary_data[
|
|
227
|
+
The team health score measured {summary_data["team_health_score"]:.1f}/100 ({summary_data["team_health_rating"]}). Ticket coverage reached {summary_data["ticket_coverage"]:.1f}% of total commits with trackable references.
|
|
228
228
|
|
|
229
229
|
### Measured Outputs:
|
|
230
|
-
- Code changes: {summary_data[
|
|
231
|
-
- Story points completed: {summary_data[
|
|
232
|
-
- Velocity trend: {summary_data[
|
|
230
|
+
- Code changes: {summary_data["lines_changed"]:,} lines modified
|
|
231
|
+
- Story points completed: {summary_data["story_points"]}
|
|
232
|
+
- Velocity trend: {summary_data["velocity_trend"]}
|
|
233
233
|
|
|
234
234
|
### Process Recommendations:
|
|
235
|
-
1. {
|
|
236
|
-
2. {
|
|
235
|
+
1. {"Maintain current output rate" if summary_data["velocity_trend"] == "increasing" else "Analyze velocity decline factors"}
|
|
236
|
+
2. {"Sustain current tracking rate" if summary_data["ticket_coverage"] > 60 else "Increase commit-ticket linking to reach 70% coverage target"}
|
|
237
237
|
3. Review projects with health scores below 60/100 for process gaps
|
|
238
238
|
|
|
239
239
|
*Note: This is a fallback summary. For detailed analysis, configure ChatGPT integration.*
|
|
@@ -353,7 +353,7 @@ Response (format: CATEGORY confidence reasoning):""",
|
|
|
353
353
|
"""
|
|
354
354
|
formatted = []
|
|
355
355
|
for i, example in enumerate(examples, 1):
|
|
356
|
-
formatted.append(f
|
|
356
|
+
formatted.append(f'{i}. Message: "{example["message"]}"')
|
|
357
357
|
formatted.append(f" Response: {example['response']}")
|
|
358
358
|
return "\n".join(formatted)
|
|
359
359
|
|
|
@@ -577,8 +577,7 @@ class QualitativeProcessor:
|
|
|
577
577
|
llm_pct = (llm_processed / total_commits) * 100 if total_commits > 0 else 0
|
|
578
578
|
|
|
579
579
|
self.logger.info(
|
|
580
|
-
f"Processing breakdown: {cache_pct:.1f}% cached, "
|
|
581
|
-
f"{nlp_pct:.1f}% NLP, {llm_pct:.1f}% LLM"
|
|
580
|
+
f"Processing breakdown: {cache_pct:.1f}% cached, {nlp_pct:.1f}% NLP, {llm_pct:.1f}% LLM"
|
|
582
581
|
)
|
|
583
582
|
|
|
584
583
|
def _should_optimize_cache(self) -> bool:
|
|
@@ -906,7 +906,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
906
906
|
"status": (
|
|
907
907
|
"excellent"
|
|
908
908
|
if activity_score >= 80
|
|
909
|
-
else "good"
|
|
909
|
+
else "good"
|
|
910
|
+
if activity_score >= 60
|
|
911
|
+
else "needs_improvement"
|
|
910
912
|
),
|
|
911
913
|
},
|
|
912
914
|
"contributor_diversity": {
|
|
@@ -915,7 +917,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
915
917
|
"status": (
|
|
916
918
|
"excellent"
|
|
917
919
|
if len(contributors) >= 4
|
|
918
|
-
else "good"
|
|
920
|
+
else "good"
|
|
921
|
+
if len(contributors) >= 2
|
|
922
|
+
else "concerning"
|
|
919
923
|
),
|
|
920
924
|
},
|
|
921
925
|
"pr_velocity": {
|
|
@@ -929,7 +933,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
929
933
|
"status": (
|
|
930
934
|
"excellent"
|
|
931
935
|
if ticket_coverage >= 80
|
|
932
|
-
else "good"
|
|
936
|
+
else "good"
|
|
937
|
+
if ticket_coverage >= 60
|
|
938
|
+
else "needs_improvement"
|
|
933
939
|
),
|
|
934
940
|
},
|
|
935
941
|
}
|
|
@@ -948,7 +954,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
948
954
|
"status": (
|
|
949
955
|
"excellent"
|
|
950
956
|
if overall_score >= 80
|
|
951
|
-
else "good"
|
|
957
|
+
else "good"
|
|
958
|
+
if overall_score >= 60
|
|
959
|
+
else "needs_improvement"
|
|
952
960
|
),
|
|
953
961
|
}
|
|
954
962
|
|
|
@@ -1918,7 +1926,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
1918
1926
|
"status": (
|
|
1919
1927
|
"excellent"
|
|
1920
1928
|
if ticket_coverage >= 80
|
|
1921
|
-
else "good"
|
|
1929
|
+
else "good"
|
|
1930
|
+
if ticket_coverage >= 60
|
|
1931
|
+
else "needs_improvement"
|
|
1922
1932
|
),
|
|
1923
1933
|
},
|
|
1924
1934
|
"message_quality": {
|
|
@@ -1926,7 +1936,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
1926
1936
|
"status": (
|
|
1927
1937
|
"excellent"
|
|
1928
1938
|
if message_quality >= 80
|
|
1929
|
-
else "good"
|
|
1939
|
+
else "good"
|
|
1940
|
+
if message_quality >= 60
|
|
1941
|
+
else "needs_improvement"
|
|
1930
1942
|
),
|
|
1931
1943
|
},
|
|
1932
1944
|
"commit_size_compliance": {
|
|
@@ -1934,7 +1946,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
1934
1946
|
"status": (
|
|
1935
1947
|
"excellent"
|
|
1936
1948
|
if size_compliance >= 80
|
|
1937
|
-
else "good"
|
|
1949
|
+
else "good"
|
|
1950
|
+
if size_compliance >= 60
|
|
1951
|
+
else "needs_improvement"
|
|
1938
1952
|
),
|
|
1939
1953
|
},
|
|
1940
1954
|
"pr_approval_rate": {"score": pr_approval_rate, "status": "good"}, # Placeholder
|
|
@@ -1986,7 +2000,9 @@ class EnhancedQualitativeAnalyzer:
|
|
|
1986
2000
|
"collaboration_level": (
|
|
1987
2001
|
"high"
|
|
1988
2002
|
if collaboration_score >= 70
|
|
1989
|
-
else "medium"
|
|
2003
|
+
else "medium"
|
|
2004
|
+
if collaboration_score >= 40
|
|
2005
|
+
else "low"
|
|
1990
2006
|
),
|
|
1991
2007
|
"patterns": {
|
|
1992
2008
|
"multi_project_engagement": cross_collaboration_rate >= 50,
|
|
@@ -1938,12 +1938,14 @@ class NarrativeReportGenerator:
|
|
|
1938
1938
|
}
|
|
1939
1939
|
)
|
|
1940
1940
|
|
|
1941
|
-
# Contributor analysis
|
|
1941
|
+
# Contributor analysis - use canonical_id as key for accurate lookup
|
|
1942
|
+
# Store display name separately so we can show the correct name in reports
|
|
1943
|
+
canonical_id = commit.get("canonical_id", "unknown")
|
|
1942
1944
|
author = commit.get("author", "Unknown")
|
|
1943
|
-
if
|
|
1944
|
-
contributors[
|
|
1945
|
-
contributors[
|
|
1946
|
-
contributors[
|
|
1945
|
+
if canonical_id not in contributors:
|
|
1946
|
+
contributors[canonical_id] = {"count": 0, "categories": set(), "name": author}
|
|
1947
|
+
contributors[canonical_id]["count"] += 1
|
|
1948
|
+
contributors[canonical_id]["categories"].add(category)
|
|
1947
1949
|
|
|
1948
1950
|
# Project analysis
|
|
1949
1951
|
project = commit.get("project_key", "UNKNOWN")
|
|
@@ -1998,12 +2000,14 @@ class NarrativeReportGenerator:
|
|
|
1998
2000
|
contributors.items(), key=lambda x: x[1]["count"], reverse=True
|
|
1999
2001
|
)
|
|
2000
2002
|
|
|
2001
|
-
for
|
|
2003
|
+
for canonical_id, data in sorted_contributors[:5]: # Show top 5
|
|
2002
2004
|
untracked_count = data["count"]
|
|
2003
2005
|
pct_of_untracked = (untracked_count / total_untracked) * 100
|
|
2006
|
+
# Get the display name from the stored data
|
|
2007
|
+
author_name = data.get("name", "Unknown")
|
|
2004
2008
|
|
|
2005
|
-
# Find developer's total commits
|
|
2006
|
-
dev_data = dev_lookup.get(
|
|
2009
|
+
# Find developer's total commits using canonical_id
|
|
2010
|
+
dev_data = dev_lookup.get(canonical_id)
|
|
2007
2011
|
if dev_data:
|
|
2008
2012
|
total_dev_commits = dev_data["total_commits"]
|
|
2009
2013
|
pct_of_dev_work = (
|
|
@@ -2018,7 +2022,7 @@ class NarrativeReportGenerator:
|
|
|
2018
2022
|
if len(categories_list) > 3:
|
|
2019
2023
|
categories_str += f" (+{len(categories_list) - 3} more)"
|
|
2020
2024
|
|
|
2021
|
-
report.write(f"- **{
|
|
2025
|
+
report.write(f"- **{author_name}**: {untracked_count} commits ")
|
|
2022
2026
|
report.write(f"({pct_of_untracked:.1f}% of untracked{dev_context}) - ")
|
|
2023
2027
|
report.write(f"*{categories_str}*\n")
|
|
2024
2028
|
report.write("\n")
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Generate security analysis reports."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import csv
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from ..security_analyzer import SecurityAnalysis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecurityReportGenerator:
|
|
12
|
+
"""Generate various format reports for security findings."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
15
|
+
"""Initialize report generator.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
output_dir: Directory for report output
|
|
19
|
+
"""
|
|
20
|
+
self.output_dir = output_dir or Path("reports")
|
|
21
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def generate_reports(self, analyses: List[SecurityAnalysis], summary: Dict[str, Any]) -> Dict[str, Path]:
|
|
24
|
+
"""Generate all report formats.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
analyses: List of security analyses
|
|
28
|
+
summary: Summary statistics
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dictionary of report type to file path
|
|
32
|
+
"""
|
|
33
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
34
|
+
reports = {}
|
|
35
|
+
|
|
36
|
+
# Generate Markdown report
|
|
37
|
+
md_path = self.output_dir / f"security_report_{timestamp}.md"
|
|
38
|
+
self._generate_markdown_report(analyses, summary, md_path)
|
|
39
|
+
reports["markdown"] = md_path
|
|
40
|
+
|
|
41
|
+
# Generate JSON report
|
|
42
|
+
json_path = self.output_dir / f"security_findings_{timestamp}.json"
|
|
43
|
+
self._generate_json_report(analyses, summary, json_path)
|
|
44
|
+
reports["json"] = json_path
|
|
45
|
+
|
|
46
|
+
# Generate CSV report
|
|
47
|
+
csv_path = self.output_dir / f"security_issues_{timestamp}.csv"
|
|
48
|
+
self._generate_csv_report(analyses, csv_path)
|
|
49
|
+
reports["csv"] = csv_path
|
|
50
|
+
|
|
51
|
+
# Generate SARIF report if requested
|
|
52
|
+
if any(a.total_findings > 0 for a in analyses):
|
|
53
|
+
sarif_path = self.output_dir / f"security_sarif_{timestamp}.json"
|
|
54
|
+
self._generate_sarif_report(analyses, sarif_path)
|
|
55
|
+
reports["sarif"] = sarif_path
|
|
56
|
+
|
|
57
|
+
return reports
|
|
58
|
+
|
|
59
|
+
def _generate_markdown_report(self, analyses: List[SecurityAnalysis], summary: Dict, path: Path) -> None:
|
|
60
|
+
"""Generate comprehensive Markdown security report."""
|
|
61
|
+
with open(path, 'w') as f:
|
|
62
|
+
# Header
|
|
63
|
+
f.write("# 🔒 Security Analysis Report\n\n")
|
|
64
|
+
f.write(f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
65
|
+
|
|
66
|
+
# Executive Summary
|
|
67
|
+
f.write("## 📊 Executive Summary\n\n")
|
|
68
|
+
f.write(f"- **Commits Analyzed**: {summary['total_commits']}\n")
|
|
69
|
+
f.write(f"- **Commits with Issues**: {summary['commits_with_issues']}\n")
|
|
70
|
+
f.write(f"- **Total Findings**: {summary['total_findings']}\n")
|
|
71
|
+
f.write(f"- **Risk Level**: **{summary['risk_level']}** (Score: {summary['average_risk_score']})\n\n")
|
|
72
|
+
|
|
73
|
+
# Risk Assessment
|
|
74
|
+
self._write_risk_assessment(f, summary)
|
|
75
|
+
|
|
76
|
+
# Severity Distribution
|
|
77
|
+
f.write("## 🎯 Severity Distribution\n\n")
|
|
78
|
+
severity = summary['severity_distribution']
|
|
79
|
+
if severity['critical'] > 0:
|
|
80
|
+
f.write(f"- 🔴 **Critical**: {severity['critical']}\n")
|
|
81
|
+
if severity['high'] > 0:
|
|
82
|
+
f.write(f"- 🟠 **High**: {severity['high']}\n")
|
|
83
|
+
if severity['medium'] > 0:
|
|
84
|
+
f.write(f"- 🟡 **Medium**: {severity['medium']}\n")
|
|
85
|
+
if severity['low'] > 0:
|
|
86
|
+
f.write(f"- 🟢 **Low**: {severity['low']}\n")
|
|
87
|
+
f.write("\n")
|
|
88
|
+
|
|
89
|
+
# Top Issues
|
|
90
|
+
if summary['top_issues']:
|
|
91
|
+
f.write("## 🔝 Top Security Issues\n\n")
|
|
92
|
+
f.write("| Issue Type | Severity | Occurrences | Affected Files |\n")
|
|
93
|
+
f.write("|------------|----------|-------------|----------------|\n")
|
|
94
|
+
for issue in summary['top_issues']:
|
|
95
|
+
f.write(f"| {issue['type']} | {issue['severity'].upper()} | "
|
|
96
|
+
f"{issue['occurrences']} | {issue['affected_files']} |\n")
|
|
97
|
+
f.write("\n")
|
|
98
|
+
|
|
99
|
+
# Detailed Findings by Category
|
|
100
|
+
self._write_detailed_findings(f, analyses)
|
|
101
|
+
|
|
102
|
+
# LLM Insights
|
|
103
|
+
if 'llm_insights' in summary and summary['llm_insights']:
|
|
104
|
+
f.write("## 🤖 AI Security Insights\n\n")
|
|
105
|
+
f.write(summary['llm_insights'])
|
|
106
|
+
f.write("\n\n")
|
|
107
|
+
|
|
108
|
+
# Recommendations
|
|
109
|
+
f.write("## 💡 Recommendations\n\n")
|
|
110
|
+
for rec in summary['recommendations']:
|
|
111
|
+
f.write(f"- {rec}\n")
|
|
112
|
+
f.write("\n")
|
|
113
|
+
|
|
114
|
+
# Appendix - All Findings
|
|
115
|
+
f.write("## 📋 Detailed Findings\n\n")
|
|
116
|
+
self._write_all_findings(f, analyses)
|
|
117
|
+
|
|
118
|
+
def _write_risk_assessment(self, f, summary: Dict) -> None:
|
|
119
|
+
"""Write risk assessment section."""
|
|
120
|
+
risk_level = summary['risk_level']
|
|
121
|
+
score = summary['average_risk_score']
|
|
122
|
+
|
|
123
|
+
f.write("## ⚠️ Risk Assessment\n\n")
|
|
124
|
+
|
|
125
|
+
if risk_level == "CRITICAL":
|
|
126
|
+
f.write("### 🚨 CRITICAL RISK DETECTED\n\n")
|
|
127
|
+
f.write("Immediate action required. Critical security vulnerabilities have been identified "
|
|
128
|
+
"that could lead to severe security breaches.\n\n")
|
|
129
|
+
elif risk_level == "HIGH":
|
|
130
|
+
f.write("### 🔴 High Risk\n\n")
|
|
131
|
+
f.write("Significant security issues detected that should be addressed urgently.\n\n")
|
|
132
|
+
elif risk_level == "MEDIUM":
|
|
133
|
+
f.write("### 🟡 Medium Risk\n\n")
|
|
134
|
+
f.write("Moderate security concerns identified that should be addressed in the near term.\n\n")
|
|
135
|
+
else:
|
|
136
|
+
f.write("### 🟢 Low Risk\n\n")
|
|
137
|
+
f.write("Minor security issues detected. Continue with regular security practices.\n\n")
|
|
138
|
+
|
|
139
|
+
# Risk score visualization
|
|
140
|
+
f.write("**Risk Score Breakdown**:\n")
|
|
141
|
+
f.write("```\n")
|
|
142
|
+
bar_length = 50
|
|
143
|
+
filled = int(score / 100 * bar_length)
|
|
144
|
+
bar = "█" * filled + "░" * (bar_length - filled)
|
|
145
|
+
f.write(f"[{bar}] {score:.1f}/100\n")
|
|
146
|
+
f.write("```\n\n")
|
|
147
|
+
|
|
148
|
+
def _write_detailed_findings(self, f, analyses: List[SecurityAnalysis]) -> None:
|
|
149
|
+
"""Write detailed findings by category."""
|
|
150
|
+
# Aggregate findings
|
|
151
|
+
all_secrets = []
|
|
152
|
+
all_vulnerabilities = []
|
|
153
|
+
all_dependencies = []
|
|
154
|
+
all_llm = []
|
|
155
|
+
|
|
156
|
+
for analysis in analyses:
|
|
157
|
+
all_secrets.extend(analysis.secrets)
|
|
158
|
+
all_vulnerabilities.extend(analysis.vulnerabilities)
|
|
159
|
+
all_dependencies.extend(analysis.dependency_issues)
|
|
160
|
+
all_llm.extend(analysis.llm_findings)
|
|
161
|
+
|
|
162
|
+
# Secrets Section
|
|
163
|
+
if all_secrets:
|
|
164
|
+
f.write("## 🔑 Exposed Secrets\n\n")
|
|
165
|
+
f.write(f"**Total**: {len(all_secrets)} potential secrets detected\n\n")
|
|
166
|
+
|
|
167
|
+
# Group by secret type
|
|
168
|
+
by_type = {}
|
|
169
|
+
for secret in all_secrets:
|
|
170
|
+
secret_type = secret.get('secret_type', 'unknown')
|
|
171
|
+
if secret_type not in by_type:
|
|
172
|
+
by_type[secret_type] = []
|
|
173
|
+
by_type[secret_type].append(secret)
|
|
174
|
+
|
|
175
|
+
for secret_type, secrets in sorted(by_type.items()):
|
|
176
|
+
f.write(f"### {secret_type.replace('_', ' ').title()}\n")
|
|
177
|
+
for s in secrets[:5]: # Show first 5 of each type
|
|
178
|
+
f.write(f"- **File**: `{s.get('file', 'unknown')}`\n")
|
|
179
|
+
f.write(f" - Line: {s.get('line', 'N/A')}\n")
|
|
180
|
+
f.write(f" - Pattern: `{s.get('match', 'N/A')}`\n")
|
|
181
|
+
if len(secrets) > 5:
|
|
182
|
+
f.write(f" - *... and {len(secrets) - 5} more*\n")
|
|
183
|
+
f.write("\n")
|
|
184
|
+
|
|
185
|
+
# Vulnerabilities Section
|
|
186
|
+
if all_vulnerabilities:
|
|
187
|
+
f.write("## 🛡️ Code Vulnerabilities\n\n")
|
|
188
|
+
f.write(f"**Total**: {len(all_vulnerabilities)} vulnerabilities detected\n\n")
|
|
189
|
+
|
|
190
|
+
# Group by vulnerability type
|
|
191
|
+
by_type = {}
|
|
192
|
+
for vuln in all_vulnerabilities:
|
|
193
|
+
vuln_type = vuln.get('vulnerability_type', 'unknown')
|
|
194
|
+
if vuln_type not in by_type:
|
|
195
|
+
by_type[vuln_type] = []
|
|
196
|
+
by_type[vuln_type].append(vuln)
|
|
197
|
+
|
|
198
|
+
for vuln_type, vulns in sorted(by_type.items()):
|
|
199
|
+
f.write(f"### {vuln_type.replace('_', ' ').title()}\n")
|
|
200
|
+
for v in vulns[:5]:
|
|
201
|
+
f.write(f"- **File**: `{v.get('file', 'unknown')}:{v.get('line', 'N/A')}`\n")
|
|
202
|
+
f.write(f" - Tool: {v.get('tool', 'N/A')}\n")
|
|
203
|
+
f.write(f" - Message: {v.get('message', 'N/A')}\n")
|
|
204
|
+
if len(vulns) > 5:
|
|
205
|
+
f.write(f" - *... and {len(vulns) - 5} more*\n")
|
|
206
|
+
f.write("\n")
|
|
207
|
+
|
|
208
|
+
# Dependencies Section
|
|
209
|
+
if all_dependencies:
|
|
210
|
+
f.write("## 📦 Vulnerable Dependencies\n\n")
|
|
211
|
+
f.write(f"**Total**: {len(all_dependencies)} vulnerable dependencies\n\n")
|
|
212
|
+
|
|
213
|
+
for dep in all_dependencies[:10]:
|
|
214
|
+
f.write(f"- **{dep.get('package', 'unknown')}** @ {dep.get('version', 'unknown')}\n")
|
|
215
|
+
f.write(f" - File: `{dep.get('file', 'unknown')}`\n")
|
|
216
|
+
if dep.get('cve'):
|
|
217
|
+
f.write(f" - CVE: {dep['cve']}\n")
|
|
218
|
+
f.write(f" - Message: {dep.get('message', 'N/A')}\n")
|
|
219
|
+
if len(all_dependencies) > 10:
|
|
220
|
+
f.write(f"\n*... and {len(all_dependencies) - 10} more vulnerable dependencies*\n")
|
|
221
|
+
f.write("\n")
|
|
222
|
+
|
|
223
|
+
def _write_all_findings(self, f, analyses: List[SecurityAnalysis]) -> None:
|
|
224
|
+
"""Write all findings in detail."""
|
|
225
|
+
for analysis in analyses:
|
|
226
|
+
if analysis.total_findings == 0:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
f.write(f"### Commit: `{analysis.commit_hash[:8]}`\n")
|
|
230
|
+
f.write(f"**Time**: {analysis.timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
231
|
+
f.write(f"**Files Changed**: {len(analysis.files_changed)}\n")
|
|
232
|
+
f.write(f"**Risk Score**: {analysis.risk_score:.1f}\n\n")
|
|
233
|
+
|
|
234
|
+
if analysis.secrets:
|
|
235
|
+
f.write("**Secrets**:\n")
|
|
236
|
+
for s in analysis.secrets:
|
|
237
|
+
f.write(f"- {s.get('secret_type', 'unknown')}: {s.get('file', 'N/A')}\n")
|
|
238
|
+
|
|
239
|
+
if analysis.vulnerabilities:
|
|
240
|
+
f.write("**Vulnerabilities**:\n")
|
|
241
|
+
for v in analysis.vulnerabilities:
|
|
242
|
+
f.write(f"- {v.get('vulnerability_type', 'unknown')}: {v.get('file', 'N/A')}\n")
|
|
243
|
+
|
|
244
|
+
f.write("\n---\n\n")
|
|
245
|
+
|
|
246
|
+
def _generate_json_report(self, analyses: List[SecurityAnalysis], summary: Dict, path: Path) -> None:
|
|
247
|
+
"""Generate JSON report with all findings."""
|
|
248
|
+
report = {
|
|
249
|
+
"metadata": {
|
|
250
|
+
"generated": datetime.now().isoformat(),
|
|
251
|
+
"version": "1.0.0"
|
|
252
|
+
},
|
|
253
|
+
"summary": summary,
|
|
254
|
+
"analyses": []
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for analysis in analyses:
|
|
258
|
+
report["analyses"].append({
|
|
259
|
+
"commit_hash": analysis.commit_hash,
|
|
260
|
+
"timestamp": analysis.timestamp.isoformat(),
|
|
261
|
+
"files_changed": analysis.files_changed,
|
|
262
|
+
"risk_score": analysis.risk_score,
|
|
263
|
+
"findings": {
|
|
264
|
+
"secrets": analysis.secrets,
|
|
265
|
+
"vulnerabilities": analysis.vulnerabilities,
|
|
266
|
+
"dependency_issues": analysis.dependency_issues,
|
|
267
|
+
"llm_findings": analysis.llm_findings
|
|
268
|
+
},
|
|
269
|
+
"metrics": {
|
|
270
|
+
"total": analysis.total_findings,
|
|
271
|
+
"critical": analysis.critical_count,
|
|
272
|
+
"high": analysis.high_count,
|
|
273
|
+
"medium": analysis.medium_count,
|
|
274
|
+
"low": analysis.low_count
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
with open(path, 'w') as f:
|
|
279
|
+
json.dump(report, f, indent=2)
|
|
280
|
+
|
|
281
|
+
def _generate_csv_report(self, analyses: List[SecurityAnalysis], path: Path) -> None:
|
|
282
|
+
"""Generate CSV report of all findings."""
|
|
283
|
+
with open(path, 'w', newline='') as f:
|
|
284
|
+
writer = csv.DictWriter(f, fieldnames=[
|
|
285
|
+
'commit_hash', 'timestamp', 'type', 'severity',
|
|
286
|
+
'category', 'file', 'line', 'message', 'tool', 'confidence'
|
|
287
|
+
])
|
|
288
|
+
writer.writeheader()
|
|
289
|
+
|
|
290
|
+
for analysis in analyses:
|
|
291
|
+
# Write all findings
|
|
292
|
+
for finding in (analysis.secrets + analysis.vulnerabilities +
|
|
293
|
+
analysis.dependency_issues + analysis.llm_findings):
|
|
294
|
+
writer.writerow({
|
|
295
|
+
'commit_hash': analysis.commit_hash[:8],
|
|
296
|
+
'timestamp': analysis.timestamp.isoformat(),
|
|
297
|
+
'type': finding.get('type', 'unknown'),
|
|
298
|
+
'severity': finding.get('severity', 'medium'),
|
|
299
|
+
'category': finding.get('vulnerability_type',
|
|
300
|
+
finding.get('secret_type', 'unknown')),
|
|
301
|
+
'file': finding.get('file', ''),
|
|
302
|
+
'line': finding.get('line', ''),
|
|
303
|
+
'message': finding.get('message', ''),
|
|
304
|
+
'tool': finding.get('tool', finding.get('source', '')),
|
|
305
|
+
'confidence': finding.get('confidence', '')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
def _generate_sarif_report(self, analyses: List[SecurityAnalysis], path: Path) -> None:
|
|
309
|
+
"""Generate SARIF format report for GitHub Security tab integration."""
|
|
310
|
+
sarif = {
|
|
311
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
312
|
+
"version": "2.1.0",
|
|
313
|
+
"runs": [{
|
|
314
|
+
"tool": {
|
|
315
|
+
"driver": {
|
|
316
|
+
"name": "GitFlow Analytics Security",
|
|
317
|
+
"version": "1.0.0",
|
|
318
|
+
"informationUri": "https://github.com/yourusername/gitflow-analytics"
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"results": []
|
|
322
|
+
}]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for analysis in analyses:
|
|
326
|
+
for finding in (analysis.secrets + analysis.vulnerabilities):
|
|
327
|
+
result = {
|
|
328
|
+
"ruleId": finding.get('vulnerability_type',
|
|
329
|
+
finding.get('secret_type', 'unknown')),
|
|
330
|
+
"level": self._severity_to_sarif_level(finding.get('severity', 'medium')),
|
|
331
|
+
"message": {
|
|
332
|
+
"text": finding.get('message', 'Security issue detected')
|
|
333
|
+
},
|
|
334
|
+
"locations": [{
|
|
335
|
+
"physicalLocation": {
|
|
336
|
+
"artifactLocation": {
|
|
337
|
+
"uri": finding.get('file', 'unknown')
|
|
338
|
+
},
|
|
339
|
+
"region": {
|
|
340
|
+
"startLine": finding.get('line', 1)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}]
|
|
344
|
+
}
|
|
345
|
+
sarif["runs"][0]["results"].append(result)
|
|
346
|
+
|
|
347
|
+
with open(path, 'w') as f:
|
|
348
|
+
json.dump(sarif, f, indent=2)
|
|
349
|
+
|
|
350
|
+
def _severity_to_sarif_level(self, severity: str) -> str:
|
|
351
|
+
"""Convert severity to SARIF level."""
|
|
352
|
+
mapping = {
|
|
353
|
+
"critical": "error",
|
|
354
|
+
"high": "error",
|
|
355
|
+
"medium": "warning",
|
|
356
|
+
"low": "note"
|
|
357
|
+
}
|
|
358
|
+
return mapping.get(severity.lower(), "warning")
|