A suite for managing risks in multiple projects.
Development
Pre requirements
This Suite uses django, and go-task for basic fast setup of django.
-
poetry:
pacman -Sy poetry
Startup of the suite
-
clone this repository
-
run
go-task first-init-
it will ask for a password, this password is for your superuser with the username admin
-
This task migrates alle migrations, and adds all initial fixtures to the databases
-
-
to start the development server from now on run
go-task run-devel
Adding initial test data
To create a simple testbase run go-task add-test-fixture, this also creates a user called testuser with the password: LetMeIn-01
Testing with local ldap
Setup and test a local ldap
-
clone this repo
-
get docker up and running
-
run
go-task spawn-glauth, this will start a glauth docker, and create self signed certs for ldaps-
reach ldap at 0.0.0.0:3389
-
reach ldaps at 0.0.0.0:3636
-
-
to test if a simple search ldapsearch works, run:
$ ldapsearch -LLL -H \ ldap://localhost:3389 \ -D cn=serviceuser,ou=domain-admins,dc=glauth,dc=com \ -w domain-nimda \ -x -bdc=glauth,dc=com \ cn=viewer -
or with ldaps
$ LDAPTLS_REQCERT=allow ldapsearch -v -LLL -H \ ldaps://localhost:3636 \ -D cn=serviceuser,ou=domain-admins,dc=glauth,dc=com \ -w domain-nimda \ -x -bdc=glauth,dc=com \ cn=viewer -
all necessary user and group info can be found inside the
_supplementary/glauth/glauth-ldap-config.cfg
Setup Product Risk Suite to use LDAP(S)
-
create a custom settings file with content similar to:
product_risk_suite.ldap_settings.pyimport ldap from django_auth_ldap.config import LDAPSearch from product_risk_suite.settings import * AUTHENTICATION_BACKENDS = ( 'django_auth_ldap.backend.LDAPBackend', ) + AUTHENTICATION_BACKENDS AUTH_LDAP_SERVER_URI = "ldap://localhost:3389" AUTH_LDAP_BIND_DN = 'cn=serviceuser,ou=domain-admins,dc=glauth,dc=com' AUTH_LDAP_BIND_PASSWORD = 'domain-nimda' AUTH_LDAP_USER_SEARCH = LDAPSearch( 'dc=glauth,dc=com', ldap.SCOPE_SUBTREE, '(uid=%(user)s)', ) # Or: # AUTH_LDAP_USER_DN_TEMPLATE = 'uid=%(user)s,ou=users,dc=example,dc=com' # Set up the basic group parameters. # AUTH_LDAP_GROUP_SEARCH = LDAPSearch( # 'ou=django,ou=groups,dc=example,dc=com', # ldap.SCOPE_SUBTREE, # '(objectClass=groupOfNames)', # ) # AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr='cn') # Simple group restrictions # AUTH_LDAP_REQUIRE_GROUP = 'cn=enabled,ou=django,ou=groups,dc=example,dc=com' # AUTH_LDAP_DENY_GROUP = 'cn=disabled,ou=django,ou=groups,dc=example,dc=com' # Populate the Django user from the LDAP directory. # AUTH_LDAP_USER_ATTR_MAP = { # 'first_name': 'givenName', # 'last_name': 'sn', # 'email': 'mail', # } # AUTH_LDAP_USER_FLAGS_BY_GROUP = { # 'is_active': 'cn=active,ou=django,ou=groups,dc=example,dc=com', # 'is_staff': 'cn=staff,ou=django,ou=groups,dc=example,dc=com', # 'is_superuser': 'cn=superuser,ou=django,ou=groups,dc=example,dc=com', # } # This is the default, but I like to be explicit. AUTH_LDAP_ALWAYS_UPDATE_USER = True # Use LDAP group membership to calculate group permissions. # make local project permissions AUTH_LDAP_FIND_GROUP_PERMS = False # Cache distinguished names and group memberships for an hour to minimize # LDAP traffic. AUTH_LDAP_CACHE_TIMEOUT = 3600 -
adjust the values so it matches your setup. If you need more configuration values, head over to django-auth-ldap and checkout what is possible.
-
Start the product risk suite with your custom configuration, like:
poetry run product_risk_suite/manage.py runserver --settings=product_risk_suite.ldap_settings
Using the ldap auth
First you should login as the administrator you setup locally during initial setup, then checkout the Users Table. None of the ldap users is listed. Now whenever a LDAP user logs into the product risk suite, the user will added to the Users group, the administrator is then able to assign permissions. While the user is unassigned it will not be able to see anything.
The administrator then should go to the products administration page and for each product the user should have access to add an object permission. As soon as the permission was assigned a simple reload of the products page will show the newly added capabilities for that user (no re-login required).
If a user already exists with that username, then you could disable the password login for that user and as soon as the user tries to login the credentials from the LDAP will be used. This way if you know the system of your usernames within the LDAP you could prepare accounts and assign object permissions before the users will login the first time
Usage
Adding risks
In general a Risk can be defined without a specific product/project in mind. That way multiple product/project teams can share their knowledge about possible risks and only one source will be used for those, if one changes/enhances the description all others will have that knew information available. Each risk will then be evaluated on a per project basis. Depending on the project a risk might be nearly non existent for one but would have a heavy impact on the other project. So the impact will be project dependant. Mitigations will then be shared through projects, again other projects can reuse the thoughts of others on how to mitigate/fix risks.
To add or change risks you have to enter the admin interface of the system (http://127.0.0.1:8000/admin) login as an admin and start adding risks and mitigations
Adding projects
-
First you need to create a new project
-
Then you create a new Risk analysis and give it a "nice name"
-
then you add risks to that analysis and add initial risk ratings
-
later on you can add possible mitigations, evidences and after mitigation risk analysis’s
The views
-
Are accessible via http://127.0.0.1:8000 will just show the risk analysis’s nice colorized and not so spread through the tables.
-
They also display charts of the current project
The api
Browse to /graphql to see an interactive GraphQl Ui to build up your graphql query.
API tokens
For machine-to-machine access (CI pipelines, scripts, other services) you do not need a
user session/login at all. Instead an administrator issues an API token, which
authenticates requests against /graphql directly via an Authorization: Bearer header.
This works the same whether or not LDAP login is enabled,
since token authentication does not go through the login/LDAP backends.
An API token is always issued for a user (its "owner"). The token acts with exactly the
permissions of that owner, including the same per-product object permission you would
assign to any regular user (see Using the ldap auth for how
object permissions are assigned). This gives you two ways to use it:
-
Attach the token to an existing person’s user account, so it acts with their access.
-
Create a dedicated user for a system/service (e.g.
svc-jenkins), disable password login for it (Unusable passwordin the admin), assign it only the object permissions it needs, and issue the token against that account.
-
Go to the admin interface (http://127.0.0.1:8000/admin), open Users and pick (or create) the user that should own the token.
-
Make sure that user has the `object permission`s it needs on the relevant products, exactly like for a normal login user.
-
Open Api tokens → Add api token, give it a recognizable name, pick the owner and optionally an expiry date, then save.
-
The full token value is shown exactly once as a message right after saving — copy it now, it cannot be displayed again afterwards (only its prefix stays visible in the admin, to help you recognize it later). If you lose it, delete the token and issue a new one.
Send it as a bearer token on requests to /graphql:
curl -H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"query": "{ products { title } }"}' \
{baseUrl}/graphql
A missing header behaves like an anonymous request (empty results). An invalid, expired, or deactivated token results in an HTTP 401 response instead.
Example scripts to work with the API
-
example single api call with login:
#!/bin/bash BASE_URL=https://localhost:8443/ LOGIN_URL=${BASE_URL}login YOUR_USER='admin' YOUR_PASS='nimda' COOKIES=/tmp/cookies.txt CURL_CALL="curl -k -s -c ${COOKIES} -b ${COOKIES} -e ${LOGIN_URL}" echo "Django Auth: get csrftoken ..." ${CURL_CALL} ${LOGIN_URL} > /dev/null DJANGO_TOKEN="csrfmiddlewaretoken=$(grep csrftoken ${COOKIES} | sed 's/^.*csrftoken\s*//')" echo " perform login ..." ${CURL_CALL} \ -d "${DJANGO_TOKEN}&username=${YOUR_USER}&password=${YOUR_PASS}" \ -X POST \ ${LOGIN_URL} echo " do api call ..." ${CURL_CALL} \ -H "Content-Type: application/json" \ -d '{"query": "query { productByTitle(title:\"IconTrust HMI\") { analyzes { riskEntries { risk { asset { name }, origin { name }, stride { name }, title, description } } } } }"}' \ -X GET \ ${BASE_URL}graphql echo echo " logout" rm ${COOKIES} -
example that creates an adoc table from the result:
#!/bin/bash BASE_URL=https://localhost:8443/ LOGIN_URL=${BASE_URL}login YOUR_USER='admin' YOUR_PASS='nimda' COOKIES=/tmp/cookies.txt CURL_CALL="curl -k -s -c ${COOKIES} -b ${COOKIES} -e ${LOGIN_URL}" OUTPUT_ADOC_TABLE=/tmp/output-api-table.adoc trap 'rm -f ${COOKIES}' EXIT # get csrf token ${CURL_CALL} ${LOGIN_URL} > /dev/null DJANGO_TOKEN="csrfmiddlewaretoken=$(grep csrftoken ${COOKIES} | sed 's/^.*csrftoken\s*//')" # login ${CURL_CALL} \ -d "${DJANGO_TOKEN}&username=${YOUR_USER}&password=${YOUR_PASS}" \ -X POST \ ${LOGIN_URL} ${CURL_CALL} \ -H "Content-Type: application/json" \ -d '{"query": "query { productByTitle(title:\"IconTrust HMI\") { analyzes { riskEntries { risk { asset { name }, origin { name }, stride { name, full }, title, description } } } } }"}' \ -X GET \ ${BASE_URL}graphql > /tmp/result.json # trap 'rm -f /tmp/result.json' EXIT echo "{counter2:index:0}" > ${OUTPUT_ADOC_TABLE} echo "|===" >> ${OUTPUT_ADOC_TABLE} echo "| Nr | Asset | Origin | Stride | Title | Description" >> ${OUTPUT_ADOC_TABLE} echo "" >> ${OUTPUT_ADOC_TABLE} jq -r '.data.productByTitle.analyzes[].riskEntries[] | " | {counter:index} | \(.risk.asset.name) | \(.risk.origin.name) | \(.risk.stride | map(.full) | join(", ")) | \(.risk.title) | \(.risk.description | gsub("</li><li>"; " +\n") | gsub("</div>"; " +\n") | gsub("<[^>]*>"; "") | gsub("\r\n"; " +\n")) "' \ /tmp/result.json \ >> ${OUTPUT_ADOC_TABLE} echo "|===" >> ${OUTPUT_ADOC_TABLE} echo "created ADOC table at: »${OUTPUT_ADOC_TABLE}«" -
example single api call using an API token instead of a login:
#!/bin/bash BASE_URL=https://localhost:8443/ API_TOKEN='prs_your-token-here' echo " do api call ..." curl -k -s \ -H "Authorization: Bearer ${API_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"query": "query { productByTitle(title:\"IconTrust HMI\") { analyzes { riskEntries { risk { asset { name }, origin { name }, stride { name }, title, description } } } } }"}' \ -X POST \ ${BASE_URL}graphql
Production docker
docker run --rm -it \
--name product-risk-suite-production \
--volume ./docker/data:/data \
--volume ./docker/config:/config \
-e DJANGO_SUPERUSER_USERNAME=admin \
-e DJANGO_SUPERUSER_PASSWORD=nimda \
-e DATABASE_ENGINE=sqlite3 \
-e DATABASE_NAME=/data/product-risk-db.sqlite3 \
-e DJANGO_DEBUG=${DJANGO_DEBUG} \
-e IN_DOCKER=true \
-e SET_FIXTURES=true \
-e DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} \
-e DJANGO_CSRF_TRUSTED_ORIGINS=${DJANGO_CSRF_TRUSTED_ORIGINS} \
-p 8443:443 \
product-risk-suite:latest
Or run the the task: go-task docker-build-setup-run, which will also build the container.
docker run --rm -it \
--name product-risk-suite-production \
--volume ./docker/data:/data \
--volume ./docker/config:/config \
-e DATABASE_ENGINE=sqlite3 \
-e DATABASE_NAME=/data/product-risk-db.sqlite3 \
-e DJANGO_DEBUG=${DJANGO_DEBUG} \
-e IN_DOCKER=true \
-e SET_FIXTURES= \
-e DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} \
-e DJANGO_CSRF_TRUSTED_ORIGINS=${DJANGO_CSRF_TRUSTED_ORIGINS} \
-p 8443:443 \
product-risk-suite:latest
Or run the the task: go-task docker-build-run, which will also build the container.
ENV DJANGO_SETTINGS_MODULE=product_risk_suite.settings ENV DJANGO_SECRET_KEY="django-insecure-scwz(sj+*te-dbfuc)vnkju5y1=p1en^d#8mz5*keg2(fj^mn-dudi-bla%" ENV DJANGO_DEBUG= ENV DJANGO_SUPERUSER_USERNAME=admin ENV DJANGO_SUPERUSER_PASSWORD=adminpassword ENV DJANGO_SUPERUSER_EMAIL=admin@example.com ENV DATABASE_ENGINE=sqlite3 ENV DATABASE_NAME= ENV DATABASE_USERNAME= ENV DATABASE_PASSWORD= ENV DATABASE_HOST= ENV DATABASE_PORT= ENV SET_FIXTURES=true ENV DJANGO_LOG_LEVEL=INFO
you should know which to overwrite with values you suite the most
VOLUME /data # /config may also hold a custom Django settings module (e.g. custom_settings.py), # which is picked up automatically since /config is added to the Python path. # Point DJANGO_SETTINGS_MODULE at it (bare module name, e.g. "custom_settings"). VOLUME /config
To customize your run you can use a local .env file, for this copy the file dot-env.example to .env (next to the topmost Taskfile.yml).
Adjust the parameters inside the .env, and then simply run one of the above taskfile jobs
Using a custom settings file
By default the container loads product_risk_suite.settings. To load different settings (e.g. to enable LDAP auth, see Product Risk Suite) without baking them into the image, provide your own settings module via the /config volume:
-
create a custom settings file with content similar to:
product_risk_suite.ldap_settings.pyimport ldap from django_auth_ldap.config import LDAPSearch from product_risk_suite.settings import * AUTHENTICATION_BACKENDS = ( 'django_auth_ldap.backend.LDAPBackend', ) + AUTHENTICATION_BACKENDS AUTH_LDAP_SERVER_URI = "ldap://localhost:3389" AUTH_LDAP_BIND_DN = 'cn=serviceuser,ou=domain-admins,dc=glauth,dc=com' AUTH_LDAP_BIND_PASSWORD = 'domain-nimda' AUTH_LDAP_USER_SEARCH = LDAPSearch( 'dc=glauth,dc=com', ldap.SCOPE_SUBTREE, '(uid=%(user)s)', ) # Or: # AUTH_LDAP_USER_DN_TEMPLATE = 'uid=%(user)s,ou=users,dc=example,dc=com' # Set up the basic group parameters. # AUTH_LDAP_GROUP_SEARCH = LDAPSearch( # 'ou=django,ou=groups,dc=example,dc=com', # ldap.SCOPE_SUBTREE, # '(objectClass=groupOfNames)', # ) # AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr='cn') # Simple group restrictions # AUTH_LDAP_REQUIRE_GROUP = 'cn=enabled,ou=django,ou=groups,dc=example,dc=com' # AUTH_LDAP_DENY_GROUP = 'cn=disabled,ou=django,ou=groups,dc=example,dc=com' # Populate the Django user from the LDAP directory. # AUTH_LDAP_USER_ATTR_MAP = { # 'first_name': 'givenName', # 'last_name': 'sn', # 'email': 'mail', # } # AUTH_LDAP_USER_FLAGS_BY_GROUP = { # 'is_active': 'cn=active,ou=django,ou=groups,dc=example,dc=com', # 'is_staff': 'cn=staff,ou=django,ou=groups,dc=example,dc=com', # 'is_superuser': 'cn=superuser,ou=django,ou=groups,dc=example,dc=com', # } # This is the default, but I like to be explicit. AUTH_LDAP_ALWAYS_UPDATE_USER = True # Use LDAP group membership to calculate group permissions. # make local project permissions AUTH_LDAP_FIND_GROUP_PERMS = False # Cache distinguished names and group memberships for an hour to minimize # LDAP traffic. AUTH_LDAP_CACHE_TIMEOUT = 3600 -
mount it into the
/configvolume of the container, e.g. as/config/custom_settings.py(the container automatically adds/configto its Python path, so any module placed there becomes importable) -
set the
DJANGO_SETTINGS_MODULEenvironment variable to the bare module name, e.g.DJANGO_SETTINGS_MODULE=custom_settings
This applies to gunicorn, the migration/superuser/fixture steps run by the entrypoint, and the evidence-scraper process alike, since all of them read the same container environment. The custom settings file lives outside of the image and can be changed without a rebuild.
Threat model support
Creating a suitable threat model
Currently only threat models created with drawio are supported.
Use the "Threat Modeling" items or any other you might like.
The important part is that each communication way you want to add risks to need a recognizable id set.
To set such an id you need to click the item inside drawio, select the Style Tab, then click on the Edit-Button and select Edit Data from the drop down.
Inside the Dialog do a double click on the text behind the ID and change it to something useful.
Basically each editor is capable of creating a working threat model as long as the svg items that should be selectable contain an attribute called data-cell-id with a name you recognize.
Evidence scrapers
Evidences can optionally be kept in sync automatically from a remote issue
tracker instead of being maintained by hand. A scraper only ever sets status
and responsible — everything else about an Evidence is still edited as
before. Syncing runs on the cycle configured via
DJANGO_EVIDENCE_SCRAPER_INTERVAL_SECONDS (see dot-env.example), or can be
triggered manually.
Setting this up touches two things that are deliberately not visible to
every /admin user: Remote credentials and Scrapers. Only staff
explicitly granted the corresponding permissions (via the normal permission
picker on a user in /admin/auth/user/, e.g. scraper | remote credential |
Can view/add/change remote credential and the equivalent for scraper) can
see or use these two sections — being staff/having /admin access alone is
not enough. Grant these only to the admins who should be able to see stored
secrets.
1. Store the remote credentials
|
Note
|
A scraper’s credential is optional. For a publicly accessible endpoint (e.g. a public Forgejo/Gitea/GitHub issue, requiring no login at all) you can skip this section entirely and just leave Credential blank on the scraper in step 2 — requests are then sent without any authentication, and (since there’s no credential to protect) aren’t restricted to any particular domain. |
Go to Scraper → Remote credentials → Add remote credential. This example
targets the Codeberg (Forgejo) instance this project itself is hosted on,
using a personal access token. https://codeberg.org/CMon/product-risk-suite/issues/89 itself is actually a
public issue and would work fine without any credential (see the note
above) — a token is shown here only to document how to set one up for the
common case of a private tracker:
-
On Codeberg:
Settings → Applications → Generate New Token, scopeissue: readis enough. -
In the Product Risk Suite admin, create a remote credential:
Name
codebergUrl
Auth type
API tokenApi token
(the token generated above)
The token is encrypted at rest (DJANGO_SCRAPER_CREDENTIAL_KEY) and is never
shown again after saving — only re-enter it if you need to rotate it.
Forgejo/Gitea’s API also accepts a plain user:password login here (auth
type Username / password) if you’d rather not use a token, but a scoped
token is recommended.
2. Define the scraper
Go to Scraper → Scrapers → Add scraper. A Forgejo/Gitea issue API response looks like this (shortened, from `https://codeberg.org/CMon/product-risk-suite/issues/89’s underlying API call):
$ curl -s https://codeberg.org/api/v1/repos/CMon/product-risk-suite/issues/89
{
"state": "open",
"assignee": {
"login": "CMon",
...
},
...
}
So the scraper is defined as:
Name |
|
Credential |
|
Response format |
|
Assignee path |
|
Status path |
|
assignee.login is used rather than assignee.email because Codeberg only
exposes a …@noreply.codeberg.org placeholder unless a user made their email
public — matching on the Forgejo login name against the Django username is
far more reliable. Resolution order for the raw value read from
assignee.login (or whatever Assignee path extracts):
-
an Assignee mapping for this host (see below) takes priority;
-
otherwise, a local user with a matching
User.email, thenUser.username(both case-insensitive); -
if neither matches,
responsibleis set to unassigned (None) — it is no longer left at whatever it was before the sync.
Assignee mapping
Often the remote login doesn’t match the local Django username at all — e.g.
Codeberg user CMon should resolve to the local user schaefer.simon. For
this, go to Scraper → Assignee mappings → Add assignee mapping:
Base url |
|
Remote username |
|
User |
|
Base url is matched as a prefix of the scrape URL actually used (the same
way a Remote credential’s `Url restricts which URLs a credential is sent
to), so one mapping row covers every scraper/Evidence pointed at that host.
Remote username is matched case-insensitively against the raw value read
from Assignee path — it does not need to be a valid Django username itself.
If no mapping row matches the host, resolution falls back to the plain email/username lookup described above; if that also finds nothing, the Evidence ends up unassigned rather than accidentally left pointed at a previous, unrelated responsible person.
Status mapping
A Forgejo/Gitea issue only ever has two states, open and closed — so per
the ticket driving this feature, only two of the four local statuses are
mapped; everything else about the Evidence’s status vocabulary is untouched:
Remote value ( |
Local status |
|
|
|
|
Add both rows in the Status mappings inline on the scraper’s edit page. Any remote value that isn’t mapped here (there shouldn’t be any for this API, but this applies generally) makes that particular sync attempt count as failed rather than silently leaving the old status in place.
3. Point an Evidence at it
Open the Evidence (or create a new one) and set:
Scraper |
|
Evidence link |
|
Scrape url |
|
Scrape url only needs filling in when the API endpoint differs from
Evidence link like it does here — leave it blank and the scraper URL falls
back to Evidence link automatically.
|
Tip
|
Before saving, use the "Test scraper" button at the bottom of the
Evidence form to try the currently entered Scraper/Scrape url/Evidence
link values right away — even on a brand new, not-yet-saved Evidence. It
opens a new tab showing the request that was made, the raw response
(JSON/HTML), and what would have been extracted and resolved (assignee →
user, status → local status) — without touching any Evidence record, so it’s
safe to use for trial and error while setting things up.
|
4. Syncing
-
Automatically: whatever runs
manage.py sync_evidence_scrapers --loop(already wired up as its own process in the Docker image) re-checks every Evidence with a scraper assigned everyDJANGO_EVIDENCE_SCRAPER_INTERVAL_SECONDSseconds. -
Manually: in the Evidence admin changelist, select one or more rows and run the "Run scraper now" action for an immediate sync.
5. Reading the sync icon
The risk table’s evidence rows show a small icon next to each Evidence:
-
a muted "unlinked" icon — no scraper configured, nothing to show
-
a green check — last sync succeeded; hover shows when
-
a red warning triangle — the most recent sync attempt failed; hover still shows the last time it did succeed, so you can tell how stale the data is
The same information is available as "Last scrape" (an icon) and "Last successful scrape at" columns in the Evidence admin changelist. Both are read-only on the Evidence form — only the scraper itself (automatic cycle, "Run scraper now", or the underlying sync) ever sets them.