Metadata-Version: 2.5
Name: blackduck-jira-sync
Version: 0.2.5
Summary: Sync Black Duck license and security findings into Jira tickets.
License: AGPL-3.0-or-later
License-File: LICENSE
Requires-Python: >=3.9
Requires-Dist: blackduck>=1.1.3
Requires-Dist: python-dotenv>=1.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: requests>=2.31
Description-Content-Type: text/markdown

# bd-jira-sync

Report [Black Duck](https://www.blackduck.com/) 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. 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

```powershell
uv sync
```

## Configure

```powershell
copy config.example.yaml config.yaml
copy .env.example .env
```

* `.env` holds 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.yaml` holds everything else: Jira project, issue type, components, assignee,
  link type, done statuses, the project versions to scan, 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:

```yaml
projects:
  - project: payment-service      # no 'track' -> license + security
    version: main
  - project: vendor-sdk           # security only
    version: "4.1"
    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`.

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

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

```yaml
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
```

### 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_project`:

```yaml
license:
  group_by_project: true    # default: one ticket per project version, risks in a table
security:
  group_by_project: false   # default: one ticket per vulnerability
```

So grouped security tickets alongside individual license tickets is just
`security.group_by_project: true` plus `license.group_by_project: false`, and the
opposite combination works the same way.

| `group_by_project` | License | Security |
| --- | --- | --- |
| `true` | 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 |
| `false` | 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` /
`single_summary_template` for license, `group_summary_template` /
`summary_template` for security.

Grouping only affects tickets created from now on; findings already recorded in
the state file stay attached to their original ticket.

#### `--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_project: false` on *every* workflow the run selected, ignoring what the
config says. Assuming both settings are `true`:

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

```yaml
license:
  group_by_project: false   # individual license tickets
security:
  group_by_project: true    # one grouped security ticket per project version
```

## Run

```powershell
# 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_project` 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

```powershell
# 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:

```yaml
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

```powershell
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. `--unlink` cleans 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

```powershell
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

```powershell
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

```powershell
# Individual tickets for this run only, whatever group_by_project says
uv run bd-jira-sync ABC-123 --license --single --send
```

Overrides `group_by_project` 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_project` flags in `config.yaml`.

### `-p`, `--project NAME/VERSION` — narrow the run

```powershell
# 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

```powershell
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](#undoing-a-wrong-master-ticket).

### `-c`, `--config PATH` — a different configuration

```powershell
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

```powershell
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

```powershell
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

```powershell
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`

```powershell
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
  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]
```

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, 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. |
| `new` | Finding nobody reported yet; it goes into a ticket below. |
| `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:

```powershell
# 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_type` are removed. Any other link on
  the master ticket is reported as `kept` and left in place.
- The tickets themselves are never closed, deleted or edited — only the link.
- Every removed link is also taken out of `master_tickets` in the state file, so
  the next normal run re-links those tickets to the right master ticket instead
  of reporting them as `already 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.

```yaml
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"
        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_project`: 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 differ on purpose. A license risk is version-specific, so a
component whose version, license or risk level changes is reported again — that
is a genuinely new thing to review. A vulnerability is not: a component version
bump that does not fix the CVE is the same problem, so the security key stays
`project|version|vulnerability|package` and the existing ticket keeps covering it.

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

### 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_tickets` and 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_tickets` lack the master, and
  reports the rest as `already linked`.
- Any exception aborts the run, but the state file is still written (the CLI
  saves it in a `finally` block), 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)
  state.py           the state file
  engine.py          dedup, 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):

```yaml
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.

```powershell
$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:

```bash
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:

```powershell
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

```powershell
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:

```powershell
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](LICENSE) for the full text.
