diff --git a/data/detail_popup.ui b/data/detail_popup.ui new file mode 100644 index 0000000..8c1230c --- /dev/null +++ b/data/detail_popup.ui @@ -0,0 +1,51 @@ + + + DetailsPopup + + + + 0 + 0 + 400 + 300 + + + + Dialog + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + clicked(QAbstractButton*) + DetailsPopup + close() + + + 248 + 254 + + + 157 + 274 + + + + + diff --git a/data/userquery.ui b/data/userquery.ui new file mode 100644 index 0000000..897dec0 --- /dev/null +++ b/data/userquery.ui @@ -0,0 +1,633 @@ + + + UserQueryTab + + + + 0 + 0 + 774 + 846 + + + + QAbstractScrollArea::AdjustToContents + + + true + + + + + 0 + 0 + 772 + 844 + + + + + 0 + 0 + + + + + + + + 0 + 0 + + + + + 16777215 + 20 + + + + + 11 + 75 + true + + + + SELinux Users + + + + + + + Qt::Horizontal + + + + User Browser + + + + + + + + + + Qt::Vertical + + + + + 16777215 + 16777215 + + + + Search Criteria + + + + + + QDialogButtonBox::Apply + + + + + + + + 16777215 + 120 + + + + Default MLS Level + + + + 6 + + + 6 + + + 6 + + + 6 + + + 3 + + + + + + 0 + 0 + + + + + 150 + 20 + + + + + 250 + 16777215 + + + + + + + + The level criterion will match if it is equal to the user's default level. + + + Equal + + + true + + + + + + + The level criterion will match if it dominates the user's default level. + + + Dominate + + + + + + + The level criterion will match if it is dominated by the user's default level. + + + Dominated + + + + + + + + + + + 16777215 + 120 + + + + MLS Range + + + + 6 + + + 6 + + + 6 + + + 6 + + + 3 + + + + + + 0 + 0 + + + + + 150 + 20 + + + + + 250 + 16777215 + + + + + + + + The range criterion will match if it is equal to the user's range. + + + Equal + + + true + + + + + + + The range criterion will match if it is a subset of the user's range. + + + Subset + + + + + + + The range criterion will match if it overlaps the user's range. + + + Overlap + + + + + + + The range criterion will match if it is a superset of the user's range. + + + Superset + + + + + + + + + + Roles + + + + 6 + + + 6 + + + 6 + + + 6 + + + 3 + + + + + Clear + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Invert + + + + + + + A matching user will have a role set equal to the selected roles. + + + Equal + + + + + + + A matching user will have any of the selected roles. + + + Any + + + true + + + + + + + + 0 + 0 + + + + + 250 + 16777215 + + + + Match the role set of the user. + + + QAbstractItemView::ExtendedSelection + + + + + + + + + + + + 0 + 0 + + + + 0 + + + + Results + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + QAbstractScrollArea::AdjustIgnored + + + true + + + true + + + + + + + + Raw Results + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + + 0 + 0 + + + + + Monospace + + + + + + + QPlainTextEdit::NoWrap + + + true + + + + + + + + + + + + + + 0 + 0 + + + + + 16 + 16 + + + + + 16 + 16 + + + + Qt::TabFocus + + + QPushButton:flat { border: none; } + + + + + + + icons/expand_inactive.png + icons/expand_active.pngicons/expand_inactive.png + + + + 16 + 16 + + + + true + + + false + + + true + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Notes + + + + + + + + 0 + 125 + + + + Optionally enter notes here about the query. + + + + + browser_search_splitter + notes + label + notes_expander + label_4 + + + + + UserList + QListView +
setoolsgui/apol/usermodel.h
+
+
+ + users + roles + roles_any + roles_equal + clear_roles + invert_roles + level + level_exact + level_dom + level_domby + range_ + range_exact + range_overlap + range_subset + range_superset + results_frame + table_results + raw_results + notes_expander + notes + + + + + notes_expander + toggled(bool) + notes + setVisible(bool) + + + 17 + 833 + + + 379 + 910 + + + + + clear_roles + clicked() + roles + clearSelection() + + + 429 + 99 + + + 319 + 184 + + + + +
diff --git a/setoolsgui/apol/details.py b/setoolsgui/apol/details.py new file mode 100644 index 0000000..7e90bc4 --- /dev/null +++ b/setoolsgui/apol/details.py @@ -0,0 +1,60 @@ +# Copyright 2016, Tresys Technology, LLC +# +# This file is part of SETools. +# +# SETools is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 2.1 of +# the License, or (at your option) any later version. +# +# SETools is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with SETools. If not, see +# . +# +import logging + +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import QDialog + +from ..widget import SEToolsWidget + + +class DetailsPopup(SEToolsWidget, QDialog): + + """A generic non-modal popup with a text field to write detailed info.""" + # TODO: make the font changes relative + # instead of setting absolute values + + def __init__(self, parent, title=None): + super(DetailsPopup, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + self.setupUi(title) + + def setupUi(self, title): + self.load_ui("detail_popup.ui") + + if title: + self.title = title + + @property + def title(self): + self.windowTitle(self) + + @title.setter + def title(self, text): + self.setWindowTitle(text) + + def append(self, text): + self.contents.setFontWeight(QFont.Normal) + self.contents.setFontPointSize(9) + self.contents.append(text) + + def append_header(self, text): + self.contents.setFontWeight(QFont.Black) + self.contents.setFontPointSize(11) + self.contents.append(text) diff --git a/setoolsgui/apol/mainwindow.py b/setoolsgui/apol/mainwindow.py index ce0b23f..3b7ad07 100644 --- a/setoolsgui/apol/mainwindow.py +++ b/setoolsgui/apol/mainwindow.py @@ -31,6 +31,7 @@ from .infoflow import InfoFlowAnalysisTab from .mlsrulequery import MLSRuleQueryTab from .rbacrulequery import RBACRuleQueryTab from .terulequery import TERuleQueryTab +from .userquery import UserQueryTab class ApolMainWindow(SEToolsWidget, QMainWindow): @@ -197,42 +198,15 @@ class ChooseAnalysis(SEToolsWidget, QDialog): The item_mapping attribute will be populated to map the tree list items to the analysis tab widgets. """ -# _components_map = {"Attributes (Type)": TERuleQueryTab, -# "Booleans": TERuleQueryTab, -# "Categories": TERuleQueryTab, -# "Common Permission Sets": TERuleQueryTab, -# "Object Classes": TERuleQueryTab, -# "Policy Capabilities": TERuleQueryTab, -# "Roles": TERuleQueryTab, -# "Types": TERuleQueryTab, -# "Users": TERuleQueryTab} -# -# _rule_map = {"TE Rules": TERuleQueryTab, -# "RBAC Rules": TERuleQueryTab, -# "MLS Rules": TERuleQueryTab, -# "Constraints": TERuleQueryTab} -# -# _analysis_map = {"Domain Transition Analysis": TERuleQueryTab, -# "Information Flow Analysis": TERuleQueryTab} -# -# _labeling_map = {"fs_use Statements": TERuleQueryTab, -# "Genfscon Statements": TERuleQueryTab, -# "Initial SID Statements": TERuleQueryTab, -# "Netifcon Statements": TERuleQueryTab, -# "Nodecon Statements": TERuleQueryTab, -# "Portcon Statements": TERuleQueryTab} -# -# _analysis_choices = {"Components": _components_map, -# "Rules": _rule_map, -# "Analysis": _analysis_map, -# "Labeling Statements": _labeling_map} _analysis_map = {"Domain Transition Analysis": DomainTransitionAnalysisTab, "Information Flow Analysis": InfoFlowAnalysisTab} + _components_map = {"Users": UserQueryTab} _rule_map = {"MLS Rules": MLSRuleQueryTab, "RBAC Rules": RBACRuleQueryTab, "TE Rules": TERuleQueryTab} - _analysis_choices = {"Rules": _rule_map, + _analysis_choices = {"Components": _components_map, + "Rules": _rule_map, "Analyses": _analysis_map} def __init__(self, parent): diff --git a/setoolsgui/apol/usermodel.py b/setoolsgui/apol/usermodel.py new file mode 100644 index 0000000..ff7e4cf --- /dev/null +++ b/setoolsgui/apol/usermodel.py @@ -0,0 +1,132 @@ +# Copyright 2016, Tresys Technology, LLC +# +# This file is part of SETools. +# +# SETools is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 2.1 of +# the License, or (at your option) any later version. +# +# SETools is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with SETools. If not, see +# . +# +from PyQt5.QtCore import Qt, QAbstractTableModel, QModelIndex +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QAction, QListView, QMenu +from setools.policyrep.exception import MLSDisabled + +from .details import DetailsPopup + + +def user_detail(parent, user): + """ + Create a dialog box for user details. + + Parameters: + parent The parent Qt Widget + user The user + """ + + detail = DetailsPopup(parent, "SELinux user detail: {0}".format(user)) + detail.append_header("Roles:") + + for r in user.roles: + detail.append(" {0}".format(r)) + + try: + l = user.mls_level + r = user.mls_range + except MLSDisabled: + pass + else: + detail.append_header("\nDefault MLS Level:") + detail.append(" {0}".format(l)) + detail.append_header("\nMLS Range:") + detail.append(" {0}".format(r)) + + detail.show() + + +class UserList(QListView): + + """A QListView widget for listing users.""" + + def __init__(self, parent): + super(UserList, self).__init__(parent) + + # set up right-click context menu + self.get_detail = QAction("More details...", self) + self.menu = QMenu(self) + self.menu.addAction(self.get_detail) + + def contextMenuEvent(self, event): + self.menu.popup(QCursor.pos()) + + +class UserTableModel(QAbstractTableModel): + + """Table-based model for users.""" + + def __init__(self, parent, mls): + super(UserTableModel, self).__init__(parent) + self.resultlist = [] + self.mls = mls + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + if section == 0: + return "User Name" + elif section == 1: + return "Roles" + elif section == 2: + return "Default Level" + elif section == 3: + return "Range" + else: + raise ValueError("Invalid column number: {0}".format(section)) + + def columnCount(self, parent=QModelIndex()): + if self.mls: + return 4 + else: + return 2 + + def rowCount(self, parent=QModelIndex()): + if self.resultlist: + return len(self.resultlist) + else: + return 0 + + def data(self, index, role): + if role == Qt.DisplayRole: + if not self.resultlist: + return None + + row = index.row() + col = index.column() + + if col == 0: + return str(self.resultlist[row]) + elif col == 1: + return ", ".join(sorted(str(r) for r in self.resultlist[row].roles)) + elif col == 2: + try: + return str(self.resultlist[row].mls_level) + except MLSDisabled: + return None + elif col == 3: + try: + return str(self.resultlist[row].mls_range) + except MLSDisabled: + return None + else: + raise ValueError("Invalid column number: {0}".format(col)) + elif role == Qt.UserRole: + # get the whole rule for user role + return self.resultlist[row].statement() diff --git a/setoolsgui/apol/userquery.py b/setoolsgui/apol/userquery.py new file mode 100644 index 0000000..eb698cb --- /dev/null +++ b/setoolsgui/apol/userquery.py @@ -0,0 +1,244 @@ +# Copyright 2016, Tresys Technology, LLC +# +# This file is part of SETools. +# +# SETools is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 2.1 of +# the License, or (at your option) any later version. +# +# SETools is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with SETools. If not, see +# . +# + +import logging + +from PyQt5.QtCore import pyqtSignal, Qt, QObject, QSortFilterProxyModel, QStringListModel, QThread +from PyQt5.QtGui import QPalette, QTextCursor +from PyQt5.QtWidgets import QCompleter, QHeaderView, QMessageBox, QProgressDialog, QScrollArea +from setools import UserQuery + +from ..widget import SEToolsWidget +from .models import SEToolsListModel, invert_list_selection +from .usermodel import UserTableModel, user_detail + + +class UserQueryTab(SEToolsWidget, QScrollArea): + + """User browser and query tab.""" + + def __init__(self, parent, policy, perm_map): + super(UserQueryTab, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + self.policy = policy + self.query = UserQuery(policy) + self.setupUi() + + def __del__(self): + self.thread.quit() + self.thread.wait(5000) + + def setupUi(self): + self.load_ui("userquery.ui") + + # populate user list + self.user_model = SEToolsListModel(self) + self.user_model.item_list = sorted(self.policy.users()) + self.users.setModel(self.user_model) + + # populate role list + self.role_model = SEToolsListModel(self) + self.role_model.item_list = sorted(r for r in self.policy.roles() if r != "object_r") + self.roles.setModel(self.role_model) + + # set up results + self.table_results_model = UserTableModel(self, self.policy.mls) + self.sort_proxy = QSortFilterProxyModel(self) + self.sort_proxy.setSourceModel(self.table_results_model) + self.table_results.setModel(self.sort_proxy) + + if self.policy.mls: + # setup indications of errors on level/range + self.orig_palette = self.level.palette() + self.error_palette = self.level.palette() + self.error_palette.setColor(QPalette.Base, Qt.red) + self.clear_level_error() + self.clear_range_error() + else: + # hide level and range criteria + self.level_criteria.setHidden(True) + self.range_criteria.setHidden(True) + + # set up processing thread + self.thread = QThread() + self.worker = ResultsUpdater(self.query, self.table_results_model) + self.worker.moveToThread(self.thread) + self.worker.raw_line.connect(self.raw_results.appendPlainText) + self.worker.finished.connect(self.update_complete) + self.worker.finished.connect(self.thread.quit) + self.thread.started.connect(self.worker.update) + + # create a "busy, please wait" dialog + self.busy = QProgressDialog(self) + self.busy.setModal(True) + self.busy.setRange(0, 0) + self.busy.setMinimumDuration(0) + self.busy.canceled.connect(self.thread.requestInterruption) + + # Ensure settings are consistent with the initial .ui state + self.notes.setHidden(not self.notes_expander.isChecked()) + + # connect signals + self.users.doubleClicked.connect(self.get_detail) + self.users.get_detail.triggered.connect(self.get_detail) + self.roles.selectionModel().selectionChanged.connect(self.set_roles) + self.invert_roles.clicked.connect(self.invert_role_selection) + self.level.textEdited.connect(self.clear_level_error) + self.level.editingFinished.connect(self.set_level) + self.range_.textEdited.connect(self.clear_range_error) + self.range_.editingFinished.connect(self.set_range) + self.buttonBox.clicked.connect(self.run) + + # + # User browser + # + def get_detail(self): + # .ui is set for single item selection. + index = self.users.selectedIndexes()[0] + item = self.user_model.data(index, Qt.UserRole) + + self.log.debug("Generating detail window for {0}".format(item)) + user_detail(self, item) + + # + # Role criteria + # + def set_roles(self): + selected_roles = [] + for index in self.roles.selectionModel().selectedIndexes(): + selected_roles.append(self.role_model.data(index, Qt.UserRole)) + + self.query.roles = selected_roles + + def invert_role_selection(self): + invert_list_selection(self.roles.selectionModel()) + + # + # Default level criteria + # + def clear_level_error(self): + self.level.setToolTip("Match the default level of the user.") + self.level.setPalette(self.orig_palette) + + def set_level(self): + try: + self.query.level = self.level.text() + except Exception as ex: + self.log.info("Level criterion error: " + str(ex)) + self.level.setToolTip("Error: " + str(ex)) + self.level.setPalette(self.error_palette) + + # + # Range criteria + # + def clear_range_error(self): + self.range_.setToolTip("Match the default range of the user.") + self.range_.setPalette(self.orig_palette) + + def set_range(self): + try: + self.query.range_ = self.range_.text() + except Exception as ex: + self.log.info("Range criterion error: " + str(ex)) + self.range_.setToolTip("Error: " + str(ex)) + self.range_.setPalette(self.error_palette) + + # + # Results runner + # + + def run(self, button): + # right now there is only one button. + self.query.roles_equal = self.roles_equal.isChecked() + self.query.level_dom = self.level_dom.isChecked() + self.query.level_domby = self.level_domby.isChecked() + self.query.range_overlap = self.range_overlap.isChecked() + self.query.range_subset = self.range_subset.isChecked() + self.query.range_superset = self.range_superset.isChecked() + + # start processing + self.busy.setLabelText("Processing query...") + self.busy.show() + self.raw_results.clear() + self.thread.start() + + def update_complete(self): + # update sizes/location of result displays + if not self.busy.wasCanceled(): + self.busy.setLabelText("Resizing the result table's columns; GUI may be unresponsive") + self.busy.repaint() + self.table_results.resizeColumnsToContents() + + if not self.busy.wasCanceled(): + self.busy.setLabelText("Resizing the result table's rows; GUI may be unresponsive") + self.busy.repaint() + self.table_results.resizeRowsToContents() + + if not self.busy.wasCanceled(): + self.busy.setLabelText("Moving the raw result to top; GUI may be unresponsive") + self.busy.repaint() + self.raw_results.moveCursor(QTextCursor.Start) + + self.busy.reset() + + +class ResultsUpdater(QObject): + + """ + Thread for processing queries and updating result widgets. + + Parameters: + query The query object + model The model for the results + + Qt signals: + finished The update has completed. + raw_line (str) A string to be appended to the raw results. + """ + + finished = pyqtSignal() + raw_line = pyqtSignal(str) + + def __init__(self, query, model): + super(ResultsUpdater, self).__init__() + self.query = query + self.table_results_model = model + + def update(self): + """Run the query and update results.""" + self.table_results_model.beginResetModel() + + results = [] + counter = 0 + + for counter, item in enumerate(self.query.results(), start=1): + results.append(item) + + self.raw_line.emit(item.statement()) + + if QThread.currentThread().isInterruptionRequested(): + break + elif not counter % 10: + # yield execution every 10 rules + QThread.yieldCurrentThread() + + self.table_results_model.resultlist = results + self.table_results_model.endResetModel() + + self.finished.emit()