Skip to main content

Scans

A scan is one end-to-end run that detects components, licenses, and vulnerabilities for a project. Scans run on a Celery worker (never inline on the API) — typical durations range from 5 minutes (small npm projects) to 60 minutes (large multi-module Java repositories).

Audience

Engineers with developer or higher on the project's team. Triggering scans against private repos requires repo credentials embedded in the project's git_url — see Projects → Private repositories.

Scan kinds

KindPipelineWhat it detects
sourcecdxgen (CycloneDX generator) → scancode (first-party license detection) → Trivy (trivy sbom)Components and their declared licenses (from dependency metadata) plus detected licenses (scancode reading your own first-party source), and CVEs (Common Vulnerabilities and Exposures) matched by the local Trivy DB against NVD + OSV + GHSA + EPSS + KEV.
containerTrivy (Aqua Security container scanner)OS-package vulnerabilities and (limited) language-package CVEs in a container image.
sbomconformance scoring → component persistence → Trivy (trivy sbom)An SBOM your own tooling already produced (CycloneDX-JSON or SPDX). TRUSCA does not clone or build your source — it scores the SBOM's quality, persists its components, and matches CVEs. See SBOM upload below.

Source and Container are selectable from the UI scan dialog — pick one when you trigger a scan (see Trigger a scan → From the UI). An sbom scan is created differently: you upload an existing SBOM to the ingest endpoint rather than picking it in the dialog (see SBOM upload). The API accepts all three kinds.

Trigger a scan

From the UI

  1. Open Projects in the sidebar.
  2. Find the project row and click the Scan button at the end of the row.
  3. The scan dialog opens. At the top, choose the scan type:
    • Source — runs cdxgen + scancode + Trivy on the project's source. This is the default.
    • Container — runs Trivy on a container image you name. See Scan a container image.
  4. For a Source scan, pick how to provide the source (Git URL, an uploaded .zip, or a folder zipped in the browser), then click Start scan.
Verbose logs (debug)

The scan dialog has a Verbose logs (debug) toggle (off by default). Leave it off for the standard progress trace. Turn it on for a single scan to stream the full cdxgen / scancode / Trivy diagnostic output into the per-stage log panel — cdxgen runs in debug mode, scancode emits a per-file line, and Trivy switches to --debug. Use it when you are debugging why a scan found too few components, missed a license, or matched an unexpected CVE. Verbose output can be large; the per-scan line budget (SCAN_LOG_MAX_LINES_PER_SCAN, default 20000) still caps it. Credentials are redacted from the log on the way out, and a verbose source scan additionally lists each scanned file's path — visible to anyone on the team who can open the scan, so prefer it on internal/trusted projects when sharing logs widely. Over the API, set metadata.verbosity to "verbose" (absent or "normal" keeps the quiet trace).

A right-slide drawer opens on the project list page with a live progress view backed by a WebSocket connection. You can close the tab — the scan continues on the worker. Reopen the project and reconnect at any time. While a scan is queued or running, the drawer carries a Cancel scan action — see Cancel a scan.

One scan at a time per branch

A project can scan several branches at once — pushing to main and release/1.x together no longer makes one CI job wait on the other, because the two write to separate snapshots. What is still serialized is the same branch: triggering a second scan for a branch that already has one queued or running returns 409 Conflict with the RFC 7807 extension scan_already_in_progress: true, and the detail names the branch that is busy.

Scans triggered without a ref — the Scan button, and API calls that omit metadata.ref — all count as one bucket, so two of those still conflict with each other. That is why the button greys out only while another ad-hoc scan is in flight; a CI scan on main leaves it usable, and the in-progress chip beside it stays visible either way (clicking it re-opens that scan's drawer).

Wait for the conflicting scan to reach a terminal state, or Cancel it, before re-triggering. The same guard applies to UI, API, and CI clients.

Scan progress drawer — bootstrap → fetch → cdxgen → scancode → vuln_match → finalize stages, live over WebSocket

Branch selection for source scans

Source scans run against the project's default_branch (typically main). Neither the UI nor the API exposes a branch override in this release. To scan develop or a feature branch, temporarily change default_branch in Project Settings before triggering the scan, then revert. A first-class branch field on the trigger is on the roadmap.

Scan a container image

Pick Container in the scan dialog to scan a built image instead of source. Trivy (the Aqua Security container scanner) inspects the image's OS packages for known vulnerabilities — complementary to a source scan, which covers your application's dependency tree.

  1. Open the scan dialog from the project row's Scan button.
  2. At the top of the dialog, select Container.
  3. Enter the container image reference in name:tag form, for example alpine:3.19 or ghcr.io/org/app:1.2.3. The image must be pullable from the worker. Public registries work as shipped; a private registry needs a credential an administrator has stored for it (see Private registries below). An operator may also restrict which registries are allowed at all (see Allowed registries), in which case an image outside that list is rejected when you start the scan.
  4. Click Start scan.

The same progress drawer opens. When the scan reaches succeeded, the OS-package vulnerabilities appear under the project's Vulnerabilities tab.

Allowed registries

A container scan makes the worker pull an image, which is an outbound request to whatever address the reference names. Operators can restrict that with CONTAINER_SCAN_ALLOWED_REGISTRIES on the portal: a comma-separated list of hosts (ghcr.io) or host-plus-prefix entries (ghcr.io/your-org).

Unset means no restriction, which is the behaviour of every deployment that has not configured it. Setting it rejects anything else when the scan is started, with an error naming the registry, so you find out immediately rather than after waiting for a worker slot.

Two details worth knowing when you write the list:

  • An image with no registry segment resolves to Docker Hub. alpine:3.19 and myorg/app both need docker.io on the list, even though neither string mentions it.
  • Hosts match exactly. ghcr.io does not admit ghcr.io.example.com, and it does not admit evil.example.com/ghcr.io/app either, where the allowed name appears only in the path.

Private registries

This is about container images, not source dependencies

This section covers Trivy pulling a container image for a container scan. For authenticating cdxgen against a private Maven / npm / pip registry during a source scan, see Private registries for dependency resolution.

Most enterprise images are not public. A super-admin stores one login per registry per organization:

curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"registry_host":"ghcr.io","username":"bot","password":"'"$TOKEN_VALUE"'"}' \
"https://trustedoss.example.com/v1/admin/organizations/$ORG_ID/registry-credentials"

registry_host is the host as it appears in an image reference (ghcr.io, registry.example.com, registry:5000). A pasted https://ghcr.io/ is normalised for you. A URL carrying the login itself (https://bot:token@ghcr.io/) is rejected rather than cleaned up: this field is returned by the API and written to the audit log, so a password reaching it would sit in plaintext next to the encrypted one.

For Docker Hub use docker.io, the same name an image with no registry segment resolves to. Storing it under index.docker.io means the scan-time lookup never finds the row.

This feature needs secret encryption configured (GITHUB_APP_ENCRYPTION_KEY, which also covers the other credentials the portal stores). Without it in production, storing a credential is refused rather than saved unencrypted.

What happens to the credential:

  • Stored encrypted, with the same mechanism as every other credential this product holds. It is never returned by any endpoint, including the one that created it, so GET shows the registry and username and nothing else.
  • Sent only to its own registry. The worker writes a Docker config.json for the single registry the image being scanned comes from, so a credential for one registry is never offered to another, and credentials for registries this scan does not touch are not written at all.
  • Never in the scanner's environment. It reaches Trivy as a file the scanner reads, not as an environment variable, so a scanner crash report or error path has no credential to carry out. The file lives outside the scan workspace so backups never capture it, and is deleted when the scan ends, including when it fails.

One limit worth knowing before you store a login, because the file permissions invite a stronger reading than they support. The file is private to the worker user, which keeps it away from other users on the host. It does not separate one running scan from another: worker processes share a user, so a scan's Trivy could read a concurrently running scan's file. Reaching that takes arbitrary file read or code execution inside Trivy itself, so it is a residual risk rather than an open door, and it is the reason credentials are narrowed to the one registry a scan actually pulls from. If your deployment scans images for organizations that should not be able to reach each other's registries even under that assumption, run container scans at a concurrency of 1.

If the registry is not on the allowed list, storing a credential for it is rejected: it could never be used. A credential stored before the list was tightened stays but shows "allowed": false in the listing, so a setting that has stopped working is visible rather than silent.

Base-image OS end-of-life

Trivy also reports whether the image's base OS release is past its end-of-service-life — a release such as an old Debian or Alpine version that no longer receives upstream security fixes. When it is, the scan detail page shows an OS end-of-life panel naming the OS family and release. This is a scan-level signal distinct from the per-component end-of-life badge on source scans: even if no individual package CVE fires, an image built on an end-of-life OS will never receive patches for CVEs disclosed after that OS retired, so the recommendation is to rebuild on a supported release.

The end-of-life verdict comes from Trivy's bundled vulnerability database, so it needs no extra network access. A stale database may not yet flag a release that was retired very recently; keep the Trivy database fresh (see the admin health panel) for an accurate verdict.

Container scans do not need a Git URL

A container scan reads an image reference, not the repository. A project with no git_url can still run container scans. The Source / Container choice is independent of the project's source configuration.

From the API

curl -sS -X POST \
"https://trustedoss.example.com/v1/projects/${PROJECT_ID}/scans" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"kind": "source"}' | jq .

The response carries the scan UUID. Poll:

curl -sS "https://trustedoss.example.com/v1/scans/${SCAN_ID}" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" | jq .status

For a container scan, set kind to container and pass the image reference under metadata.image_ref:

curl -sS -X POST \
"https://trustedoss.example.com/v1/projects/${PROJECT_ID}/scans" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"kind": "container", "metadata": {"image_ref": "alpine:3.19"}}' | jq .

From CI

The recommended path is the GitHub Action, the GitLab CI template, or the Jenkinsfile example. Each one wraps the API and adds the build gate.

SBOM upload (received SBOMs)

If your own build or CI already produces an SBOM, you can upload it instead of having TRUSCA clone and scan your source. This creates an sbom scan: TRUSCA persists the SBOM's components, matches CVEs with Trivy, and classifies declared licenses — the same component / vulnerability / license views you get from a source scan, and the build gate runs on it too.

  • Formats: CycloneDX-JSON, or SPDX (JSON or Tag-Value). Trivy auto-detects the format for matching; SPDX is mapped to CycloneDX internally for the component graph.
  • How to upload: POST /v1/projects/{project_id}/sbom-ingest with an API key. The full how-to (fields, size limits, errors) is in Upload an SBOM.

Conformance verdict

Because a supplier-provided SBOM can be a "shell" with missing versions, PURLs, or no dependency graph, TRUSCA scores its quality on ingest and shows a pass / warn / fail badge plus a per-requirement table on the scan detail page. The verdict is advisory — a fail does not block ingest (CVE matching still runs); it tells you whether to accept the SBOM or send it back to the supplier. Mandatory checks include a timestamp, tool info, a top-level component, 100% component name+version, PURL coverage ≥ 90%, no pkg:generic placeholders, and a transitive dependency graph. The component-level ones are measured over package components only — file and dataset entries cannot carry a package version or a PURL, so they are not counted against those fields, and a document made up entirely of such entries is warned rather than passed; license and hash coverage are recommended (warn-only). Read it via the UI panel or GET /v1/projects/{project_id}/scans/{scan_id}/conformance — see Upload an SBOM → Read the conformance verdict.

On CycloneDX documents the verdict also carries five regulatory field checks — SHA-512 checksum, component creator, component filename, source / distribution URI, and delivered-file properties coverage (threshold SBOM_CONFORMANCE_FIELD_MIN_PCT, default 80). They are advisory and verdict-neutral: they never move the pass / warn / fail badge. Instead they feed a regulatory crosswalk that cross-references each check to the documentation requirements of four frameworks — BSI TR-03183-2 (EU Cyber Resilience Act), the US minimum elements, EU AI Act Annex IV, and Korea's AI Framework Act — and rolls them up per framework as present / gap / needs human review / failed. The crosswalk is a documentation-preparation aid, not a compliance determination, and the response carries that disclaimer verbatim. See Upload an SBOM → Regulatory field checks.

Every CycloneDX document is additionally measured against the 2026 SBOM minimum elements (17 data fields and 6 practices, 23 advisory checks), which replaced the NTIA elements of 2021 and apply to all software. CycloneDX specVersion 1.7 is accepted, and when the uploaded document carries a machine-learning-model component the panel appends the advisory G7 AI SBOM minimum elements checklist (51 elements in seven clusters) on top. Both baselines are advisory throughout — see SBOM conformance baselines.

Lifecycle

queued ─────► running ─────► succeeded
│ │
│ └────► failed
│ │
└──────────────────┴────► cancelled
StatusMeaning
queuedEnqueued; waiting for a free worker slot.
runningA worker has picked up the task and is executing the pipeline.
succeededPipeline finished, components and findings are now queryable.
failedThe worker raised an error. Inspect error_detail in the API response or the worker log.
cancelledA user or admin cancelled the run while it was queued or running. The worker task was stopped and its workspace reclaimed. See Cancel a scan.

queued, running are non-terminal; succeeded, failed, and cancelled are terminal. A scan can be cancelled only from a non-terminal state.

Pipeline stages (source)

The progress view shows real-time stage transitions:

  1. Bootstrapping — preparing the workspace.
  2. Fetching sourcegit clone (or git fetch + checkout for an existing workspace).
  3. Detecting componentscdxgen walks the repo and emits a CycloneDX SBOM, with declared licenses read from each dependency's package metadata. Immediately after, the runtime-scope post-filter drops non-deployable dependencies (Maven test/provided, npm devDependencies) so every later stage — signing, persistence, vulnerability matching — sees the deployable set; see Components & licenses → Runtime-scope filtering.
  4. Detecting first-party licenses — scancode scans the project's own source files and records the detected licenses it finds, each tagged with the source_path of the file it came from (see Components & licenses → Detected vs. declared). This stage is best-effort: if scancode is not installed, times out, or the tree is too large, the scan continues with declared licenses only — a degraded but non-fatal outcome. Legal-tier classification is then applied from the built-in classifier catalog (see Components & licenses → Classification source).
  5. Resolving vulnerabilitiestrivy sbom matches the CycloneDX SBOM against the local Trivy DB (NVD + OSV + GHSA + EPSS + KEV). No network call per scan.
  6. Persisting — components, licenses, and findings are written to PostgreSQL.
ORT was replaced by scancode

Earlier builds ran the OSS Review Toolkit (ORT) at the license stage. v0.10.0 replaces it with scancode for first-party detection. Third-party dependency sources are deliberately not downloaded — that kept per-scan runtime within budget — so dependency licenses stay declared (from cdxgen) and scancode adds detected licenses for the code your team actually wrote.

If the local Trivy DB has not finished downloading when stage 5 runs (most common on a fresh install), the scan completes with 0 vulnerability findings and a banner on the Vulnerabilities tab pointing operators at Vulnerability data (Trivy DB). The automatic re-match beat picks up findings once the DB lands — no re-scan needed.

iOS projects (CocoaPods / Swift Package Manager)

iOS repositories scan offline from their committed lockfiles — the worker needs neither Xcode, a Swift toolchain, nor the CocoaPods CLI:

  • CocoaPodscdxgen cannot catalog pods without the pod CLI, so the scanner excludes that cataloger (avoiding a crash that used to kill the whole scan) and reconstructs the pod set and its dependency graph directly from Podfile.lock. The PODS: block is the fully resolved truth: direct and transitive pods, pinned versions, and each pod's sub-dependencies — subspecs like Moya/Core appear as their own components. Pods carry no Usage (Required/Optional) value because Podfile.lock records no runtime/test distinction; the column shows .
  • Swift Package Manager — a committed Package.resolved is parsed by cdxgen as-is (both the v1 and v2 formats). The sidecar executors skip swift package resolve when the file is committed — the committed lockfile already is the resolved graph, and re-resolving would hit the network.

Commit Podfile.lock and Package.resolved to the repository — without them an iOS scan finds only what the manifests declare.

When a scan finds nothing

A scan can succeed and still produce no components. That is not the same as a project having no dependencies, and the portal says which one happened rather than showing you an empty page and letting you guess.

The scan still succeeds. cdxgen does not recognise every build system, and for a tree it cannot read an empty result is the correct answer; failing the scan there would break a pipeline that is working as intended. What you get instead is a note on the project's Overview tab, a different empty state on the Components tab, and a component_outcome field on the gate result that the CI action prints.

What happenedWhat it meansWhat to do
Components were foundThe ordinary case.Nothing.
No components, and the source declared no dependency manifestThe scanner did not recognise this build system, so there was nothing for it to read. Every count on the project is 0 for that reason.Check the supported ecosystems before treating the result as clean.
No components, but the source does carry dependency manifestsSomething failed during the scan. Build preparation is best-effort, and when it cannot produce the lockfile cdxgen needs, that ecosystem comes back empty.Open the scan log, find the stage that came back empty, and re-run.

A build gate on an empty scan passes, because every count it reads is 0 and there is nothing to block on. The CI action prints a warning next to that verdict so a passing build does not read as an all-clear. See component-outcome.

This is separate from under-reporting. A project with a package.json and no committed package-lock.json produces its direct dependencies and drops the transitive ones: that is a populated SBOM that is quietly incomplete, it has a different cause, and committing the lockfile is what fixes it.

Average duration

Project sizeSource scanContainer scan
Small (≤ 50 components)3–8 min1–3 min
Medium (50–500)8–20 min2–5 min
Large (≥ 500, multi-module)20–60 min5–10 min

The dominant cost in a source scan is the cdxgen walk, with scancode adding time proportional to the size of the first-party tree. The trivy sbom matching stage is fast — the Trivy DB is local and per-scan I/O is well under a second per thousand components. Container scans are bound by image-pull time when the image is not in the worker's cache.

The global scan queue

Visit Scans in the left sidebar for an organization-wide view of every running and queued scan. The queue is split into 5 status tabs: Running, Queued, Succeeded, Failed, All. Project- / team-level filters and per-worker views are on the roadmap.

Global /scans queue — Running / Queued / Succeeded / Failed / All status tabs above a recent-runs table with project, kind, and started-at columns

The Project column shows the project's display name and links to its detail page; rows where the underlying project name could not be resolved (a foreign-key fallback path) fall back to the first 8 characters of the project UUID. The list endpoint batch-loads the project relationship in a single round-trip, so the column populates without per-row lookups even on a queue of hundreds of scans.

Each queued or running row carries a Cancel scan action in its Actions column — see Cancel a scan.

Cancel a scan

You can stop a scan that is still queued or running — for example, when you triggered it against the wrong branch, or a large repo is taking longer than expected and you want to free the worker slot.

Audience

Any team member with developer or higher on the owning team. You can cancel only your own team's scans; a scan belonging to another team is not visible to you and cannot be cancelled. Super admins can cancel any scan from the admin scan queue.

From the UI

The Cancel scan action appears in two places:

  • The scan progress drawer (opens after you trigger a scan, or when you reopen a running scan).
  • The Actions column of each queued or running row in the global scan queue (/scans).

To cancel:

  1. Click Cancel scan.
  2. An inline confirmation appears. Click Cancel scan again to confirm, or Keep running to dismiss.
  3. The scan moves to cancelled and the progress bar stops.

What happens on the server when you confirm:

  • The worker task is stopped (the Celery task is revoked with SIGTERM).
  • The scan's workspace (the cloned source tree) is reclaimed.
  • The status becomes cancelled, with a completion timestamp and error_message = "cancelled by user".
  • The action is recorded in the audit log as a scans update.
Closing the browser is safe

Cancellation is processed entirely on the server. After you confirm, you can close the panel or the browser tab — the worker stops and the workspace is cleaned up regardless.

Already-finished scans cannot be cancelled

A scan that already reached a terminal state (succeeded, failed, or cancelled) cannot be cancelled. The UI shows the message "This scan already finished and can no longer be cancelled." This is expected — there is nothing left to stop.

From the API

curl -sS -X POST \
"https://trustedoss.example.com/v1/scans/${SCAN_ID}/cancel" \
-H "Authorization: Bearer ${TRUSTEDOSS_API_KEY}" | jq .
ResponseMeaning
200 OKThe scan was cancelled. The body carries the updated scan record with status: "cancelled".
404 Not FoundThe scan does not exist, or it belongs to a team you are not a member of. Other teams' scans are existence-hidden — a 404 does not confirm that the scan exists.
409 ConflictThe scan is already in a terminal state. The RFC 7807 body carries the extension field scan_already_cancelled: true.

Verify the cancel worked

  1. The scan status reads Cancelled in the drawer and in the /scans queue (Cancelled appears under the All tab).
  2. The progress bar is no longer advancing.
  3. The worker slot is free — a queued scan behind it begins running.
  4. The audit log records a scans update event with the new status.

Watching scan progress

Once a scan is queued the scan progress drawer opens with three panels stacked top-to-bottom:

  1. Stage list — every pipeline stage with its current state (pending, running, succeeded, failed, skipped). The active stage carries a live spinner.
  2. Per-stage log panel — a scrollable text panel mirroring the worker's log frames for the currently selected stage. Click any stage row to switch the panel to that stage's frames; the panel auto-scrolls to the latest frame while you stay near the bottom and pauses auto-scroll when you scroll up to read earlier output. Frames are buffered up to the most recent ~500 lines per stage; older lines roll off.
  3. Action footerCancel scan while the run is non-terminal; close affordance once it reaches succeeded / failed / cancelled.

Re-opening the drawer for an already-completed scan replays the persisted stage transitions and final log frame from the database (the spinner does not keep spinning on a succeeded row — the stage's terminal state is shown). Live frames stream over the WebSocket below.

WebSocket progress feed

The UI subscribes to ws(s)://<host>/ws/scans/{scan_id} for live stage and percentage updates. The connection auto-reconnects with exponential backoff if the network drops. Reconnect re-emits the latest stage so the UI converges quickly.

If you build a custom client, the message shape is:

{
"step": "trivy",
"percent": 90,
"ts": "2026-05-09T13:42:11Z"
}

percent is an integer 0–100. step is one of the pipeline slugs (bootstrap, fetch, prep, cdxgen, sign, scancode, approvals, scanoss, trivy, finalize) plus the two terminal states (succeeded, failed). The scancode slug replaced the former ort slug, and trivy replaced the former dt_upload/dt_findings pair after the Dependency-Track removal (ADR-0001). The frame does not echo scan_id — the subscriber already knows it from the URL.

Where a scan's results came from

A scan that reports fewer components than expected raises a question the results themselves cannot answer: did the scanner not find the package, or did it never see the file that declares it?

Every finished scan records what it had in front of it, and the scan detail page shows it under Where these results came from.

Source scans list the dependency manifests and lockfiles the fetched tree carried — package.json, go.mod, pom.xml, their lockfiles, and the equivalents for the other ecosystems — with each file's size. A component that no manifest declares is a component the scan had no way to find, and that is usually the whole answer.

The list is what the repository carried, not what the scan produced from it. TRUSCA generates a lockfile for ecosystems that cannot resolve transitive dependencies without one, and that generated file is deliberately absent here: the useful fact is that your repository did not have it.

Installed dependencies are not listed. node_modules, vendor, Pods and their equivalents hold a manifest per installed package, which states what a dependency declares about itself rather than what your project declares.

Uploaded SBOMs have no tree, so the panel shows what the document said about itself instead: its format and specification version, the tool that generated it, the supplier and authors it names, the timestamp it carries, and how many components it declared. These are the document's claims — a generator can write a timestamp that is wrong or name a supplier that is nobody, and comparing those claims against the results is the point.

An SPDX Tag-Value upload is scanned normally but is not summarised here, because the summary is built by parsing the document and that format is not parsed for this purpose.

The panel does not appear when nothing was recorded — scans that ran before this was added, and container scans, which have neither a tree nor an uploaded document.

Reading it through the API

curl -sS -H "Authorization: Bearer $TOKEN" \
"$PORTAL/v1/scans/$SCAN_ID/provenance"

Both halves are nullable, and null means "not recorded". That is different from an inventory whose count is 0, which means the scan looked and found no manifest — the second is a measurement, and often the answer you were looking for.

Verify it worked

After a scan completes:

  1. The project status switches to Succeeded.
  1. The Components count > 0.
  1. The Vulnerabilities count is visible (may be 0 if the project is genuinely clean).
  1. The Last scan timestamp on the Overview tab reflects "now".

    SELECT count(*) FROM scans s
    JOIN projects p ON p.id = s.project_id
    WHERE p.slug = 'portal-web'
    AND s.status = 'succeeded'
    AND s.completed_at IS NOT NULL;
  1. The audit log records target_table=scans&action=create and target_table=scans&action=update events.

Troubleshooting

Compose service names differ by stack

The commands below target the production compose (docker-compose.yml), whose Celery services are worker and beat. On the dev compose (docker-compose.dev.yml) the same services are celery-worker and celery-beat — substitute the name and the -f file if you are on the dev stack.

Scan stuck in Queued

No worker has picked it up. Either the worker is down or the queue is saturated.

docker-compose -f docker-compose.yml ps worker
docker-compose -f docker-compose.yml logs --tail=200 worker

If the worker is unhealthy, restart it:

docker-compose -f docker-compose.yml restart worker

If the queue is saturated, increase CELERY_CONCURRENCY in .env and docker-compose up -d worker to scale up. Each concurrent slot needs ~2 GB of RAM.

Scan failed with git clone error

The worker could not reach the repository. Check:

  • Is the repo URL correct? (Test from the worker: docker-compose exec worker git ls-remote <url>.)
  • Is the repo private? Embed credentials in the git_url — see Projects → Private repositories.
  • Does the worker have outbound HTTPS to your Git host? Corporate proxies must be set in .env (HTTP_PROXY, HTTPS_PROXY).

Scan finished but vulnerabilities are missing

The local Trivy DB may not be in place yet. Confirm on the worker:

docker-compose -f docker-compose.yml exec worker \
ls -lh /var/lib/trivy/db/

An empty or absent db/ directory means the boot-time download has not completed. The first download takes 1–3 minutes; the automatic re-match beat repopulates findings on existing scans once the DB lands — no re-scan needed. See Vulnerability data — Troubleshooting.

Scan stuck running for ≥ 4 hours

First try Cancel scan from the drawer or the /scans queue (see Cancel a scan). If the run does not move to cancelled — for example because the broker is unreachable — use the on-call playbook for force-cancel + worker inspect: On-call runbook → Scan stuck.

"Cancel scan" does nothing / the scan stays running

The cancel request reached the API but the worker did not stop in time:

  • If the broker (Redis) was briefly unreachable, the scan is still marked cancelled and the workspace is reclaimed by the orphan-workspace cleaner and the worker hard-limit backstop — you do not need to retry.
  • If the row still shows running after a minute, confirm the worker is up (docker-compose -f docker-compose.yml ps worker) and escalate via the on-call runbook.

"This scan already finished and can no longer be cancelled"

The scan reached a terminal state (succeeded / failed / cancelled) between the moment the page loaded and the moment you clicked Cancel scan. Reload the queue to see the up-to-date status — no action is needed.

A second scan won't start — the Scan button is greyed out

The project already has an ad-hoc (queued or running) scan, and the button triggers another of the same kind. Only one active scan per branch is allowed, and every ref-less trigger shares one bucket. Open the in-progress chip in the project header (or the row in the global queue) to see the existing run, wait for it to finish, or Cancel it before starting another. A scan running on a named branch does not disable the button — see One scan at a time per branch.

A completed scan's drawer shows a spinner that never finishes

Older builds (pre-P1) left the Finalizing step's spinner animating after the scan had already reached succeeded. The fix freezes the spinner on the terminal state when the drawer is opened on a completed run. If you still see the symptom, force-reload the project page to refresh the cached scan record.

Detected (first-party) licenses are missing

The Detected licenses come from scancode and are best-effort. They may be absent when:

  • scancode is not installed in the worker image (the scan still succeeds with declared licenses only — non-fatal). Confirm with docker-compose -f docker-compose.yml logs worker | grep scancode_stage_skipped.
  • The first-party tree exceeds the SCANCODE_MAX_FILES ceiling, scancode timed out, or the result was too large — all log a warning and fall back to declared-only.
  • The relevant code lives inside an excluded directory (node_modules, vendor, .git, dist, build, out, target, .venv, …). Those are skipped by design — see Components & licenses → Detected vs. declared.

Roadmap

Items tracked for later releases.

  • Branch-override field on the project-level Scan trigger — planned. (The Source / Container kind-selection dialog shipped in this release — see Trigger a scan → From the UI.)

See also