Skip to content

Commit 81d5fb6

Browse files
committed
Add repository_url and access_token fields to Project model, serializers, views, and frontend. Implement daily task to update project dependencies using OWASP Dependency-Checker.
1 parent 09d1739 commit 81d5fb6

File tree

8 files changed

+155
-9
lines changed

8 files changed

+155
-9
lines changed

backend/analyzer/manager/project_manager.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import logging
2+
import os
3+
import subprocess
4+
import tempfile
25
from secrets import token_urlsafe
36

47
from django.db import DatabaseError
58

69
from analyzer.manager.cve_manager import CVEObjectManager
710
from analyzer.models import Project, Report, Dependency
11+
from analyzer.parser.parser_manager import ParserManager
812
from analyzer.parser.types import ParseResult
913
from utilities.helperclass import hash_key
1014

@@ -138,3 +142,47 @@ def _update_dependencies(self, data: dict[str, ParseResult]):
138142
logger.warning(
139143
f"An error occurred while trying to create reports. Following exception occurred: {str(de)}."
140144
f"Project id: {self.project.project_id}.")
145+
146+
def run_dependency_checker(self):
147+
"""
148+
Clones the repository, runs the OWASP Dependency-Checker, and updates the project with the results.
149+
"""
150+
if not self.project.repository_url:
151+
logger.error(f"No repository URL configured for project {self.project.project_id}.")
152+
return
153+
154+
with tempfile.TemporaryDirectory() as temp_dir:
155+
repo_path = os.path.join(temp_dir, "repo")
156+
157+
# Clone the repository
158+
clone_cmd = ["git", "clone", self.project.repository_url, repo_path]
159+
if self.project.access_token:
160+
clone_cmd[1] = self.project.repository_url.replace("https://", f"https://{self.project.access_token}@")
161+
162+
try:
163+
subprocess.run(clone_cmd, check=True)
164+
logger.info(f"Repository cloned successfully for project {self.project.project_id}.")
165+
except subprocess.CalledProcessError as e:
166+
logger.error(f"Failed to clone repository: {e}")
167+
return
168+
169+
# Run OWASP Dependency-Checker
170+
output_file = os.path.join(temp_dir, "dependency-check-report.json")
171+
try:
172+
subprocess.run([
173+
"dependency-check", "--project", self.project.project_name,
174+
"--out", temp_dir, "--format", "JSON", "--scan", repo_path
175+
], check=True)
176+
logger.info(f"Dependency-Checker executed successfully for project {self.project.project_id}.")
177+
except subprocess.CalledProcessError as e:
178+
logger.error(f"Failed to run Dependency-Checker: {e}")
179+
return
180+
181+
# Parse the results
182+
try:
183+
parser = ParserManager(output_file)
184+
parsed_data = parser.parse()
185+
self.update_project(parsed_data)
186+
logger.info(f"Project {self.project.project_id} updated successfully with new dependency data.")
187+
except Exception as e:
188+
logger.error(f"Failed to parse Dependency-Checker results: {e}")

backend/analyzer/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22

33
from django.db import models
4+
from django.core.mail import send_mail
5+
from django.utils.timezone import now
46

57
from utilities import constants
68

@@ -19,6 +21,8 @@ class Meta:
1921
choices=constants.Threshold.choices,
2022
default=constants.Threshold.MEDIUM.name)
2123
api_key_hash = models.CharField(null=True, max_length=100)
24+
repository_url = models.URLField(max_length=2048, blank=True, null=True)
25+
access_token = models.CharField(max_length=255, blank=True, null=True)
2226

2327
@property
2428
def dependency_count(self):
@@ -93,6 +97,35 @@ def vulnerabilities_count(self, status_types: list[str]) -> dict:
9397
counted_vul.update({severity: counted})
9498
return counted_vul
9599

100+
def check_for_new_cves(self):
101+
"""
102+
Check for new CVEs above the blocking level and notify maintainers.
103+
"""
104+
blocking_levels = constants.BaseSeverity.names[:constants.BaseSeverity.names.index(self.deployment_threshold) + 1]
105+
new_cves = Report.objects.filter(
106+
dependency__project=self,
107+
dependency__in_use=True,
108+
cve_object__base_severity__in=blocking_levels,
109+
update_date__gte=now().date()
110+
)
111+
112+
if new_cves.exists():
113+
# Notify maintainers
114+
maintainers = self.get_maintainers_emails()
115+
send_mail(
116+
subject=f"New CVEs detected for project {self.project_name}",
117+
message=f"New CVEs have been detected that exceed the blocking level for project {self.project_name}. Please review them.",
118+
from_email="[email protected]",
119+
recipient_list=maintainers,
120+
)
121+
122+
def get_maintainers_emails(self):
123+
"""
124+
Retrieve the email addresses of the maintainers for this project.
125+
"""
126+
# Assuming a Many-to-Many relationship with User model
127+
return [user.email for user in self.user_set.all()]
128+
96129

97130
class CVEObject(models.Model):
98131
class Meta:

backend/analyzer/tasks.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from celery import shared_task
2+
from celery.schedules import crontab
3+
from .models import Project
4+
from analyzer.manager.project_manager import ProjectManager
5+
from analyzer.celery import app
6+
7+
@shared_task
8+
def check_projects_for_new_cves():
9+
"""
10+
Celery task to check all projects for new CVEs and notify maintainers if necessary.
11+
If a project has a repository URL and access token, it will clone the repository,
12+
run the dependency-checker, and update the project data.
13+
"""
14+
for project in Project.objects.all():
15+
if project.repository_url and project.access_token:
16+
project_manager = ProjectManager(project)
17+
project_manager.run_dependency_checker()
18+
project.check_for_new_cves()
19+
20+
# Schedule the task to run daily
21+
app.conf.beat_schedule = {
22+
'check-projects-daily': {
23+
'task': 'analyzer.tasks.check_projects_for_new_cves',
24+
'schedule': crontab(hour=0, minute=0), # Runs daily at midnight
25+
},
26+
}

backend/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ coverage==7.6.4
1212
gunicorn==23.0.0
1313
cyclonedx-python-lib==8.2.1
1414
whitenoise==6.7.0
15+
celery[redis]==5.3.0
16+
django-celery-beat==2.8.0

backend/securecheckplus/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ def get_env_variable_or_shutdown_gracefully(var_name):
159159
"rest_framework",
160160
]
161161

162+
INSTALLED_APPS += [
163+
"django_celery_beat",
164+
]
165+
162166
MIDDLEWARE = [
163167
"django.middleware.security.SecurityMiddleware",
164168
"whitenoise.middleware.WhiteNoiseMiddleware",
@@ -300,3 +304,9 @@ def format(self, record):
300304
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
301305
SESSION_SAVE_EVERY_REQUEST = True
302306
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
307+
308+
# Celery Configuration
309+
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
310+
CELERY_ACCEPT_CONTENT = ["json"]
311+
CELERY_TASK_SERIALIZER = "json"
312+
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")

backend/webserver/serializer/project_serializer.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
77

88

99
class ProjectBasicSerializer(serializers.ModelSerializer):
10+
repositoryUrl = serializers.URLField(source="repository_url", allow_blank=True, required=False)
11+
accessToken = serializers.CharField(source="access_token", allow_blank=True, required=False)
12+
1013
class Meta:
1114
model = Project
12-
fields = ["projectId", "projectName", "updated", "deploymentThreshold"]
15+
fields = [
16+
"projectId", "projectName", "updated", "deploymentThreshold",
17+
"repositoryUrl", "accessToken"
18+
]
1319

1420
projectId = serializers.CharField(source="project_id", read_only=True)
1521
projectName = serializers.CharField(source="project_name", allow_blank=True)
@@ -20,9 +26,12 @@ class Meta:
2026
class ProjectDetailSerializer(ProjectBasicSerializer):
2127
class Meta:
2228
model = Project
23-
fields = ["projectId", "projectName", "updated", "deploymentThreshold",
24-
"resolvedReportCount", "solutionDistribution", "statusDistribution", "dependencyCount",
25-
"notEvaluated", "evaluated"]
29+
fields = [
30+
"projectId", "projectName", "updated", "deploymentThreshold",
31+
"repositoryUrl", "accessToken",
32+
"resolvedReportCount", "solutionDistribution", "statusDistribution", "dependencyCount",
33+
"notEvaluated", "evaluated"
34+
]
2635

2736
resolvedReportCount = serializers.ReadOnlyField(source="resolved_report_count")
2837
solutionDistribution = serializers.ReadOnlyField(source="solution_distribution")

backend/webserver/views/project_views.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,9 @@ def put(self, request, project_id: str):
130130
try:
131131
project = Project.objects.get(project_id__iexact=project_id)
132132

133-
project_serializer = ProjectBasicSerializer(project, data=request.data,
134-
partial=True)
133+
project_serializer = ProjectBasicSerializer(project, data=request.data, partial=True)
135134
if project_serializer.is_valid():
136-
project_serializer.save()
135+
project_serializer.save()
137136

138137
return Response(data=f"Update of Project: {project_id} successful.")
139138
except KeyError as ke:
@@ -152,7 +151,7 @@ def post(self, request, project_id):
152151
raise AlreadyExists(project_id)
153152
if len(project_id) >= 1 and re.search(r"^[\w-]+$", project_id):
154153
project = Project.objects.create(project_id=project_id)
155-
project_serializer = ProjectBasicSerializer(project, request.data)
154+
project_serializer = ProjectBasicSerializer(project, data=request.data)
156155
if project_serializer.is_valid():
157156
project_serializer.save()
158157
else:

frontend/src/components/ProjectSettingsContent.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const ProjectSettingsContent: React.FunctionComponent<DialogProps> = ({setOpen}:
2929
const [projectName, setProjectName] = useState<string>("")
3030
const [threshold, setThreshold] = useState<string>("")
3131
const [apiKey, setApiKey] = useState("");
32+
const [repositoryUrl, setRepositoryUrl] = useState<string>("");
33+
const [accessToken, setAccessToken] = useState<string>("");
3234
const {data: fetchedApiKey, refetch} = useQuery("apiKey", () => getApiKey(projectId), {enabled: false})
3335
const user = useUserContext();
3436
const notification = useNotification();
@@ -37,6 +39,8 @@ const ProjectSettingsContent: React.FunctionComponent<DialogProps> = ({setOpen}:
3739
if (isSuccess) {
3840
setProjectName(data?.data.projectName);
3941
setThreshold(data?.data.deploymentThreshold);
42+
setRepositoryUrl(data?.data.repositoryUrl || "");
43+
setAccessToken(data?.data.accessToken || "");
4044
}
4145

4246
if (isError) {
@@ -76,7 +80,9 @@ const ProjectSettingsContent: React.FunctionComponent<DialogProps> = ({setOpen}:
7680
const handleSave = useMutation(() => updateProject(projectId, {
7781
projectId: projectId,
7882
projectName: projectName,
79-
deploymentThreshold: threshold
83+
deploymentThreshold: threshold,
84+
repositoryUrl: repositoryUrl,
85+
accessToken: accessToken
8086
}), {
8187
onSuccess: () => {
8288
queryClient.invalidateQueries(["projectDetails", projectId]);
@@ -112,6 +118,19 @@ const ProjectSettingsContent: React.FunctionComponent<DialogProps> = ({setOpen}:
112118
helperText={projectName.length > 20 ? localization.dialog.projectNameHelperToLong : ""}
113119
onChange={(e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => setProjectName(e.target.value)}
114120
/>
121+
<TextField
122+
label={localization.dialog.repositoryUrl}
123+
value={repositoryUrl}
124+
variant="filled"
125+
onChange={(e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => setRepositoryUrl(e.target.value)}
126+
/>
127+
<TextField
128+
label={localization.dialog.accessToken}
129+
value={accessToken}
130+
variant="filled"
131+
type="password"
132+
onChange={(e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => setAccessToken(e.target.value)}
133+
/>
115134
<Stack mt={"2rem"}>
116135
<Typography variant={"body1"}>{localization.ProjectPage.deploymentThresholdTitle}</Typography>
117136
<DropdownMenu readOnly={false}

0 commit comments

Comments
 (0)