blob: c4e13b959703fb1e543d3f0c0c96af6da9d99a56 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Security issue dashboard for the Apache Software Foundation"""
from app import reports, statistics, utils
from app.config import AppConfig
import asfquart
import asfquart.auth
import datetime
import os
import pathlib
from typing import Any
import quart
def _ensure_state_dir(app_config: AppConfig) -> None:
state_dir_path = app_config.state_dir_path
if not state_dir_path.exists():
state_dir_path.mkdir(parents=True)
elif not state_dir_path.is_dir():
raise NotADirectoryError(f"State directory '{state_dir_path}' is not a directory")
CLIENT = quart.Blueprint(
"client",
__name__,
static_folder="static/assets",
static_url_path="/assets",
template_folder="templates",
url_prefix="/",
)
@CLIENT.route("/")
async def home():
user = await utils.UserSession.create()
if user.is_authenticated and len(user.accessible_pmcs) == 1:
return quart.redirect(quart.url_for("client.project", project=user.accessible_pmcs[0]))
return await quart.render_template("home.html")
def _state_sort_key(state: str) -> tuple[int, str]:
if state == "untriaged":
return (0, "")
elif state == "confirmed":
return (1, "")
elif state == "disclosure":
return (3, "")
elif state.startswith("non-issue"):
return (4, state)
else:
return (2, state)
_STATE_TITLES: dict[str, str] = {
"untriaged": "Untriaged",
"confirmed": "Confirmed",
"disclosure": "Waiting for disclosure",
"non-issue-docs": "Non-issue: pending documentation improvements",
"non-issue-feedback": "Non-issue: pending feedback to reporter",
"non-issue-upstream": "Non-issue: pending upstream",
}
_STATE_DESCRIPTIONS: dict[str, str] = {
"untriaged": "After initial analysis, either reject the issue and provide feedback to the reporter, or accept it and allocate a CVE",
"confirmed": "The PMC has accepted and is working on these issues. For those that don't have CVEs allocated yet, this can be done now",
"disclosure": "A fix for these issues has been released. When you are happy with the advisory in the cveprocess tool, you can send them by moving the state to READY and using the 'Send these Emails' button on the 'OSS/ASF Emails' tab in cveprocess.",
"non-issue-upstream": "Make sure the issue is fixed upstream and a release is made with the fix, or find an alternative to the problematic upstream component",
}
def _state_title(state: str) -> str:
if state in _STATE_TITLES:
return _STATE_TITLES[state]
if state.startswith("non-issue-"):
return f"Non-issues: {state.removeprefix('non-issue-')}"
return f"Waiting for {state}"
def _state_description(state: str) -> str:
return _STATE_DESCRIPTIONS.get(state, "")
def _asf_group_acl(project, pmc_membership, project_membership):
return (
project in pmc_membership
or (
project in config.get().pmcs_with_security_emails
and project in project_membership
)
)
async def _require_authorization_for(project: str) -> None:
user = await utils.UserSession.create()
if not user.is_authenticated:
raise asfquart.auth.AuthenticationFailed(asfquart.auth.Requirements.E_NOT_LOGGED_IN)
pmcs = user.accessible_pmcs
if (not _asf_group_acl(project, pmcs, user.projects)
and not _asf_group_acl("security", pmcs, user.projects)):
raise asfquart.auth.AuthenticationFailed(f"You are not a member of the {project} PMC.")
async def _require_authentication() -> utils.UserSession:
user = await utils.UserSession.create()
if not user.is_authenticated:
raise asfquart.auth.AuthenticationFailed(asfquart.auth.Requirements.E_NOT_LOGGED_IN)
return user
@CLIENT.route("/statistics")
async def statistics_dashboard():
await _require_authentication()
return await quart.render_template("statistics.html", debt_constant=statistics.DEBT_CONSTANT)
@CLIENT.route("/api/statistics/debt")
async def statistics_debt_api():
user = await _require_authentication()
requested_pmcs = {
pmc.strip()
for pmc in quart.request.args.getlist("pmc")
}
if requested_pmcs:
for pmc in requested_pmcs:
if pmc not in user.accessible_pmcs and not user.in_security_team:
quart.abort(403)
# security team members see every project; everyone else sees only the
# projects they can access (the same set shown on their front page).
if requested_pmcs:
pmcs = requested_pmcs
elif user.in_security_team:
pmcs = None
elif user.accessible_pmcs:
pmcs = user.accessible_pmcs
else:
quart.abort(403)
now = datetime.datetime.now(tz=datetime.timezone.utc)
return quart.jsonify(statistics.compute_debt_chart(now, pmcs=pmcs))
async def _audit_access(project: str):
user = await utils.UserSession.create()
# Out-of-PMC access, such as security team or other admin
# access, is OK but logged with more scrutiny
mark = "" if project in user.pmcs else "[*]"
print(f"User {user.uid} accessed project {project}{mark}")
@CLIENT.route("/project/<project>")
async def project(project: str):
await _require_authorization_for(project)
await _audit_access(project)
r = await reports.load_pmc_reports(project)
states = sorted(dict.fromkeys(report.state for report in r), key=_state_sort_key)
sections = [
(_state_title(state), _state_description(state), [report for report in r if report.state == state])
for state in states
]
return await quart.render_template("project.html",
project_name=project,
debt_constant=statistics.DEBT_CONSTANT,
sections=sections,
show_subproject=project in config.get().pmcs_with_subprojects or project == "security")
@CLIENT.route("/api/project/<project>/reports")
async def project_reports_api(project: str):
await _require_authorization_for(project)
await _audit_access(project)
r = await reports.load_pmc_reports(project)
return quart.jsonify([
{
"cves": report.cves,
"title": report.title,
"asf_member_link": report.asf_member_link,
"state": report.state,
"date": report.date.isoformat(),
}
for report in r
])
def _register_routes(quart_app: asfquart.base.QuartApp) -> None:
quart_app.register_blueprint(CLIENT)
def _setup_context(quart_app: asfquart.base.QuartApp, app_config: AppConfig) -> None:
@quart_app.context_processor
async def app_context() -> dict[str, Any]:
return {
"current_user": await utils.UserSession.create()
}
_CSP = "; ".join([
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://apache.org",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
])
def _setup_security_headers(quart_app: asfquart.base.QuartApp) -> None:
@quart_app.after_request
async def add_security_headers(response: quart.Response) -> quart.Response:
response.headers.setdefault("Content-Security-Policy", _CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response
def create_app(test_environment: bool = False) -> asfquart.base.QuartApp:
from app import config
app_dir = None
cfg_file = asfquart.base.CONFIG_FNAME
# load the application config first to determine the state directory
app_path = pathlib.Path(os.getcwd())
app_config = config.load_app_config(app_path / cfg_file)
if not test_environment:
# ensure the state directory exists before proceeding
_ensure_state_dir(app_config)
# store the app secret in the state directory
token_file = str((app_config.state_dir_path / "apptoken.txt").absolute())
else:
# no secret in unit tests
token_file = None
quart_app = asfquart.construct("security-dashboard", app_dir, cfg_file, token_file)
config.setup_app_config(quart_app, app_config)
_register_routes(quart_app)
_setup_context(quart_app, app_config)
_setup_security_headers(quart_app)
return quart_app