mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(dashboard): render dynamic-provider compliance frameworks (#11503)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
@@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2):
|
||||
return html.Div(section_containers, className="compliance-data-layout")
|
||||
|
||||
|
||||
def _status_bar(success, failed, classname):
|
||||
"""Build the stacked PASS/FAIL bar shown next to an accordion title."""
|
||||
fig = go.Figure(
|
||||
data=[
|
||||
go.Bar(
|
||||
name="Failed",
|
||||
x=[failed],
|
||||
y=[""],
|
||||
orientation="h",
|
||||
marker=dict(color="#e77676"),
|
||||
width=[0.8],
|
||||
),
|
||||
go.Bar(
|
||||
name="Success",
|
||||
x=[success],
|
||||
y=[""],
|
||||
orientation="h",
|
||||
marker=dict(color="#45cc6e"),
|
||||
width=[0.8],
|
||||
),
|
||||
]
|
||||
)
|
||||
fig.update_layout(
|
||||
barmode="stack",
|
||||
margin=dict(l=10, r=10, t=10, b=10),
|
||||
paper_bgcolor="rgba(0,0,0,0)",
|
||||
plot_bgcolor="rgba(0,0,0,0)",
|
||||
showlegend=False,
|
||||
width=350,
|
||||
height=30,
|
||||
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
|
||||
yaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
|
||||
annotations=[
|
||||
dict(
|
||||
x=success + failed,
|
||||
y=0,
|
||||
xref="x",
|
||||
yref="y",
|
||||
text=str(success),
|
||||
showarrow=False,
|
||||
font=dict(color="#45cc6e", size=14),
|
||||
xanchor="left",
|
||||
yanchor="middle",
|
||||
),
|
||||
dict(
|
||||
x=0,
|
||||
y=0,
|
||||
xref="x",
|
||||
yref="y",
|
||||
text=str(failed),
|
||||
showarrow=False,
|
||||
font=dict(color="#e77676", size=14),
|
||||
xanchor="right",
|
||||
yanchor="middle",
|
||||
),
|
||||
],
|
||||
)
|
||||
fig.add_annotation(
|
||||
x=failed,
|
||||
y=0.3,
|
||||
text="|",
|
||||
showarrow=False,
|
||||
xanchor="center",
|
||||
yanchor="middle",
|
||||
font=dict(size=20),
|
||||
)
|
||||
return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname)
|
||||
|
||||
|
||||
def get_section_containers_generic(data, section_col, id_col):
|
||||
"""Two-level view: section -> requirement id (+ description) -> checks.
|
||||
|
||||
Sorts lexicographically so arbitrary requirement IDs never crash the
|
||||
version-aware sort used by the CIS renderer.
|
||||
"""
|
||||
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
|
||||
data[section_col] = data[section_col].astype(str)
|
||||
data[id_col] = data[id_col].astype(str)
|
||||
data.sort_values(by=[section_col, id_col], inplace=True)
|
||||
|
||||
counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0)
|
||||
counts_id = (
|
||||
data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0)
|
||||
)
|
||||
|
||||
def count(counts, key, emoji):
|
||||
return counts.loc[key, emoji] if emoji in counts.columns else 0
|
||||
|
||||
has_description = "REQUIREMENTS_DESCRIPTION" in data.columns
|
||||
table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"]
|
||||
|
||||
section_containers = []
|
||||
for section in data[section_col].unique():
|
||||
graph_div = html.Div(
|
||||
_status_bar(
|
||||
count(counts_section, section, pass_emoji),
|
||||
count(counts_section, section, fail_emoji),
|
||||
"info-bar",
|
||||
),
|
||||
className="graph-section",
|
||||
)
|
||||
|
||||
internal_items = []
|
||||
for req_id in data[data[section_col] == section][id_col].unique():
|
||||
specific_data = data[
|
||||
(data[section_col] == section) & (data[id_col] == req_id)
|
||||
]
|
||||
data_table = dash_table.DataTable(
|
||||
data=specific_data.to_dict("records"),
|
||||
columns=[
|
||||
{"name": i, "id": i}
|
||||
for i in table_cols
|
||||
if i in specific_data.columns
|
||||
],
|
||||
style_table={"overflowX": "auto"},
|
||||
style_as_list_view=True,
|
||||
style_cell={"textAlign": "left", "padding": "5px"},
|
||||
)
|
||||
graph_div_req = html.Div(
|
||||
_status_bar(
|
||||
count(counts_id, (section, req_id), pass_emoji),
|
||||
count(counts_id, (section, req_id), fail_emoji),
|
||||
"info-bar-child",
|
||||
),
|
||||
className="graph-section-req",
|
||||
)
|
||||
|
||||
title = req_id
|
||||
if has_description:
|
||||
title = (
|
||||
f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}"
|
||||
)
|
||||
if len(title) > 130:
|
||||
title = title[:130] + " ..."
|
||||
|
||||
internal_items.append(
|
||||
html.Div(
|
||||
[
|
||||
graph_div_req,
|
||||
dbc.Accordion(
|
||||
[
|
||||
dbc.AccordionItem(
|
||||
title=title,
|
||||
children=[
|
||||
html.Div(
|
||||
[data_table],
|
||||
className="inner-accordion-content",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
start_collapsed=True,
|
||||
flush=True,
|
||||
),
|
||||
],
|
||||
className="accordion-inner--child",
|
||||
)
|
||||
)
|
||||
|
||||
section_containers.append(
|
||||
html.Div(
|
||||
[
|
||||
graph_div,
|
||||
dbc.Accordion(
|
||||
[
|
||||
dbc.AccordionItem(
|
||||
title=f"{section}", children=internal_items
|
||||
)
|
||||
],
|
||||
start_collapsed=True,
|
||||
flush=True,
|
||||
),
|
||||
],
|
||||
className="accordion-inner",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(section_containers, className="compliance-data-layout")
|
||||
|
||||
|
||||
def get_section_containers_format4(data, section_1):
|
||||
|
||||
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import (
|
||||
get_section_containers_format4,
|
||||
get_section_containers_generic,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
# Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime.
|
||||
attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")]
|
||||
|
||||
# Section column (in priority order):
|
||||
# 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention
|
||||
# 2. First discovered attribute column — covers novel schemas
|
||||
# 3. None — no section, group flat by requirement id
|
||||
if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols:
|
||||
section_col = "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
elif attr_cols:
|
||||
section_col = attr_cols[0]
|
||||
else:
|
||||
section_col = None
|
||||
|
||||
base_cols = [
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"STATUS",
|
||||
"CHECKID",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
|
||||
# Two levels (section -> requirement id) when a section distinct from the
|
||||
# id exists; otherwise group flat by requirement id.
|
||||
if section_col and section_col != "REQUIREMENTS_ID":
|
||||
needed = [section_col] + base_cols
|
||||
aux = data[[c for c in needed if c in data.columns]].copy()
|
||||
return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID")
|
||||
|
||||
aux = data[[c for c in base_cols if c in data.columns]].copy()
|
||||
return get_section_containers_format4(aux, "REQUIREMENTS_ID")
|
||||
@@ -156,7 +156,7 @@ def create_layout_compliance(
|
||||
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
|
||||
html.Span("Subscribe to Prowler Cloud"),
|
||||
],
|
||||
href="https://prowler.pro/",
|
||||
href="https://cloud.prowler.com/",
|
||||
target="_blank",
|
||||
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
|
||||
),
|
||||
|
||||
@@ -215,6 +215,58 @@ else:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_scope_columns(data):
|
||||
"""Guarantee ACCOUNTID and REGION exist.
|
||||
|
||||
Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive
|
||||
them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and
|
||||
fall back to "-" to avoid a KeyError.
|
||||
"""
|
||||
cols = list(data.columns)
|
||||
scope = []
|
||||
if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols:
|
||||
start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE")
|
||||
scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")]
|
||||
|
||||
if "ACCOUNTID" not in data.columns:
|
||||
if scope:
|
||||
data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True)
|
||||
else:
|
||||
data["ACCOUNTID"] = "-"
|
||||
if "REGION" not in data.columns:
|
||||
if scope:
|
||||
data.rename(columns={scope.pop(0): "REGION"}, inplace=True)
|
||||
else:
|
||||
data["REGION"] = "-"
|
||||
return data
|
||||
|
||||
|
||||
def _dispatch_compliance_renderer(data, analytics_input):
|
||||
"""Resolve the compliance renderer module and return (table, deduped_data).
|
||||
|
||||
Tries to import the framework-specific builtin module. On
|
||||
ModuleNotFoundError (dynamic/external provider with no dedicated module),
|
||||
falls back to the generic renderer. Any other ImportError is re-raised.
|
||||
get_table() is called OUTSIDE the try block so errors inside the renderer
|
||||
surface as real exceptions rather than being swallowed.
|
||||
"""
|
||||
current = analytics_input.replace(".", "_")
|
||||
target = f"dashboard.compliance.{current}"
|
||||
try:
|
||||
module = importlib.import_module(target)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != target:
|
||||
raise
|
||||
from dashboard.compliance import generic as module
|
||||
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
|
||||
if "MUTED" in data.columns:
|
||||
dedup_columns.insert(2, "MUTED")
|
||||
data = data.drop_duplicates(subset=dedup_columns)
|
||||
if "threatscore" in analytics_input:
|
||||
data = get_threatscore_mean_by_pillar(data)
|
||||
return module.get_table(data), data
|
||||
|
||||
|
||||
@callback(
|
||||
[
|
||||
Output("output", "children"),
|
||||
@@ -292,7 +344,7 @@ def display_data(
|
||||
data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True)
|
||||
|
||||
# Filter the chosen level of the CIS
|
||||
if is_level_1:
|
||||
if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns:
|
||||
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
|
||||
|
||||
# Rename the column PROJECTID to ACCOUNTID for GCP
|
||||
@@ -314,6 +366,9 @@ def display_data(
|
||||
data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True)
|
||||
data["REGION"] = "-"
|
||||
|
||||
# Normalize scope columns for any remaining (e.g. dynamic) provider.
|
||||
data = _ensure_scope_columns(data)
|
||||
|
||||
# Filter ACCOUNT
|
||||
if account_filter == ["All"]:
|
||||
updated_cloud_account_values = data["ACCOUNTID"].unique()
|
||||
@@ -409,36 +464,7 @@ def display_data(
|
||||
# Check cases where the compliance start with AWS_
|
||||
if "aws_" in analytics_input:
|
||||
analytics_input = analytics_input + "_aws"
|
||||
try:
|
||||
current = analytics_input.replace(".", "_")
|
||||
compliance_module = importlib.import_module(
|
||||
f"dashboard.compliance.{current}"
|
||||
)
|
||||
# Build subset list based on available columns
|
||||
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
|
||||
if "MUTED" in data.columns:
|
||||
dedup_columns.insert(2, "MUTED")
|
||||
data = data.drop_duplicates(subset=dedup_columns)
|
||||
|
||||
if "threatscore" in analytics_input:
|
||||
data = get_threatscore_mean_by_pillar(data)
|
||||
|
||||
table = compliance_module.get_table(data)
|
||||
except ModuleNotFoundError:
|
||||
table = html.Div(
|
||||
[
|
||||
html.H5(
|
||||
"No data found for this compliance",
|
||||
className="card-title",
|
||||
style={"text-align": "left", "color": "black"},
|
||||
)
|
||||
],
|
||||
style={
|
||||
"width": "99%",
|
||||
"margin-right": "0.8%",
|
||||
"margin-bottom": "10px",
|
||||
},
|
||||
)
|
||||
table, data = _dispatch_compliance_renderer(data, analytics_input)
|
||||
|
||||
df = data.copy()
|
||||
# Remove Muted rows
|
||||
|
||||
@@ -1538,7 +1538,7 @@ def filter_data(
|
||||
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
|
||||
html.Span("Subscribe to Prowler Cloud"),
|
||||
],
|
||||
href="https://prowler.pro/",
|
||||
href="https://cloud.prowler.com/",
|
||||
target="_blank",
|
||||
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user