Skip to content

Commit f616593

Browse files
authored
Merge pull request #7 from seantis/import-risk-excel
Import risk excel
2 parents 8a0b50a + 9356cf2 commit f616593

File tree

4 files changed

+345
-1
lines changed

4 files changed

+345
-1
lines changed

.github/workflows/docker-publish.yml

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Docker
2+
3+
# This workflow uses actions that are not certified by GitHub.
4+
# They are provided by a third-party and are governed by
5+
# separate terms of service, privacy policy, and support
6+
# documentation.
7+
8+
on:
9+
schedule:
10+
- cron: '16 11 * * *'
11+
push:
12+
branches: [ "main" ]
13+
# Publish semver tags as releases.
14+
tags: [ 'v*.*.*' ]
15+
pull_request:
16+
branches: [ "main" ]
17+
18+
env:
19+
# Use docker.io for Docker Hub if empty
20+
REGISTRY: ghcr.io
21+
# github.repository as <account>/<repo>
22+
IMAGE_NAME: ${{ github.repository }}
23+
24+
25+
jobs:
26+
build:
27+
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
packages: write
32+
# This is used to complete the identity challenge
33+
# with sigstore/fulcio when running outside of PRs.
34+
id-token: write
35+
36+
steps:
37+
- name: Checkout repository
38+
uses: actions/checkout@v3
39+
40+
# Install the cosign tool except on PR
41+
# https://github.com/sigstore/cosign-installer
42+
- name: Install cosign
43+
if: github.event_name != 'pull_request'
44+
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
45+
with:
46+
cosign-release: 'v2.1.1'
47+
48+
# Set up BuildKit Docker container builder to be able to build
49+
# multi-platform images and export cache
50+
# https://github.com/docker/setup-buildx-action
51+
- name: Set up Docker Buildx
52+
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
53+
54+
# Login against a Docker registry except on PR
55+
# https://github.com/docker/login-action
56+
- name: Log into registry ${{ env.REGISTRY }}
57+
if: github.event_name != 'pull_request'
58+
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
59+
with:
60+
registry: ${{ env.REGISTRY }}
61+
username: ${{ github.actor }}
62+
password: ${{ secrets.GITHUB_TOKEN }}
63+
64+
# Extract metadata (tags, labels) for Docker
65+
# https://github.com/docker/metadata-action
66+
- name: Extract Docker metadata
67+
id: meta
68+
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
69+
with:
70+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
71+
72+
# Build and push Docker image with Buildx (don't push on PR)
73+
# https://github.com/docker/build-push-action
74+
- name: Build and push Docker image
75+
id: build-and-push
76+
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
77+
with:
78+
context: .
79+
push: ${{ github.event_name != 'pull_request' }}
80+
tags: ${{ steps.meta.outputs.tags }}
81+
labels: ${{ steps.meta.outputs.labels }}
82+
cache-from: type=gha
83+
cache-to: type=gha,mode=max
84+
85+
# Sign the resulting Docker image digest except on PRs.
86+
# This will only write to the public Rekor transparency log when the Docker
87+
# repository is public to avoid leaking data. If you would like to publish
88+
# transparency data even for private images, pass --force to cosign below.
89+
# https://github.com/sigstore/cosign
90+
- name: Sign the published Docker image
91+
if: ${{ github.event_name != 'pull_request' }}
92+
env:
93+
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
94+
TAGS: ${{ steps.meta.outputs.tags }}
95+
DIGEST: ${{ steps.build-and-push.outputs.digest }}
96+
# This step uses the identity token to provision an ephemeral certificate
97+
# against the sigstore community Fulcio instance.
98+
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ fanstatic.libraries =
6868
console_scripts =
6969
add_user = riskmatrix.scripts.add_user:main
7070
upgrade = riskmatrix.scripts.upgrade:main
71+
import-seantis-excel = riskmatrix.scripts.seantis_import_risk_excel:main
7172

7273
[flake8]
7374
extend-select = B901,B903,B904,B908,TC2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""
2+
Import risk excel 🕸️ into RiskMatrix ✨
3+
4+
This script is specific to our sitation at seantis. The script is included
5+
anyway, you might adjust it to import the excel at your organization too.
6+
"""
7+
import argparse
8+
import sys
9+
import traceback
10+
from datetime import datetime
11+
from typing import TYPE_CHECKING
12+
from typing import Iterator
13+
14+
try:
15+
from openpyxl import load_workbook
16+
except ImportError:
17+
print("Excel import requires openpyxl library. Install with:\n")
18+
print("$ pip install openpyxl")
19+
print()
20+
sys.exit(1)
21+
22+
import sqlalchemy
23+
from pyramid.paster import bootstrap
24+
from pyramid.paster import get_appsettings
25+
from sqlalchemy import select
26+
27+
from riskmatrix.models import Asset
28+
from riskmatrix.models import Organization
29+
from riskmatrix.models import Risk
30+
from riskmatrix.models import RiskAssessment
31+
from riskmatrix.models import RiskCatalog
32+
from riskmatrix.orm import Base
33+
from riskmatrix.orm import get_engine
34+
from riskmatrix.scripts.util import select_existing_organization
35+
36+
if TYPE_CHECKING:
37+
38+
from typing import TypedDict
39+
40+
from sqlalchemy.orm import Session
41+
42+
class RiskDetails(TypedDict):
43+
""" A risk extracted from the excel. """
44+
name: str
45+
category: str
46+
asset_name: str
47+
desc: str
48+
likelihood: int
49+
impact: int
50+
51+
52+
def parse_args(argv: list[str]) -> argparse.Namespace:
53+
parser = argparse.ArgumentParser()
54+
parser.add_argument(
55+
'config_uri',
56+
help='Configuration file, e.g., development.ini',
57+
)
58+
parser.add_argument(
59+
'catalog',
60+
help='Risk catalog excel file, e.g., catalog.xlsx',
61+
)
62+
return parser.parse_args(argv[1:])
63+
64+
65+
def get_or_create_asset(
66+
asset_name: str,
67+
organization: Organization,
68+
session: 'Session'
69+
) -> Asset:
70+
71+
q = select(Asset).where(
72+
Asset.organization_id == organization.id,
73+
Asset.name == asset_name
74+
)
75+
76+
if asset := session.scalars(q).one_or_none():
77+
return asset
78+
79+
asset = Asset(asset_name, organization)
80+
asset.organization_id = organization.id
81+
session.add(asset)
82+
return asset
83+
84+
85+
def get_or_create_risk(
86+
risk_name: str,
87+
catalog: RiskCatalog,
88+
session: 'Session'
89+
) -> Risk:
90+
91+
q = select(Risk).where(
92+
Risk.organization_id == catalog.organization.id,
93+
Risk.name == risk_name
94+
)
95+
96+
if risk := session.scalars(q).one_or_none():
97+
return risk
98+
99+
risk = Risk(risk_name, catalog)
100+
session.add(risk)
101+
return risk
102+
103+
104+
def get_or_create_risk_assessment(
105+
risk: Risk,
106+
asset: Asset,
107+
session: 'Session'
108+
) -> RiskAssessment:
109+
110+
q = select(RiskAssessment).where(
111+
RiskAssessment.risk_id == risk.id,
112+
RiskAssessment.asset_id == asset.id,
113+
)
114+
115+
if assessment := session.scalars(q).one_or_none():
116+
return assessment
117+
118+
assessment = RiskAssessment(risk=risk, asset=asset)
119+
session.add(assessment)
120+
return assessment
121+
122+
123+
def populate_catalog(
124+
catalog: RiskCatalog,
125+
risks: 'Iterator[RiskDetails]',
126+
session: 'Session'
127+
) -> None:
128+
129+
for risk_details in risks:
130+
asset = get_or_create_asset(
131+
risk_details['asset_name'], catalog.organization, session
132+
)
133+
134+
risk = get_or_create_risk(
135+
risk_details['name'], catalog, session
136+
)
137+
risk.category = risk_details['category']
138+
risk.description = risk_details['desc']
139+
140+
assessment = get_or_create_risk_assessment(risk, asset, session)
141+
assessment.likelihood = risk_details['likelihood']
142+
assessment.impact = risk_details['impact']
143+
144+
145+
def risks_from_excel(
146+
excel_file: str,
147+
sheet_name: str = 'Risikokatalog'
148+
) -> 'Iterator[RiskDetails]':
149+
"""
150+
Load risks from excel.
151+
"""
152+
workbook = load_workbook(excel_file, read_only=True)
153+
154+
sheet = workbook[sheet_name]
155+
156+
# Rows are vertically grouped into sections by a category. A section begins
157+
# with a row that contains the category name but is otherwise empty.
158+
current_category = None
159+
160+
# Header row sometimes spans over two rows (combined), sometimes only one.
161+
# Anyway, actual riks rows will start after row #2.
162+
start_after_row = 2
163+
164+
iterator = sheet.iter_rows(
165+
values_only=True,
166+
min_row=start_after_row
167+
)
168+
169+
for row in iterator:
170+
nr = row[0]
171+
name = row[1]
172+
173+
is_empty_row = not (nr or name)
174+
is_category_row = not nr and name
175+
176+
if is_empty_row:
177+
continue
178+
elif is_category_row:
179+
current_category = name
180+
continue
181+
182+
yield {
183+
'name': str(name),
184+
'category': str(current_category),
185+
'asset_name': str(row[2]),
186+
'desc': str(row[3]),
187+
'likelihood': int(str(row[7])),
188+
'impact': int(str(row[8]))
189+
}
190+
191+
# readonly mode forces us to manually close the workbook, see also:
192+
# https://openpyxl.readthedocs.io/en/stable/optimized.html#read-only-mode
193+
workbook.close()
194+
195+
196+
def main(argv: list[str] = sys.argv) -> None:
197+
args = parse_args(argv)
198+
199+
with bootstrap(args.config_uri) as env:
200+
settings = get_appsettings(args.config_uri)
201+
202+
engine = get_engine(settings)
203+
Base.metadata.create_all(engine)
204+
205+
with env['request'].tm:
206+
dbsession = env['request'].dbsession
207+
208+
print('Organization to attach risk catalog to')
209+
210+
org = select_existing_organization(dbsession)
211+
212+
if not org:
213+
return
214+
215+
today = datetime.today().strftime('%Y-%m-%d')
216+
217+
catalog = RiskCatalog(
218+
'seantis risk register',
219+
organization=org,
220+
description=f'Imported from risk excel on {today}.'
221+
)
222+
223+
catalog.organization_id = org.id
224+
225+
try:
226+
populate_catalog(
227+
catalog,
228+
risks_from_excel(args.catalog),
229+
dbsession
230+
)
231+
except sqlalchemy.exc.IntegrityError:
232+
print('Failed to import excel, aborting.')
233+
print(traceback.format_exc())
234+
dbsession.rollback()
235+
sys.exit(1)
236+
else:
237+
print(
238+
f'Successfully populated risk catalog "{catalog.name}" '
239+
'from risk register excel.'
240+
)
241+
242+
243+
if __name__ == '__main__':
244+
main(sys.argv)

test_requirements.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ types-setuptools==69.0.0.0
3232
types-translationstring==1.4.0.1
3333
types-WebOb==1.8.0.5
3434
types-WTForms==3.1.0.2
35+
types-openpyxl==3.1.0.20240428
3536
virtualenv==20.24.4
36-
WebTest==3.0.0
37+
WebTest==3.0.0

0 commit comments

Comments
 (0)