bd-jira-sync
Report Black Duck findings as Jira tickets.
One tool for both risk types:
--license— FOSS license risks, grouped into one ticket per project version.--security— vulnerabilities (CVEs), one ticket per finding or grouped per project version.
Both modes share the same behaviour: read the BOM, drop everything that was
already reported in an earlier run, create Jira tickets for what is new, link
them to a master ticket, and keep unresolved tickets of earlier runs linked to
the current master ticket. A risk that comes back after its ticket was closed is
raised again, and a ticket whose findings are gone is handed back to its project
owner. Nothing is written to Jira unless --send is given.
No server names, project names or credentials live in the code: all of it comes from a configuration file and the environment.
Install
uv sync
Configure
copy config.example.yaml config.yaml
copy .env.example .env
.envholds the credentials —BLACKDUCK_URL,BLACKDUCK_API_TOKEN,JIRA_URL,JIRA_TOKEN. Real environment variables win over.env, so CI can inject them from a secret store.config.yamlholds everything else: Jira project, issue type, components, assignee, link type, done statuses, the project versions to scan with their owners, and the reporting rules per risk type. Unknown or incomplete settings abort the run with a message naming the offending key.
Per-project risk types
A project version is scanned for both risk types unless its optional track list
says otherwise. Use it when, say, all ten project versions need security tracking
but only seven need license tracking:
projects:
- project: payment-service # no 'track' -> license + security
version: main
owner: payments.owner
- project: vendor-sdk # security only
version: "4.1"
owner: vendor.owner
components: [Vendor] # added to jira.components on every ticket of this project version
track: [security]
Valid values are license and security. --license / --security still decide
which workflows run at all; track decides which project versions each one visits.
An explicit --project overrides the config file entirely, including track (the
owner of a project version the config knows is kept).
track applies to re-linking as well: tickets of a project version that this run
does not visit are left alone, so dropping a scope stops the weekly re-link of its
old tickets instead of dragging them along forever. They stay in the state file and
resume as soon as the scope comes back.
Project owners
owner is the Jira user id of whoever decides that a ticket may be closed. It is
only used when a finding disappears: the tool then asks that person to close the
ticket (see below). A configuration without owners keeps working — the run prints a
warning naming every project version that has none, and leaves their tickets alone:
WARNING no 'owner' configured for 2/11 project version(s): web-frontend/main, gateway/main
WARNING their tickets are never commented or reassigned when a finding disappears; add 'owner: <jira user id>' to each entry under 'projects'
An owner that is not a single word (a Jira user id, not a display name) aborts
the run as a configuration error.
Findings that disappear
The mirror image of a recurrence: the scan no longer reports what a ticket was raised for. The tool does not close anything by itself — closing is a human decision — it hands the ticket back to its owner:
- Every finding of the ticket is gone from the current scan. A grouped ticket that still carries one live risk is left alone.
- The ticket is not in a
jira.closed_statusesstate. Aresolvedorimplementedticket is still open in Jira and still needs someone to close it, so it is reminded too; only a truly closed ticket is left alone. - No comment starting with
Automated Check: This bug is not thereexists yet.
When all three hold, the ticket gets
Automated Check: This bug is not there in current blackduck scan.
Reason: every finding this ticket was raised for has left the Black Duck scan. This is not a
duplicate - no other ticket took these findings over.
Please [~owner] close this ticket.
and is assigned to that owner. The comment is the memory of this step: finding it
again on a later run is what stops a second one, so editing or deleting it makes
the tool comment once more. Nothing is written to the state file, and --send is
required — a dry run only lists what it would say.
Findings that come back
Closing a ticket is a statement: that risk was dealt with. When a later scan reports the same finding again, the tool treats it as a regression and raises a new ticket instead of skipping it because "a ticket already exists":
jira:
on_recurrence: new_ticket # default; 'ignore' keeps the finding silent
The new ticket says why it exists, so nobody has to dig for the history:
Automated Check: This bug was reported before and its ticket was closed, but the
risk is present again in the current blackduck scan:
* HIGH license risk: GPL-3.0 in libfoo:2.1 - previously CIVION-2937
The two risk types can differ. license.on_recurrence and
security.on_recurrence override the jira setting for their own workflow;
leave them out to follow it:
jira:
on_recurrence: ignore # license risks stay quiet ...
security:
on_recurrence: new_ticket # ... but a CVE that comes back gets a ticket
Only a closed ticket gets that treatment. Open tickets are unaffected: their
findings stay tracked as before, and the status of each ticket is read at most
once per run. If Jira cannot be asked for a status, the finding stays tracked
— a duplicate ticket is worse than a recurrence reported one run late.
Duplicate tickets
The state file is a cache, not the authority. Lose it, point a run at a
different --state path, or let Black Duck re-map a vulnerability id, and every
finding looks new again — which is how a project ends up with three open tickets
for one CVE.
So before creating anything, the tool asks Jira whether a ticket for the same
finding already exists in jira.project_key. "Finished" here means
jira.closed_statuses, not done_statuses: a resolved or implemented ticket
is still an open item in Jira, so it still counts as a twin. Only a closed one is
out of the picture — which is exactly what makes a returning finding a genuine
recurrence rather than a duplicate.
-
A match that is not closed is reused: no new ticket, the existing one is linked to the master ticket and recorded in the state so later runs find it locally.
-
Further matches are moved to
jira.duplicate_statusand told why:Automated Check: This ticket is a duplicate. Reason: https://jira.example.com/browse/CIVION-3993 tracks the same Black Duck finding and is still open. The findings are not gone - they stay tracked on CIVION-3993. Closing this ticket as a duplicate; work on CIVION-3993 instead.
When the transition is refused — the sync user may not close that issue, the workflow has no path to
jira.duplicate_status, or that setting is empty — the ticket is not left in limbo: it gets the same reason with a request instead, and is assigned to the projectowner.Please [~foss.owner] close this ticket as a duplicate of CIVION-3993, and work on CIVION-3993 instead.
A refused transition is a warning, not an error: it does not fail the run. Both comments start with
Automated Check: This ticket is a duplicate, and that line is the memory of this step — a ticket already carrying it is not commented or reassigned again, so a weekly run does not nag.The survivor is the oldest ticket that is not done yet, or the oldest of all when every twin already claims to be handled. The oldest carries the history — comments, links, the analysis somebody already wrote — but closing an in-progress ticket in favour of an older
implementedone would throw that work away. When the state file tracked a different twin, its record is moved onto the survivor, so later runs keep deduping against a ticket that is still open. Tickets already known to the state get the same sweep on every run, so duplicates raised before this check existed are cleaned up too. -
Only closed matches mean the risk came back after being dealt with, so a new ticket is raised — see Findings that come back.
-
Jira cannot be searched: nothing is created, and the run reports an error. Reporting a finding one run late beats a fourth duplicate.
jira:
duplicate_status: Closed # empty: always ask the owner instead
duplicate_resolution: Duplicate
duplicate_link_type: Duplicate # closed ticket 'duplicates' the survivor; empty: no link
Every ticket closed or flagged as a duplicate is also linked to the survivor with
jira.duplicate_link_type: the newer ticket duplicates the older one.
The same CVE on the same component can come with several CWEs, or on several
versions of the package at once. Those are different risks, not duplicates: each gets its own finding key and ticket. A CWE is added to the summary (... in project (CWE-79) on CW41) or placed with {cwe} in
security.summary_template. A CVE with a single CWE keeps its summary and key.
Matching is on the ticket summary minus a trailing on CW41 / on 2026-10-05,
so a {week} or {date} in a summary template does not hide last week's ticket.
Anything else in the template has to stay stable for a given finding, which is
why the summary templates use {vulnerability}, {package} and {project}
rather than the CVSS score or the severity.
Findings nobody needs a ticket for
Black Duck's own remediation status already records decisions. Findings carrying one of these are skipped instead of becoming a ticket somebody has to close again:
security:
skip_remediation_statuses: [IGNORED, PATCHED, MITIGATED, DUPLICATE, REMEDIATION_COMPLETE]
Set it to [] to report every finding regardless of what Black Duck says.
Findings still detected on a handled ticket
jira.done_statuses lists everything that stops a ticket from being re-linked.
jira.closed_statuses names the subset that ends a ticket for good; it has to
be a subset of done_statuses and defaults to closed:
jira:
done_statuses: [resolved, closed, implemented]
closed_statuses: [closed]
Everything in between — implemented, resolved — is only a claim that a fix is
in. When the next scan still reports the finding, that claim was wrong, and a
second ticket would only split the discussion. So nothing is created: the
existing ticket is commented on and assigned back to the project owner:
Automated Check: This bug is still detected in the blackduck scan of CIVION-3001,
although this ticket is 'Implemented':
* HIGH license risk: GPL-3.0 in libfoo:2.1
Please [~foss.owner] re-check it. This ticket keeps tracking the risk - no new
ticket is raised until it is closed.
A grouped ticket gets one comment listing every finding that is still there. The
first line carries the master ticket of the run, so the comment is said once per
run instead of once per re-run of the same week. A project version without an
owner is only reported as a warning. --send is required; a dry run lists the
tickets it would comment on.
Per-workflow assignee and components
jira.assignee and jira.components apply to every ticket. The license and
security sections can refine them: components there are added to
jira.components, while assignee replaces jira.assignee (set it empty to
leave the field to Jira's own default):
jira:
assignee: master.user
components: [creator, T_FOSS]
license:
components: [] # license tickets: creator, T_FOSS
security:
components: [T_CYSE] # security tickets: creator, T_FOSS, T_CYSE
assignee: cyse.owner # and assigned to cyse.owner
One exception: a per-CVE or per-package security ticket with a patch available (for a
package ticket: for any one of its CVEs) is assigned to the owner of its project
version instead — a fix exists, so the ticket goes straight to whoever can apply it.
Without a patch, or without an owner on that project version, the configured
assignee is used. Per-project tickets always keep the configured assignee.
Existing tickets follow the config, not the creation rule: on every run each open
ticket of a tracked project version is assigned to that project's owner and gets
exactly the components of jira.components, the workflow's components and the
project's components (extra ones are removed; with none configured the
components are left alone). Change an owner or components entry in the config
and the next --send run updates the tickets linked to the master ticket; a dry
run lists them. Closed tickets are never touched.
CVE severity and Jira priority
The CVSS score of a finding decides its severity, not the label Black Duck ships
with it; a finding without a usable score keeps the reported label. The default
security.severity_priority maps those bands to Jira:
| CVSS | Severity | Jira priority |
|---|---|---|
| 9.0 – 10.0 | CRITICAL |
Critical |
| 7.0 – 8.9 | HIGH |
Major |
| 4.0 – 6.9 | MEDIUM |
Minor |
| 0.1 – 3.9 | LOW |
Trivial |
tracked_severities and min_cvss_score filter on the same, score-derived
severity. The priority names have to exist in the Jira project; a mapping must
cover every tracked level or the run aborts.
Ticket grouping
Each workflow decides on its own through group_by:
license:
group_by: project # default: one ticket per project version, risks in a table
security:
group_by: finding # default: one ticket per vulnerability
The older group_by_project: true / false still loads and means project /
finding; when both are set, group_by wins.
group_by |
License | Security |
|---|---|---|
project |
one ticket per project version, all new risks in a table, priority from the highest risk | one ticket per project version, all new findings in a table |
package |
one ticket per package version, all its risks in a table | one ticket per package version, all its CVEs in a table |
finding |
one ticket per risky component, priority from that component's risk | one ticket per vulnerability |
Which summary template applies follows the same switch: summary_template /
package_summary_template / single_summary_template for license,
group_summary_template / package_summary_template / summary_template for
security. A package_summary_template may only use {project} {version} {package} {package_version} {week} {date}: the Jira duplicate check matches on the summary,
so it must not change with the set of CVEs.
With package, a CVE that shows up later on a package version whose ticket is
still open is added to that ticket (state updated, Jira comment listing it); if
that ticket is closed, on_recurrence decides and a new ticket is raised. An
upgrade is a new package version, so it gets its own ticket. The old ticket is
asked to be closed once every one of its CVEs has left the scan.
Switching to a coarser grouping (finding to package to project) folds the
live tickets of the finer kind into the new group ticket, controlled per workflow
by on_regroup (merge, the default, or keep). For each package version the
run raises one group ticket holding all its findings that are still in the scan,
then for every old ticket it:
- comments the new ticket's link and links the two as duplicates,
- closes it with
jira.duplicate_status/duplicate_resolution, or, when the sync may not transition it, asks the workflow'sassignee(falling back tojira.assignee) to close it and assigns it to them, - stops re-linking it to the new master ticket.
Only tickets whose findings are all still in the scan are merged, and tickets in
a done-but-not-closed status (for example implemented) are left alone. Tickets
are never split: switching to a finer grouping leaves existing coarser tickets as
they are. A dry run lists every merge under "Merge N old ticket(s) into group
tickets".
The state file needs no migration: finding keys are unchanged, new tickets gain
an optional group field (records without it are inferred, and anything unclear
counts as project so it is never merged by mistake), and a merged ticket's
record keeps its jira_key, loses its findings and gains merged_into.
--single applies to every selected workflow
--single is a per-run override for the times you want individual tickets
without editing the config file. It is not per risk type: it forces
group_by: finding on every workflow the run selected, ignoring what the
config says. Assuming both settings are project:
| Command | License | Security |
|---|---|---|
--license --security |
1 grouped ticket per project version | 1 grouped ticket per project version |
--license --security --single |
1 ticket per risky component | 1 ticket per vulnerability |
--license --single |
1 ticket per risky component | not run at all |
So --single cannot give you grouped tickets for one risk type and individual
tickets for the other. For that combination leave --single off and put the
decision in the config file, where each workflow is independent:
license:
group_by: finding # individual license tickets
security:
group_by: project # one grouped security ticket per project version
Run
# The two commands you will use most
uv run bd-jira-sync ABC-123 --license --security # dry run: what would happen
uv run bd-jira-sync ABC-123 --license --security --send # do it
| Option | Meaning |
|---|---|
MASTER_TICKET |
Jira key of the ticket tracking this scan cycle; every new ticket is linked to it. |
--license / --security |
Which risk types to report. At least one is required, unless --unlink is used. |
--single |
One ticket per finding instead of one grouped ticket per project version. Applies to every selected workflow and overrides both group_by settings; use the config file instead if you want the two risk types to differ. |
--link |
List the tickets linked to MASTER_TICKET, their status and how the state file sees them. Always read only. |
--unlink [KEYS] |
Undo links instead of reporting. Bare --unlink detaches every finding ticket from MASTER_TICKET; --unlink ABC-4,ABC-5 detaches only those. |
--send |
Actually apply the change in Jira and write the state file. Without it the run is read-only. |
-c, --config PATH |
Configuration file (default config.yaml). |
-s, --state PATH |
State file (default data/state.yaml). |
-e, --env PATH |
Env file with the tokens (default .env). |
-p, --project NAME/VERSION |
Scan only this project version for every selected workflow; repeatable, overrides the config file including track. |
-q, --quiet |
Print the summary only. |
Exit codes: 0 success, 1 finished with errors, 2 bad command line,
3 configuration error, 4 Black Duck authentication failed.
Every option by example
MASTER_TICKET — the ticket this cycle hangs on
# Every ticket created or re-linked by this run is attached to CIVION-3001
uv run bd-jira-sync CIVION-3001 --license --security --send
Required and positional. Use one master ticket per scan cycle (typically one per
calendar week); the tool records it per finding ticket so the next cycle knows
what is already attached. Pointed it at the wrong one? See --unlink below.
Master tickets accumulate. Naming a new one links every still-open ticket of earlier runs to it in addition to the old master ticket — nothing is detached, in Jira or in the state file:
master_tickets: [CIVION-2821, CIVION-3001] # week 34, then week 35
So a ticket that survives four weeks ends up on four master tickets, which is
what makes each weekly master ticket a complete picture of what was open that
week. Use --unlink when you want one of them detached.
--license / --security, --link and --unlink are three separate modes;
pick exactly one per run.
--link — what is attached to this master ticket
uv run bd-jira-sync CIVION-3001 --link
=== Tickets linked to CIVION-3001 ===
TICKET STATUS SCOPE PROJECT VERSION / NOTE
CIVION-2483 Closed security sdv_cloud-emulator-backend/master
CIVION-2792 - - 'Blocks' link, not created by this tool
CIVION-2937 Implemented license sdv_cloud-emulator-backend/master
CIVION-2938 In Progress license sdv_starterkit-frontend/master
CIVION-9999 In Progress - not in the state file
CIVION-2944 - license sdv_app_skivi_cluster/main - state only, Jira has no link
Finding tickets linked : 4
of those done : 2
of those unknown to the state: 1
Other links left out of scope : 1
State records without a Jira link: 1
Read only — --send has no effect on it. It reads the links from Jira, the
status of each one, and joins them against the state file, which makes the two
kinds of drift visible:
- not in the state file — someone linked that ticket by hand, or the state file was lost. A run will not re-link or manage it.
- state only, Jira has no link — the state claims a link Jira does not have,
usually a hard-killed run.
--unlinkcleans those records.
Use it before --unlink to see exactly what you are about to detach, and after
a weekly run as the review of what the master ticket ended up carrying.
--license / --security — which risk types to report
uv run bd-jira-sync ABC-123 --license --send # FOSS license risks only
uv run bd-jira-sync ABC-123 --security --send # CVEs only
uv run bd-jira-sync ABC-123 --license --security --send # both, license first
At least one is required. They select the workflows; the track list in
config.yaml then decides which project versions each workflow visits.
--send — leave dry run mode
uv run bd-jira-sync ABC-123 --security # reads only, changes nothing
uv run bd-jira-sync ABC-123 --security --send # creates, links, writes the state file
Without it no issue is created, no link is made and the state file is not
written. Run it once without and once with — the dry run output is the review
step. Works the same for --unlink. A --send run closes with a
Tickets created list naming every new ticket and the project version it came
from, so the log of the run is the record of what it changed.
--single — one ticket per finding
# Individual tickets for this run only, whatever group_by says
uv run bd-jira-sync ABC-123 --license --single --send
Overrides group_by for every workflow the run selected. To have
grouped tickets for one risk type and individual ones for the other, leave
--single off and set the two group_by settings in config.yaml.
-p, --project NAME/VERSION — narrow the run
# One project version
uv run bd-jira-sync ABC-123 --license --project my-product/main --send
# Several, repeat the flag
uv run bd-jira-sync ABC-123 --security -p my-product/main -p vendor-sdk/4.1 --send
# Project names may contain '/': the last '/' separates the version
uv run bd-jira-sync ABC-123 --license -p group/my-product/main
Replaces the projects list of the config file entirely, including track, so
the named project versions are scanned by every selected workflow. Handy to
re-run a single project after fixing something, without touching the others.
--unlink [KEYS] — undo a wrong master ticket
uv run bd-jira-sync ABC-999 --unlink # dry run: what is attached
uv run bd-jira-sync ABC-999 --unlink --send # detach every finding ticket
uv run bd-jira-sync ABC-999 --unlink CIVION-2483,CIVION-2484 --send # detach only these
Removes links, never tickets. --link first if you want to see the list before
committing to it. Details and guarantees in
Undoing a wrong master ticket.
-c, --config PATH — a different configuration
uv run bd-jira-sync ABC-123 --license --send -c config/release-branch.yaml
Use one file per project set or per Jira project, for example a weekly config and a release config that scan different project versions.
-s, --state PATH — where the memory lives
uv run bd-jira-sync ABC-123 --license --send -s data/state.yaml # the default
uv run bd-jira-sync ABC-123 --license --send -s D:\shared\civion.yaml
Relative to the current directory, so run from the repository root or pass an absolute path. Missing directories are created and the write is atomic. Keep one state file per project set: two schedules pointing at different files report everything twice.
-e, --env PATH — where the tokens come from
uv run bd-jira-sync ABC-123 --license --send -e .env.production
Real environment variables always win over the file, so in CI you can inject
BLACKDUCK_API_TOKEN and JIRA_TOKEN from a secret store and skip the file.
-q, --quiet — summary only
uv run bd-jira-sync ABC-123 --license --security --send -q
Drops the per-finding lines and the detail of the dry run plan, keeps the run header, the section headers, the summary block and every error. Use it for scheduled jobs whose log you only read when something failed.
--version, -h / --help
uv run bd-jira-sync --version
uv run bd-jira-sync --help
Common combinations
| Goal | Command |
|---|---|
| Weekly cycle, review first | uv run bd-jira-sync ABC-123 --license --security |
| Weekly cycle, apply | uv run bd-jira-sync ABC-123 --license --security --send |
| Same, quiet, for a scheduler | uv run bd-jira-sync ABC-123 --license --security --send -q |
| Re-run one project after a fix | uv run bd-jira-sync ABC-123 --security -p my-product/main --send |
| Individual tickets this once | uv run bd-jira-sync ABC-123 --license --single --send |
| See what a master ticket carries | uv run bd-jira-sync ABC-123 --link |
| Wrong master ticket, undo it | uv run bd-jira-sync ABC-999 --unlink --send |
| Then link to the right one | uv run bd-jira-sync ABC-123 --license --security --send |
| Second project set, own state | uv run bd-jira-sync XYZ-9 --license --send -c other.yaml -s data/other.yaml |
Dry run
Without --send nothing is written: no Jira issue, no link, no state file. The
run reads Black Duck and Jira and reports what it would do, per project version
and again as a plan per workflow:
=== License risks: re-linking tickets from earlier runs ===
would re-link CIVION-2941 (In Progress) -> CIVION-3001
done CIVION-2937 (Implemented) - not re-linked
already linked CIVION-2942 (my-product/main)
not tracked 3 ticket(s) of vendor-sdk/4.1 - left untouched
=== License risks: my-product/main ===
tracked libfoo 1.2.3 [GPL-3.0-only, High] -> CIVION-2941
still detected libbaz 3.0 [LGPL-2.1, High] -> CIVION-2937 (Implemented)
would comment CIVION-2937 (Implemented) -> ask foss.owner to re-check it
new libbar 0.9 [MPL-2.0, Medium]
would create FOSS license risks in my-product-main on CW34 [priority Major, 1 finding]
=== License risks: dry run plan ===
Re-link 1 existing ticket(s) to CIVION-3001:
- CIVION-2941 (In Progress) -> CIVION-3001 [my-product/main]
Skip 1 done ticket(s), not re-linked to CIVION-3001:
- CIVION-2937 (Implemented) [my-product/main]
Create 1 new ticket(s):
- FOSS license risks in my-product-main on CW34 [priority Major, 1 finding] [my-product/main]
Reuse 1 open ticket(s) that already cover a finding:
- CIVION-3993 (In Progress) already tracks 'CVE-2026-17495 impacting moment/moment:2.30.1 in my-product' [my-product/main]
Close 1 duplicate ticket(s):
- CIVION-4095 (Open) duplicates CIVION-3993
Raise 1 finding(s) that came back on a closed ticket:
- HIGH license risk: GPL-3.0 in libfoo:2.1 [was CIVION-2937, Implemented]
Comment on and reassign 1 ticket(s) whose findings are still detected:
- CIVION-2937 (Implemented) -> ask foss.owner to re-check it [my-product/main]
Comment on and reassign 1 ticket(s) whose findings are gone:
- CIVION-2903 (In Progress) -> ask foss.owner to close it [my-product/main]
The per-ticket verbs are the whole vocabulary:
| Line | Meaning |
|---|---|
would re-link / re-linked |
Existing ticket, still open, gets a link to this master ticket. |
done |
Ticket is in one of jira.done_statuses — never re-linked and never reopened. |
already linked |
Ticket already carries this master ticket. |
not tracked |
Ticket belongs to a project version this run does not visit (track, or --project). |
tracked |
Finding is already covered by the named ticket. |
recurred |
Finding came back although its ticket is closed — it is raised in a new ticket. |
still detected |
Finding is still in the scan although its ticket claims to be handled (done but not closed) — it keeps that ticket. |
verified |
The new ticket was told that it replaces a closed one whose risk is back. |
superseded |
The closed ticket handed the recurring finding over to the ticket that replaced it. |
would comment / commented |
The ticket's owner is asked to act: to close it when no finding is left, to re-check it when a finding is still detected. |
assigned |
The same ticket moved to its project owner. |
already noted |
That ticket was already told in this run that its bug is still detected. |
new |
Finding nobody reported yet; it goes into a ticket below. |
would reuse / reused |
Jira already has an open ticket for this finding, so none is created; that ticket is linked and recorded instead. |
would close / closed duplicate |
A further ticket for the same finding, closed in favour of the survivor. |
asked to close |
The same, but the transition was refused — the project owner is asked to close it by hand. |
already flagged |
That ticket already carries the Automated Check: This bug is not there or Automated Check: This ticket is a duplicate comment. |
would create / created |
The ticket itself, with its priority and how many findings it covers. |
Every Jira key printed is openable: terminals that support hyperlinks make the
key itself clickable, anywhere else it is followed by its browse URL. Set
NO_HYPERLINKS=1 to always get the plain KEY (url) form.
Add -q to print the summary block only.
Undoing a wrong master ticket
If a run was pointed at the wrong master ticket, --link shows what it picked
up and --unlink takes the links back out. Unlinking is a dry run like
everything else until --send is added:
# See what is attached to the wrong master ticket
uv run bd-jira-sync ABC-999 --link
# Detach every finding ticket from it
uv run bd-jira-sync ABC-999 --unlink --send
# Detach only these
uv run bd-jira-sync ABC-999 --unlink CIVION-2483,CIVION-2484 --send
# Then attach them to the right one - the normal run re-links what is still open
uv run bd-jira-sync ABC-123 --license --security --send
=== Unlinking from ABC-999 ===
unlinked CIVION-2483 -> ABC-999
kept EPIC-12 ('Blocks' link, not created by this tool)
kept CIVION-2484 (not selected)
state cleaned CIVION-2483 no longer records ABC-999
What it does and does not touch:
- Only links of the configured
jira.link_typeare removed. Any other link on the master ticket is reported askeptand left in place. - The tickets themselves are never closed, deleted or edited — only the link.
(
--unlinkalso never comments or reassigns: that only happens on a normal run.) - Every removed link is also taken out of
master_ticketsin the state file, so the next normal run re-links those tickets to the right master ticket instead of reporting them asalready linked. - A link whose removal failed keeps its state record, so the file never claims more than what Jira actually did. Re-run the command to retry it.
- A record that claims a link Jira no longer has is cleaned too, which repairs the state after a hard-killed run.
State file
The state file is the memory of the tool: it maps every reported finding to the Jira ticket that covers it. Point every run of the same project set at the same file, otherwise findings are reported twice.
version: 2
license:
my-product/main:
project_name: my-product
version_name: main
tickets:
- jira_key: ABC-456
summary: FOSS license risks in my-product-main on CW23
url: https://jira.example.com/browse/ABC-456
priority: Critical
created_at: "2026-06-04T10:12:00"
master_tickets: [ABC-123]
findings:
- key: my-product|main|libfoo|1.2.3|GPL-3.0-only|HIGH
summary: libfoo 1.2.3 [GPL-3.0-only, High]
severity: HIGH
security:
my-product/main:
project_name: my-product
version_name: main
tickets:
- jira_key: ABC-457
summary: "CVE-2026-1111 impacting libbar:2.0 in my-product on CW23"
url: https://jira.example.com/browse/ABC-457
priority: Critical
created_at: "2026-06-04T10:12:03"
master_tickets: [ABC-123]
findings:
- key: my-product|main|CVE-2026-1111|libbar
summary: CVE-2026-1111 in libbar 2.0 [High, CVSS 7.5]
severity: HIGH
A finding is identified by its key, and that key does not depend on
group_by: flipping grouping changes how many tickets a run creates,
never what counts as already reported. Switching in either direction is safe on
an existing state file.
The two keys follow the same rule: a finding is specific to a component version, so
a component whose version, license or risk level changes is reported again — that
is a genuinely new thing to review. The security key is
project|version|vulnerability|package|package_version; two versions of one
package with the same CVE are two findings and two tickets.
When a finding is raised again after its ticket was closed, its key moves out of
the closed ticket's findings and into a recurred_as map on that same record:
- jira_key: ABC-456
recurred_as:
my-product|main|libfoo|1.2.3|GPL-3.0-only|HIGH: ABC-912
findings: []
The old ticket keeps the audit trail, while dedup now matches the ticket that replaced it — which is what stops the recurrence from being raised on every later run.
version is the schema of the file. It is upgraded in place on load, so a state
file written by an older release keeps working: 1 -> 2 rewrites the security
keys, which used to carry the component version as a fifth segment; 2 -> 3 puts
the version back, read from each stored summary line. A record whose summary names
no version keeps its old key and is re-keyed the first time a scan reports it.
Interrupted runs
master_tickets is the record of links Jira actually confirmed, so a run that
dies half way through does not lose or repeat work:
- A ticket is stored with an empty
master_ticketsand the master is appended only after Jira accepted the link. A create that succeeded while its link failed is therefore picked up by the re-link step of the next run. - A single failed link is reported as an error and the run continues; the next
run retries exactly the tickets whose
master_ticketslack the master, and reports the rest asalready linked. - Any exception aborts the run, but the state file is still written (the CLI
saves it in a
finallyblock), so completed links stay recorded.
Only a hard kill (the process never reaches that save) loses the record of the links made in that run; the next run then links those tickets again.
Layout
src/blackduck_jira_sync/
cli.py command line, run header, exit codes
config.py configuration model, validation, env overrides
http_client.py JSON over HTTP, TLS options, error mapping
blackduck.py Black Duck REST client (auth, name lookup, paging)
jira.py Jira REST client (create, link, unlink, status, comment, assign)
state.py the state file
engine.py dedup, recurrences, ticket creation, linking, counters
links.py listing and undoing the links of a master ticket
markup.py Jira wiki markup helpers
reporting.py console output
workflows/
base.py the Workflow interface
license.py license risk collection and ticket text
security.py vulnerability collection and ticket text
Adding a risk type means adding one Workflow implementation: collect findings,
build ticket drafts. Deduplication, linking, state and reporting are shared.
TLS
TLS verification is on by default. verify_ssl: false exists for lab setups and
disables certificate checking — do not use it against production systems.
With verify_ssl: true, Python validates certificates against the certifi
bundle, not against the Windows or Linux system store. A corporate server behind
a private CA — or a server that does not send its intermediate certificate —
therefore fails with:
certificate verify failed: unable to get local issuer certificate
The fix is a PEM file holding the issuing CA chain, referenced from ca_bundle
for blackduck and jira (each host may need a different chain; one file can
hold both):
blackduck:
verify_ssl: true
ca_bundle: "certs/company-ca.pem"
jira:
verify_ssl: true
ca_bundle: "certs/company-ca.pem"
verify_ssl decides, ca_bundle only refines it:
verify_ssl |
ca_bundle |
Result |
|---|---|---|
true |
set | Certificates are validated against that PEM file. The file must exist or the run stops with a configuration error. |
true |
empty | Validated against the certifi bundle. |
false |
anything | No validation at all. The ca_bundle line is ignored, existing or not, so you can switch verification off without deleting it. |
ca_bundle is a path like any other: a relative one resolves against the
directory you run from, not against config.yaml. Running the tool from
elsewhere with certs/company-ca.pem in the file is the usual cause of
Configuration error: config.yaml: 'blackduck.ca_bundle' points at 'certs/company-ca.pem',
which does not exist (relative paths resolve against the current directory, ...)
Use an absolute path if the tool is started from a scheduler.
Generating the bundle
The browser trusts the server already, so the certificates can be taken from the machine's own trust store. Run this once per host and append to the same file — it writes every certificate above the server certificate (intermediates and root) as PEM.
$h = 'blackduck.example.com' # repeat with 'jira.example.com'
$t = New-Object System.Net.Sockets.TcpClient($h, 443)
$s = New-Object System.Net.Security.SslStream($t.GetStream(), $false, ({ $true } -as [System.Net.Security.RemoteCertificateValidationCallback]))
$s.AuthenticateAsClient($h)
$leaf = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($s.RemoteCertificate)
$s.Dispose(); $t.Close()
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = 'NoCheck'
$null = $chain.Build($leaf)
New-Item -ItemType Directory -Force -Path certs | Out-Null
$out = @()
$chain.ChainElements | Select-Object -Skip 1 | ForEach-Object {
$c = $_.Certificate
$out += "# $($c.Subject)"
$out += '-----BEGIN CERTIFICATE-----'
$out += [Convert]::ToBase64String($c.RawData, 'InsertLineBreaks')
$out += '-----END CERTIFICATE-----'
}
Add-Content -Path certs\company-ca.pem -Value $out -Encoding ascii
On Linux or macOS, openssl does the same:
openssl s_client -showcerts -servername blackduck.example.com \
-connect blackduck.example.com:443 </dev/null 2>/dev/null \
| openssl x509 -outform PEM >> certs/company-ca.pem
Verify the result before running the sync:
python -c "import requests; print(requests.get('https://jira.example.com/rest/api/2/serverInfo', verify='certs/company-ca.pem').status_code)"
Anything other than an SSLError means the bundle is accepted. If your IT
department publishes the CA certificate, prefer that file over the exported one.
Development
uv run pytest
A Makefile wraps the same commands and works identically on Windows, Linux and
macOS as long as GNU make and uv are installed:
make # list the targets
make install # uv sync --all-groups
make build # uv build -> dist/
make test # uv run pytest
make clean # drop dist/, build/ and the caches
make run ARGS="MASTER-1 --license --security --send"
make test ARGS="-k license -v"
Quote ARGS, otherwise make treats the leading dashes as its own options.
License
Copyright (C) 2026 Dinesh Ravi
Licensed under the GNU Affero General Public License v3.0 or later. See LICENSE for the full text.
Metadata
Release files for blackduck-jira-sync 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| blackduck_jira_sync-0.5.1.tar.gz | 131.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| blackduck_jira_sync-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 204.4 kB
Release files / blackduck_jira_sync-0.5.1.tar.gz
| Download URL | blackduck_jira_sync-0.5.1.tar.gz |
|---|---|
| Size | 131.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8ad70f1d0bcdd66a975cdc2dafcbe79b4ad3cc633f686b5265f6595651498fec
|
|
BLAKE2b-256 checksum How to use checksums |
f0c64109c711d4d5969cb9062286aa5e63c7f12054dab04a5c6879eed6072d68
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / blackduck_jira_sync-0.5.1-py3-none-any.whl
| Download URL | blackduck_jira_sync-0.5.1-py3-none-any.whl |
|---|---|
| Size | 72.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
059217549672f557f413b932042a8ddb268b8ebb7bc61cd3b86864928d41e67b
|
|
BLAKE2b-256 checksum How to use checksums |
539bb969c22d1d4108d2b22ae92478d3e52aac7c6bb3e9b32ded96e56eec5d34
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|