Skip to content
14 changes: 5 additions & 9 deletions cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,18 +179,14 @@ def __export_config(endpoint: platform.Platform, what: list[str], **kwargs) -> N
log.info("Exporting configuration from %s completed", kwargs["url"])


def __read_input_file(file: str) -> dict[str, any]:
def __import_config(endpoint: platform.Platform, what: list[str], **kwargs) -> None:
"""Imports a platform configuration from a JSON file"""
log.info("Importing configuration to %s", kwargs[options.URL])
try:
with open(file, "r", encoding="utf-8") as fd:
with open(kwargs[options.REPORT_FILE], "r", encoding="utf-8") as fd:
data = json.loads(fd.read())
except FileNotFoundError as e:
utilities.exit_fatal(f"OS error while reading file: {e}", exit_code=errcodes.OS_ERROR)
return data


def __import_config(endpoint: platform.Platform, what: list[str], data: dict[str, any], **kwargs) -> None:
"""Imports a platform configuration from a JSON file"""
log.info("Importing configuration to %s", kwargs[options.URL])
key_list = kwargs[options.KEYS]

calls = {
Expand Down Expand Up @@ -240,7 +236,7 @@ def main() -> None:
if kwargs["import"]:
if kwargs["file"] is None:
utilities.exit_fatal("--file is mandatory to import configuration", errcodes.ARGS_ERROR)
__import_config(endpoint, what, __read_input_file(kwargs[options.REPORT_FILE]), **kwargs)
__import_config(endpoint, what, **kwargs)
utilities.stop_clock(start_time)
sys.exit(0)

Expand Down
7 changes: 7 additions & 0 deletions doc/sonar-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ sonar-audit --what projects -f projectsAudit.csv --csvSeparator ';'
- More than 3 groups with `create project` permission
- More than 10 groups with any global permissions
- Permission Templates: (if `audit.projects.permissions = yes`, default `yes`)
- Permissions Templates with no permissions granted
- More than `audit.projects.permissions.maxUsers` different users with direct permissions (default 5)
- More than `audit.projects.permissions.maxAdminUsers` users with Project admin permission (default 2)
- More than `audit.projects.permissions.maxGroups` different groups with permissions on project (default 5)
Expand All @@ -120,6 +121,10 @@ sonar-audit --what projects -f projectsAudit.csv --csvSeparator ';'
- More than `audit.projects.permissions.maxAdminGroups` groups with project admin permission (default 2)
- `sonar-users` group with elevated project permissions
- `Anyone` group with any project permissions
- No projectKeyPattern for a template that is not a default
- Suspicious projectKeyPattern (a pattern that is likely to not select more than 1 key) for instance:
- `my_favorite_project` (no `.` in pattern)
- `BUXXXX-*` (Likely confusion between wildcards and regexp)
- DB Cleaner: (if `audit.globalSettings = yes`, default `yes`)
- Delay to delete inactive short lived branches (7.9) or branches (8.0+) not between 10 and 60 days
- Delay to delete closed issues not between 10 and 60 days
Expand Down Expand Up @@ -195,10 +200,12 @@ sonar-audit --what projects -f projectsAudit.csv --csvSeparator ';'
- Empty portfolios (with no projects) if `audit.portfolios.empty` is `yes`
- Portfolios composed of a single project if `audit.portfolios.singleton` is `yes`
- Last recomputation `FAILED`
- Portfolios with no permissions
- Applications: (if `audit.applications = yes`, default `yes`)
- Empty applications (with no projects) if `audit.applications.empty` is `yes`
- Applications composed of a single project if `audit.applications.singleton` is `yes`
- Last recomputation `FAILED`
- Applications with no permissions
- Users: (if `audit.users = yes`, default `yes`)
- Users that did not login on the platform since `audit.users.maxLoginAge` days (default 180 days)
- Tokens older than `audit.tokens.maxAge` days (default 90 days)
Expand Down
6 changes: 5 additions & 1 deletion doc/what-is-new.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Next version yet unreleased

- `sonar-tools` is now available as a docker image
- `sonar-config` export can now export configuration as a YAML file (Only JSON was available previously). Import of YAML is not yet available
- `sonar-config`
- Export can now export configuration as a YAML file (Only JSON was available previously).
Import of YAML is not yet available
- Beta version of config import in SonarCloud
- `sonar-audit` a couple of new audit problems on permission templates (with no permissions, with no or wrong regexp)

# Version 3.3

Expand Down
17 changes: 14 additions & 3 deletions sonar/aggregations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
Parent module of applications and portfolios

"""

from typing import Optional
import json

import sonar.logging as log
from sonar.util import types
import sonar.platform as pf
from sonar.util.types import ApiPayload, ApiParams

import sonar.components as comp

Expand All @@ -37,7 +39,7 @@
class Aggregation(comp.Component):
"""Parent class of applications and portfolios"""

def __init__(self, endpoint: pf.Platform, key: str, data: ApiPayload = None) -> None:
def __init__(self, endpoint: pf.Platform, key: str, data: types.ApiPayload = None) -> None:
self._nbr_projects = None
self._permissions = None
super().__init__(endpoint=endpoint, key=key)
Expand Down Expand Up @@ -90,8 +92,17 @@ def _audit_empty_aggregation(self, broken_rule: object) -> list[Problem]:
def _audit_singleton_aggregation(self, broken_rule: object) -> list[Problem]:
return self._audit_aggregation_cardinality((1, 1), broken_rule)

def permissions(self) -> Optional[object]:
"""Should be implement in child classes"""
return self._permissions

def audit(self, audit_settings: types.ConfigSettings) -> list[Problem]:
if self.permissions() is None:
return []
return self.permissions().audit(audit_settings)


def count(api: str, endpoint: pf.Platform, params: ApiParams = None) -> int:
def count(api: str, endpoint: pf.Platform, params: types.ApiParams = None) -> int:
"""Returns number of aggregations of a given type (Application OR Portfolio)
:return: number of Apps or Portfolios
:rtype: int
Expand Down
9 changes: 7 additions & 2 deletions sonar/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,12 @@ def audit(self, audit_settings: types.ConfigSettings) -> list[problem.Problem]:
:rtype: list [Problem]
"""
log.info("Auditing %s", str(self))
return self._audit_empty(audit_settings) + self._audit_singleton(audit_settings) + self._audit_bg_task(audit_settings)
return (
super().audit(audit_settings)
+ self._audit_empty(audit_settings)
+ self._audit_singleton(audit_settings)
+ self._audit_bg_task(audit_settings)
)

def export(self, export_settings: types.ConfigSettings) -> types.ObjectJsonRepr:
"""Exports an application
Expand All @@ -346,7 +351,7 @@ def export(self, export_settings: types.ConfigSettings) -> types.ObjectJsonRepr:
)
return util.remove_nones(util.filter_export(json_data, _IMPORTABLE_PROPERTIES, export_settings.get("FULL_EXPORT", False)))

def set_permissions(self, data):
def set_permissions(self, data: types.JsonPermissions) -> application_permissions.ApplicationPermissions:
"""Sets an application permissions

:param dict data: dict of permission {"users": [<user1>, <user2>, ...], "groups": [<group1>, <group2>, ...]}
Expand Down
20 changes: 20 additions & 0 deletions sonar/audit/rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@
"type": "BAD_PRACTICE",
"message": "{}"
},
"OBJECT_WITH_NO_PERMISSIONS": {
"severity": "MEDIUM",
"type": "OPERATIONS",
"message": "{} has no permissions defined"
},
"OBJECT_WITH_NO_ADMIN_PERMISSION": {
"severity": "MEDIUM",
"type": "OPERATIONS",
"message": "{} has no user or group with admin permission"
},
"TEMPLATE_WITH_NO_PATTERN": {
"severity": "MEDIUM",
"type": "OPERATIONS",
"message": "{} is not a default and has no projectKetPattern defined, it will never be used"
},
"TEMPLATE_WITH_SUSPICIOUS_PATTERN": {
"severity": "HIGH",
"type": "OPERATIONS",
"message": "{} has a suspicious projectKeyPattern '{}'. It should be a regexp that may match several keys"
},
"DUBIOUS_GLOBAL_SETTING": {
"severity": "HIGH",
"type": "BAD_PRACTICE",
Expand Down
5 changes: 5 additions & 0 deletions sonar/audit/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ class RuleId(enum.Enum):
ANYONE_WITH_GLOBAL_PERMS = 151
SONAR_USERS_WITH_ELEVATED_PERMS = 152
FAILED_WEBHOOK = 153
OBJECT_WITH_NO_PERMISSIONS = 154
OBJECT_WITH_NO_ADMIN_PERMISSION = 155

DCE_DIFFERENT_APP_NODES_VERSIONS = 160
DCE_DIFFERENT_APP_NODES_PLUGINS = 161
Expand Down Expand Up @@ -161,6 +163,9 @@ class RuleId(enum.Enum):

GROUP_EMPTY = 5200

TEMPLATE_WITH_NO_PATTERN = 5300
TEMPLATE_WITH_SUSPICIOUS_PATTERN = 5301

ERROR_IN_LOGS = 6000
WARNING_IN_LOGS = 6001
DEPRECATION_WARNINGS = 6002
Expand Down
25 changes: 19 additions & 6 deletions sonar/permissions/global_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,41 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#

from __future__ import annotations

import sonar.logging as log
from sonar.permissions import permissions
from sonar.util import types


class GlobalPermissions(permissions.Permissions):
"""Abstraction of SonarQube global permissions"""

API_GET = {"users": "permissions/users", "groups": "permissions/groups"}
API_SET = {"users": "permissions/add_user", "groups": "permissions/add_group"}
API_REMOVE = {"users": "permissions/remove_user", "groups": "permissions/remove_group"}
API_GET_FIELD = {"users": "login", "groups": "name"}
API_SET_FIELD = {"users": "login", "groups": "groupName"}

def __str__(self):
def __init__(self, concerned_object: object) -> None:
self.concerned_object = concerned_object
self.endpoint = concerned_object
self.permissions = None
self.read()

def __str__(self) -> str:
return "global permissions"

def read(self):
def read(self) -> GlobalPermissions:
"""Reads global permissions"""
self.permissions = permissions.NO_PERMISSIONS
for ptype in permissions.PERMISSION_TYPES:
self.permissions[ptype] = self._get_api(
GlobalPermissions.API_GET[ptype], ptype, GlobalPermissions.API_GET_FIELD[ptype], ps=permissions.MAX_PERMS
)
return self

def set(self, new_perms):
def set(self, new_perms: types.JsonPermissions) -> GlobalPermissions:
log.debug("Setting %s to %s", str(self), str(new_perms))
if self.permissions is None:
self.read()
Expand All @@ -57,7 +68,8 @@ def set(self, new_perms):
return self.read()


def import_config(endpoint, config_data):
def import_config(endpoint: object, config_data: types.ObjectJsonRepr):
"""Imports global permissions in a SonarQube platform"""
my_permissions = config_data.get("permissions", {})
if len(my_permissions) == 0:
log.info("No global permissions in config, skipping import...")
Expand All @@ -67,9 +79,10 @@ def import_config(endpoint, config_data):
global_perms.set(my_permissions)


def edition_filter(perms, ed):
def edition_filter(perms: types.JsonPermissions, ed: str) -> types.JsonPermissions:
"""Filters permissions available in a given edition"""
for p in perms.copy():
if ed == "community" and p in ("portfoliocreator", "applicationcreator") or ed == "developer" and p == "portfoliocreator":
log.warning("Can't remove permission '%s' on a %s edition", p, ed)
log.warning("Can't manage permission '%s' on a %s edition", p, ed)
perms.remove(p)
return perms
18 changes: 17 additions & 1 deletion sonar/permissions/permission_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
from __future__ import annotations

import json
import re
from requests.exceptions import HTTPError

import sonar.logging as log
from sonar.util import types
from sonar import sqobject, utilities
from sonar.permissions import template_permissions
import sonar.platform as pf
from sonar.audit.rules import get_rule, RuleId
import sonar.audit.problem as pb

_OBJECTS = {}
Expand Down Expand Up @@ -176,9 +178,23 @@ def to_json(self, export_settings: types.ConfigSettings = None) -> types.ObjectJ
json_data["lastUpdate"] = utilities.date_to_string(self.last_update)
return utilities.remove_nones(utilities.filter_export(json_data, _IMPORTABLE_PROPERTIES, export_settings.get("FULL_EXPORT", False)))

def _audit_pattern(self, audit_settings: types.ConfigSettings) -> list[pb.Problem]:
log.debug("Auditing %s projectKeyPattern ('%s')", str(self.project_key_pattern))
if not self.project_key_pattern or self.project_key_pattern == "":
if not (self.is_applications_default() or self.is_portfolios_default() or self.is_projects_default()):
return [pb.Problem(get_rule(RuleId.TEMPLATE_WITH_NO_PATTERN), self, str(self))]
else:
# Inspect regexp to detect suspicious pattern - Can't determine all bad cases but do our best
# Currently detecting:
# - Absence of '.' in the regexp
# - '*' not preceded by '.' (confusion between wildcard and regexp)
if not re.search(r"(^|[^\\])\.", self.project_key_pattern) or re.search(r"(^|[^.])\*", self.project_key_pattern):
return [pb.Problem(get_rule(RuleId.TEMPLATE_WITH_SUSPICIOUS_PATTERN), self, str(self), self.project_key_pattern)]
return []

def audit(self, audit_settings: types.ConfigSettings) -> list[pb.Problem]:
log.debug("Auditing %s", str(self))
return self.permissions().audit(audit_settings)
return self._audit_pattern(audit_settings) + self.permissions().audit(audit_settings)


def get_object(endpoint: pf.Platform, name: str) -> PermissionTemplate:
Expand Down
42 changes: 20 additions & 22 deletions sonar/permissions/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import sonar.logging as log
from sonar import utilities, errcodes
from sonar.util import types
from sonar.audit.rules import get_rule, RuleId
from sonar.audit.problem import Problem

COMMUNITY_GLOBAL_PERMISSIONS = {
"admin": "Administer System",
Expand Down Expand Up @@ -71,11 +73,15 @@ class Permissions(ABC):
Abstraction of sonar objects permissions
"""

def __init__(self, endpoint: object) -> None:
self.endpoint = endpoint
def __init__(self, concerned_object: object) -> None:
self.concerned_object = concerned_object
self.endpoint = concerned_object.endpoint
self.permissions = None
self.read()

def __str__(self) -> str:
return f"permissions of {str(self.concerned_object)}"

def to_json(self, perm_type: str = None, csv: bool = False) -> types.JsonPermissions:
"""Converts a permission object to JSON"""
if not csv:
Expand All @@ -98,10 +104,6 @@ def export(self, export_settings: types.ConfigSettings) -> types.ObjectJsonRepr:
return None
return perms

@abstractmethod
def __str__(self) -> str:
pass

@abstractmethod
def read(self) -> Permissions:
"""
Expand Down Expand Up @@ -130,21 +132,6 @@ def set_group_permissions(self, group_perms: dict[str, list[str]]) -> Permission
"""
return self.set({"groups": group_perms})

"""
@abstractmethod
def remove_user_permissions(self, user_perms_dict):
pass

@abstractmethod
def remove_group_permissions(self, group_perms_dict):
pass


def remove_permissions(self, perms_dict):
self.remove_user_permissions(perms_dict.get("users", None))
self.remove_group_permissions(perms_dict.get("groups", None))
"""

def clear(self) -> Permissions:
"""Clears all permissions of an object
:return: self
Expand Down Expand Up @@ -206,6 +193,18 @@ def _filter_permissions_for_edition(self, perms: types.JsonPermissions) -> types
perms.remove(p)
return perms

def audit_nbr_permissions(self, audit_settings: types.ConfigSettings) -> list[Problem]:
"""Audits that at least one permission is granted to a user or a group
and that at least one group or user has admin permission on the object"""
if self.count() == 0:
return [Problem(get_rule(RuleId.OBJECT_WITH_NO_PERMISSIONS), self.concerned_object, str(self.concerned_object))]
elif self.count(perm_filter=["admin"]) == 0:
return [Problem(get_rule(RuleId.OBJECT_WITH_NO_ADMIN_PERMISSION), self.concerned_object, str(self.concerned_object))]
return []

def audit(self, audit_settings: types.ConfigSettings) -> list[Problem]:
return self.audit_nbr_permissions(audit_settings)

def count(self, perm_type: Optional[str] = None, perm_filter: Optional[list[str]] = None) -> int:
"""Counts number of permissions of an object

Expand All @@ -221,7 +220,6 @@ def count(self, perm_type: Optional[str] = None, perm_filter: Optional[list[str]
if perm_filter is None:
continue
perm_counter += len([1 for p in elem_perms if p in perm_filter])
log.debug("Perm counts = %d", (elem_counter if perm_filter is None else perm_counter))
return elem_counter if perm_filter is None else perm_counter

def _get_api(self, api: str, perm_type: str, ret_field: str, **extra_params) -> types.JsonPermissions:
Expand Down
Loading