A suite for managing risks in multiple projects.

Development

Pre requirements

This Suite uses django, and go-task for basic fast setup of django.

Install following tools:
  1. poetry: pacman -Sy poetry

Startup of the suite

  1. clone this repository

  2. run go-task first-init

    1. it will ask for a password, this password is for your superuser with the username admin

    2. This task migrates alle migrations, and adds all initial fixtures to the databases

  3. 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

  1. clone this repo

  2. get docker up and running

  3. run go-task spawn-glauth, this will start a glauth docker, and create self signed certs for ldaps

    1. reach ldap at 0.0.0.0:3389

    2. reach ldaps at 0.0.0.0:3636

  4. 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
  5. 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
  6. 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)

  1. create a custom settings file with content similar to:

    product_risk_suite.ldap_settings.py
    import 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
  2. 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.

  3. 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

  1. First you need to create a new project

  2. Then you create a new Risk analysis and give it a "nice name"

  3. then you add risks to that analysis and add initial risk ratings

  4. later on you can add possible mitigations, evidences and after mitigation risk analysis’s

The views

  1. 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.

  2. 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 password in the admin), assign it only the object permissions it needs, and issue the token against that account.

Creating a token
  1. 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.

  2. Make sure that user has the `object permission`s it needs on the relevant products, exactly like for a normal login user.

  3. Open Api tokensAdd api token, give it a recognizable name, pick the owner and optionally an expiry date, then save.

  4. 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.

Using a token

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

Example run of the container (with redeploy of default fixtures):
        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.

Example run of the container (WITHOUT redeploy of default fixtures):
        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.

Available docker environment variables are:
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

Useful volumes to create:
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:

  1. create a custom settings file with content similar to:

    product_risk_suite.ldap_settings.py
    import 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
  2. mount it into the /config volume of the container, e.g. as /config/custom_settings.py (the container automatically adds /config to its Python path, so any module placed there becomes importable)

  3. set the DJANGO_SETTINGS_MODULE environment 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 credentialsAdd 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:

  1. On Codeberg: Settings → Applications → Generate New Token, scope issue: read is enough.

  2. In the Product Risk Suite admin, create a remote credential:

    Name

    codeberg

    Url

    https://codeberg.org

    Auth type

    API token

    Api 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 → ScrapersAdd 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

forgejo-issue

Credential

codeberg

Response format

JSON

Assignee path

assignee.login

Status path

state

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):

  1. an Assignee mapping for this host (see below) takes priority;

  2. otherwise, a local user with a matching User.email, then User.username (both case-insensitive);

  3. if neither matches, responsible is 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 mappingsAdd assignee mapping:

Base url

https://codeberg.org

Remote username

CMon

User

schaefer.simon

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 (state)

Local status

open

Open

closed

Finished

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

forgejo-issue

Evidence link

https://codeberg.org/CMon/product-risk-suite/issues/89 (the human-facing page — unchanged, still what the risk table links to)

Scrape url

https://codeberg.org/api/v1/repos/CMon/product-risk-suite/issues/89 (the machine-readable API URL actually fetched)

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 every DJANGO_EVIDENCE_SCRAPER_INTERVAL_SECONDS seconds.

  • 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.