@groupby/ai-dev 0.5.16 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/teams/firstspirit-caas/mcp/jira-tools.py +2122 -0
  3. package/teams/firstspirit-caas/mcp/test_bamboo_artifacts.py +315 -0
  4. package/teams/firstspirit-caas/mcp/test_jira_links.py +192 -0
  5. package/teams/firstspirit-caas/mcp/test_update_jira_ticket.py +161 -0
  6. package/teams/firstspirit-caas/resources/mcp-setup.md +57 -0
  7. package/teams/firstspirit-caas/skills/chronicle/SKILL.md +105 -0
  8. package/teams/firstspirit-caas/skills/create-jira-ticket/SKILL.md +183 -0
  9. package/teams/firstspirit-caas/skills/get-jira-ticket/SKILL.md +125 -0
  10. package/teams/firstspirit-caas/skills/handle-red-cve-plan/SKILL.md +316 -0
  11. package/teams/firstspirit-caas/skills/handle-red-cve-plan/references/suppression.md +79 -0
  12. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/gen_suppression.py +116 -0
  13. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_json_report.py +183 -0
  14. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_report.py +255 -0
  15. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_gen_suppression.py +171 -0
  16. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_json_report.py +244 -0
  17. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_report.py +464 -0
  18. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.html +39 -0
  19. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.json +76 -0
  20. package/teams/firstspirit-caas/skills/handle-weekly-cve-plans/SKILL.md +238 -0
  21. package/teams/firstspirit-caas/skills/retrospect/SKILL.md +94 -0
  22. package/teams/firstspirit-caas/skills/retrospect/extract_prompts.py +392 -0
  23. package/teams/firstspirit-caas/skills/review/SKILL.md +131 -0
  24. package/teams/firstspirit-caas/skills/review-finalize/SKILL.md +103 -0
  25. package/teams/firstspirit-caas/skills/specify/SKILL.md +239 -0
  26. package/teams/firstspirit-caas/skills/summarise-for-jira-comment/SKILL.md +162 -0
  27. package/teams/firstspirit-caas/skills/write-releasenotes/SKILL.md +177 -0
@@ -0,0 +1,316 @@
1
+ ---
2
+ name: handle-red-cve-plan
3
+ description: 'Use when the user gives a red/failing Bamboo build URL and asks to fix, remediate, or triage its dependency CVEs — phrases like "fix the CVEs in this Bamboo build", "handle-cve for <bamboo url>", "remediate the dependency-check findings from CI". Distinct from handle-cve: this skill is driven by a Bamboo build URL and parses the published dependency-check report artifact (the JSON report when present, falling back to the HTML report), not a user-supplied local file.'
4
+ ---
5
+
6
+ # handle-red-cve-plan
7
+
8
+ Input: a red Bamboo plan/build URL. Output: **normally a Bitbucket PR** that fixes or
9
+ suppresses every active CVSS >= 7.0 finding — **except when the remediation is suppressions
10
+ only**, in which case the output is a single **unpushed local commit on the default branch**
11
+ for the invoker to review (no branch, no PR; see step 7). Runs fully autonomously — no
12
+ confirmation stops. Operates on the current working directory, assumed to be the checked-out
13
+ target repo.
14
+
15
+ **Parameters:**
16
+ - `ticket` (**required**) — the JIRA key that tracks this remediation (e.g. `PX-1234`). Drives
17
+ the fix branch (step 4): the skill reuses an existing branch for this ticket in the repo, or
18
+ creates `bugfix/<ticket>-handle-weekly-cves`. **Invoked without a ticket → hard stop and ask
19
+ for one** (see Hard stops). The orchestrator `handle-weekly-cve-plans` always supplies it.
20
+ - `useSonatype` (optional flag) — when the user specifies it (e.g. "fix the CVEs …
21
+ useSonatype"), the verify step (step 6) runs `./gradlew dependencyCheckAggregate
22
+ -DuseSonatype`; when omitted, it runs `./gradlew dependencyCheckAggregate` without the
23
+ flag. This mirrors how the Bamboo plan itself was configured (the weekly Sonatype-OSS
24
+ build passes it) so local verification matches CI.
25
+
26
+ ## When NOT to use
27
+
28
+ - A local JSON dependency-check report is already in hand and no PR/push is wanted — use
29
+ `handle-cve` (read-only, never invokes Gradle or Git).
30
+ - Single ad-hoc CVE with no scanner/Bamboo context.
31
+
32
+ ## Constraints (non-negotiable)
33
+
34
+ - Act only on **active** findings with **CVSS >= 7.0** (exclude the report's suppressed
35
+ vulnerabilities — both `parse_json_report.py` and `parse_report.py` already do this: the JSON
36
+ parser reads only the `vulnerabilities` array and never `suppressedVulnerabilities`, the HTML
37
+ parser keeps only entries that still carry a suppress button).
38
+ - **Only update an existing dependency declaration.** Never add a new entry for a
39
+ transitive dependency.
40
+ - Suppression file is **auto-discovered, never created**. If suppression is needed and no
41
+ file is found → **STOP and report**.
42
+ - Each CVE is suppressed **at most once** across the whole file — one block per CVE, listing
43
+ every affected `<packageUrl>`. Never emit the same `<cve>` in two blocks.
44
+ - Suppression type: **when in doubt, temporary** (default 2 months). Permanent only
45
+ for a confident false positive across all affected artifacts; mixed fate → temporary.
46
+ - **Suppressions-only remediation** (the only change is the suppression file, no bumps) **on a
47
+ freshly-created step-4 branch** → **no branch and no PR**: a single commit directly on the
48
+ latest default branch, **never pushed automatically** and **with no ticket number in the
49
+ message**, handed back to the invoker as the skill's output (see step 7, Path A). If step 4
50
+ reused an existing ticket branch, suppressions-only takes Path B instead (never bypass an
51
+ existing branch).
52
+
53
+ ## Workflow
54
+
55
+ ### 1. Resolve the build
56
+ Parse the URL to a plan/build key. If it ends in `/latest` or is a bare plan key,
57
+ normalize to `{PLAN}-latest` and call `mcp__plugin_planetexpress_jira-tools__get_bamboo_build`
58
+ to get the concrete build number. Confirm the build is Failed/red before proceeding. Auth or
59
+ resolution failure → hard stop.
60
+
61
+ ### 2. Fetch the report
62
+ The report artifact's **name and layout vary by plan** — do not hardcode either. Some plans
63
+ publish it as `owasp dependency check report` with the report files under `reports/`; others
64
+ (e.g. haupia / `SMASE-WDCS`) publish `Report DependencyCheckAggregate` with the files at the
65
+ artifact root. Resolve by the **file**, not the artifact name.
66
+
67
+ **Prefer the JSON report; fall back to HTML.** The JSON (`dependency-check-report.json`) is the
68
+ richer, structured source and is parsed without regex; the HTML is the backup for plans whose
69
+ CI run has not yet started publishing JSON.
70
+
71
+ - `mcp__plugin_planetexpress_jira-tools__list_bamboo_artifacts <build_key>` → across all
72
+ directory artifacts, look for a file whose relative name ends in
73
+ `dependency-check-report.json`. If present, take its `path`; that is the report to fetch and
74
+ the parse in step 3 is JSON. If **no** `.json` is present, fall back to the file whose name
75
+ ends in `dependency-check-report.html` (prefer it over the `.xml`/`.csv`/`.sarif` siblings)
76
+ and the step-3 parse is HTML.
77
+ - `mcp__plugin_planetexpress_jira-tools__get_bamboo_artifact` with the `build_key` positional
78
+ argument and `href=<that path>`. It returns as `localPath`. If **neither** a
79
+ `dependency-check-report.json` nor a `dependency-check-report.html` file is present in any
80
+ artifact → hard stop (report the artifacts found).
81
+
82
+ ### 3. Parse
83
+ Pick the parser to match the file fetched in step 2:
84
+ - **JSON** → `python "<plugin-root>/scripts/parse_json_report.py" <localPath> --out /tmp/cve-worklist.json`
85
+ - **HTML** → `python "<plugin-root>/scripts/parse_report.py" <localPath> --out /tmp/cve-worklist.json`
86
+
87
+ Both emit the **same worklist schema** (`findings` + `unparsed`), so the rest of the workflow
88
+ is identical regardless of source. Read the worklist. `findings` empty **and** `unparsed` empty
89
+ → nothing to do; report and stop. Non-empty `unparsed` → hard stop (see Hard stops below)
90
+ regardless of `findings`.
91
+
92
+ Each finding carries the report's own advisory data — use it, don't discard it:
93
+ - `description` — the advisory prose; often names the fixed release and the affected range
94
+ in words (e.g. "5.4.2 and earlier").
95
+ - `affectedVersions` — the "Vulnerable Software & Versions" entries, e.g.
96
+ `cpe:...:httpcomponents_core:* versions up to (including) 5.4.2` — the affected boundary.
97
+ - `references` — advisory URLs (NVD, vendor lists, and the Sonatype guide page, which
98
+ states the exact fixed version). Fetch these only if the description/affectedVersions
99
+ don't already answer the fixed-version question.
100
+ - `includedBy` — **JSON parse only** (the HTML parser cannot supply it): the package URLs
101
+ that pull this dependency in. For a transitive finding this is the ancestor chain step 5.2
102
+ needs, so when it is present the `dependencyInsight` call there becomes a confirmation of a
103
+ known ancestor rather than a discovery. Absent (empty) on HTML-sourced worklists — fall back
104
+ to `dependencyInsight` as before.
105
+
106
+ ### 4. Prepare the fix branch (ticket-driven)
107
+ Do this **before** editing anything — every bump, suppression, and the verify happen on this
108
+ branch. The branch is chosen from the `ticket` parameter: reuse the ticket's existing branch if
109
+ one is already in the repo, otherwise create a fresh one off the **latest default branch**.
110
+
111
+ - **Determine the default branch:** do not assume `master`. Derive it from the remote:
112
+ `git symbolic-ref refs/remotes/origin/HEAD` (or `git remote show origin` if unset). Call it
113
+ `<default>`.
114
+ - `git fetch origin`, then look for an **existing branch for this ticket** — local or remote —
115
+ matching the ticket key: `git branch -a --list "*<ticket>*"` (e.g. `*PX-1234*`). Resolve by
116
+ how many match:
117
+ - **Exactly one** → **reuse it as-is.** `git switch <branch>` (tracking the remote if it is
118
+ remote-only). Do **not** reset it onto `<default>` — commit on top of whatever work it
119
+ already carries. Record that the branch was **reused** (step 7 needs this).
120
+ - **None** → **create it off the latest tip:**
121
+ `git switch -c bugfix/<ticket>-handle-weekly-cves origin/<default>`. Record that the branch
122
+ was **newly created**.
123
+ - **More than one** → **hard stop** and report the candidates; do not guess which is the
124
+ ticket's branch.
125
+ - If the working tree has unrelated uncommitted changes that would be carried across or block
126
+ the switch, STOP and report rather than discarding them.
127
+ - This branch is the working base for fixing + verifying. Suppressions-only remediation goes to
128
+ Path A **only when this branch was newly created** — see step 7.
129
+
130
+ ### 5. Fix each finding (grouped by dependency)
131
+ Detect the repo's declaration mechanism: version catalog (`gradle/libs.versions.toml`),
132
+ direct `dependencies {}`, `constraints {}`, or `platform()`/BOM. **A bump is the preferred
133
+ fix — but only when the latest available version actually clears the CVE.** Get the affected
134
+ range / fixed version **from the report first** (the finding's `affectedVersions`,
135
+ `description`, and `references` from step 3) — that is the first place to look; **web-search
136
+ only when the report doesn't state it.** Then check the latest available version against that
137
+ range: if the latest is outside the affected range (i.e. a fixed release exists), bump to it;
138
+ if the latest version *still contains* the vulnerability, a bump is pointless — do not bump,
139
+ fall through to suppression. Suppression is the last resort. Try the fixes in this order and
140
+ stop at the first that works:
141
+
142
+ 1. **Direct dependency with an existing entry** — if the vulnerable dependency (or its GAV)
143
+ is itself declared and the **latest available version clears the CVE**, bump that entry to
144
+ the latest version. (Take the latest, not merely the lowest fixed release — but only if
145
+ the latest is in fact a fixed, non-affected version.)
146
+ 2. **Transitive dependency → bump the direct ancestor that pulls it in.** A vulnerable dep
147
+ with no direct declaration is often fixable without adding a new entry: find the
148
+ direct/parent dependency that brings it in and bump *that* existing declaration to its
149
+ latest available version — **provided that bump resolves the transitive dep to a version
150
+ that clears the CVE.**
151
+ - Identify the ancestor from the OWASP report's "included by" / `includedBy` data and
152
+ confirm with `./gradlew dependencyInsight --dependency <group:artifact>` (add
153
+ `--configuration <name>` if needed). This shows the resolution path from a declared
154
+ dependency down to the vulnerable one.
155
+ - Bump the declared ancestor, then re-run `dependencyInsight` (or rely on step 6's
156
+ verify) to confirm the transitive version is now a CVE-clearing one. If the ancestor's
157
+ latest still pulls the vulnerable version, this path does not clear it — fall through.
158
+ 3. **Existing constraint / BOM / platform entry** — if the repo already pins the version via
159
+ a `constraints {}` block or a `platform()`/BOM, and a CVE-clearing version exists, update
160
+ that existing pin to the latest available (clearing) version. (Only update an entry that
161
+ already exists — see the hard rule below.)
162
+ 4. **Suppress — only when no existing declaration can be bumped to a CVE-clearing version**
163
+ (nothing to update, no released version clears the CVE, or the only available fix would
164
+ require a new entry). A **false positive** is also fixed by a bump when the latest version
165
+ makes the finding go away; suppress a false positive only when no clearing bump exists.
166
+ - Discover the suppression file per `references/suppression.md`. Not found → STOP.
167
+ - **One block per CVE.** Before writing, check whether the CVE is already suppressed (in
168
+ the file or in a block you've added this run). If so, add the new affected `<packageUrl>`
169
+ to that existing block instead of creating a second one — never emit the same `<cve>`
170
+ twice. The same CVE across several artifacts (e.g. `httpcore` 4.x and `httpcore5`) is one
171
+ block with repeated `--pkgurl`.
172
+ - **When in doubt between permanent and temporary, choose temporary** (default, 2 months).
173
+ Pass `--permanent` only for a confident false positive across *all* affected artifacts;
174
+ if a CVE is FP for one artifact but real-yet-unfixable for another, the single block is
175
+ temporary.
176
+ - Generate the block with `python "<plugin-root>/scripts/gen_suppression.py"` (repeat
177
+ `--pkgurl` for each affected artifact; `--permanent` only per the rule above, else the
178
+ temporary default) and insert it into the file's `<suppressions>` root.
179
+
180
+ **Hard rule:** never *add* a brand-new direct declaration (or a new constraint) for a
181
+ transitive dependency just to pin it. Only *update entries that already exist*. If the only
182
+ way to pin the fixed version would be a new entry, that path is unavailable — fall through to
183
+ suppression (step 5.4).
184
+
185
+ After any bump, remove any now-obsolete `<suppress>` entry for a CVE no longer reported.
186
+
187
+ **Compile check after bumps.** A latest-version bump can break the build. After applying all
188
+ bumps (and before the step-6 verify), confirm the project still **compiles — main and test
189
+ sources**: `./gradlew classes testClasses` (this compiles both without running tests). Fix
190
+ any compilation errors the bump introduced (API changes, moved packages, renamed methods).
191
+ **Do not run the tests** — CI runs them later; you only guarantee it compiles. If compilation
192
+ cannot be made to pass, STOP and report (hard stop) rather than pushing a broken build.
193
+
194
+ Read `references/suppression.md` before writing any suppression.
195
+
196
+ ### 6. Verify
197
+ Run `./gradlew dependencyCheckAggregate` in the repo — append `-DuseSonatype` if the
198
+ `useSonatype` parameter was specified (`./gradlew dependencyCheckAggregate -DuseSonatype`),
199
+ otherwise run it without the flag. Locate the freshly written report under `build/reports/`
200
+ and re-parse it with the matching parser: **prefer `dependency-check-report.json` via
201
+ `parse_json_report.py`**, falling back to `dependency-check-report.html` via `parse_report.py`
202
+ only if the project does not emit JSON locally. Confirm **0** findings
203
+ >= 7.0 remain **and** `unparsed` is empty. If residuals remain, loop back to step 5. Non-empty
204
+ `unparsed` at this step is also a hard stop — never treat it as convergence. If it cannot
205
+ converge or Gradle fails → STOP and report; do not push. Dependency-check's own validation of
206
+ the suppression file (it fails the build on malformed suppression XML) is the guardrail
207
+ against a corrupt suppression file reaching this step.
208
+
209
+ ### 7. Commit — fork on what changed
210
+
211
+ First decide which path applies by inspecting exactly what step 5 changed:
212
+
213
+ **Path A — suppressions-only.** The *only* changed file is the suppression file (no
214
+ dependency-declaration edits, no code, no other files at all) **and the step-4 branch was
215
+ newly created** (not a reused ticket branch). Then **do not** open a branch or PR — produce a
216
+ single local commit on the latest default branch and hand it back:
217
+
218
+ - Move onto the latest default branch, carrying the suppression edit: `git switch <default>`,
219
+ then `git merge --ff-only origin/<default>` to bring it to the latest tip. If local
220
+ `<default>` cannot fast-forward (it has diverged), STOP and report — do not commit on a
221
+ stale or diverged base.
222
+ - The freshly-created fix branch from step 4 has no commits; delete it:
223
+ `git branch -D bugfix/<ticket>-handle-weekly-cves` (use the actual branch name).
224
+ - Stage **only** the suppression file (exact path; never `git add -A`/`-u`).
225
+ - Commit with a message describing the suppression(s) that **contains NO ticket number** —
226
+ no `PX-…`, `HP-…`, or any `[A-Z]+-\d+` token anywhere in subject or body. For example:
227
+ `chore(security): suppress <CVE-ids> dependency-check findings`.
228
+ - **Do not push.** Skip steps 8 (release notes) and 9 (PR).
229
+ - **Output of the skill = this commit.** Show the invoker the commit SHA, its message, and the
230
+ suppression diff, and stop. Pushing is the invoker's decision.
231
+
232
+ **Path B — anything else** (any dependency bump, any change beyond the suppression file, **or**
233
+ suppressions-only on a **reused** ticket branch — never delete or bypass an existing ticket
234
+ branch). The fix branch already exists from step 4 — commit onto it:
235
+
236
+ - Stage **only** the specific files edited in step 5 — the dependency-declaration file(s)
237
+ (e.g. `gradle/libs.versions.toml`, `build.gradle[.kts]`) and, if touched, the suppression
238
+ file, by exact path: `git add <dependency-file-path> <suppression-file-path>`. **Never**
239
+ `git add -A` or `git add -u` — this skill pushes and opens a PR autonomously, and sweeping
240
+ unrelated working-tree changes into that commit is not acceptable.
241
+ - Commit: `git commit -m "fix(<repo>): remediate CVSS>=7 dependency-check findings"`. Do
242
+ **not** push yet — release notes (step 8) are derived from committed changes and get
243
+ **amended into this same commit**, so it must not be pushed until step 8 completes.
244
+
245
+ ### 8. Release notes
246
+ *(Path B only — skipped entirely in the suppressions-only Path A, which ends at step 7.)*
247
+
248
+ Invoke the `write-releasenotes` skill (via the Skill tool) with **pre-approval** so it writes
249
+ without a confirmation gate — this skill runs autonomously (e.g. "write and apply the release
250
+ notes for this CVE remediation, no need to confirm"). `write-releasenotes` reads the **committed**
251
+ changes on the branch (hence step 7 commits first), derives the ticket key/type **from the
252
+ branch name** (which carries `<ticket>`), scaffolds per-language `.adoc` entries via the
253
+ project's `create*` Gradle task when missing, and fills them in house style.
254
+
255
+ - If `write-releasenotes` reports **not applicable** (no releasenotes subproject and no `create*`
256
+ Gradle tasks), that is a notice, not an error — proceed to step 9 and put the CVE summary
257
+ in the PR body instead.
258
+ - If it wrote or updated notes: stage **only** the generated/updated `.adoc` files and any
259
+ version file the `create*` task bumped, by exact path (same never-`git add -A/-u` rule as
260
+ step 7), then **amend them into the step-7 commit**: `git commit --amend --no-edit`. This
261
+ keeps the remediation and its release notes as a single commit. (Safe because the branch
262
+ has not been pushed yet — no force-push needed.)
263
+
264
+ Then push the branch (single commit).
265
+
266
+ ### 9. PR
267
+ *(Path B only — the suppressions-only Path A produces a local commit and no PR.)*
268
+
269
+ The Bitbucket project/repo slugs are derived from the repo's `origin` remote URL (not asked
270
+ of the user). If no open PR exists for the branch, create one with
271
+ `mcp__plugin_planetexpress_jira-tools__create_bitbucket_pr`, targeting the default branch
272
+ (`<default>` from step 4). Body: a per-CVE summary — CVE id, score, and whether it was bumped
273
+ (old→new version) or suppressed (temp until <date> / permanent, with reason).
274
+
275
+ ## Hard stops (abort, report, never push)
276
+
277
+ | Situation | Behaviour |
278
+ |---|---|
279
+ | Invoked without a `ticket` | Report and ask for the JIRA key before doing anything |
280
+ | More than one existing branch matches the ticket key (step 4) | Report the candidates and stop; do not guess the branch |
281
+ | Bamboo auth / URL resolution failure | Report and stop before fetching anything |
282
+ | Neither a `dependency-check-report.json` nor `.html` file in any build artifact (step 2) | Report the artifacts found and stop |
283
+ | Suppression needed but no suppression file found | Report and stop; never create one |
284
+ | Bumps break compilation (`classes`/`testClasses`) and can't be fixed | Report and stop; do not push a build that doesn't compile |
285
+ | Gradle verify fails or residual >= 7.0 findings remain | Report and stop; do not push |
286
+ | Either parser returns any non-empty `unparsed` entries (initial parse or verify) | Report and stop; never silently proceed as if 0 findings |
287
+ | Suppressions-only path (7A) but local default branch can't fast-forward to `origin/<default>` | Report and stop; don't commit on a diverged/stale base |
288
+
289
+ ## Known limitations
290
+
291
+ - Every active finding is classified by **severity** — a numeric CVSS score, or, for scoreless
292
+ advisories (common for JS/NPM), the qualitative band. Both parsers apply the identical rule
293
+ (`parse_json_report.py` from the typed `cvssv2/v3/v4` and `severity` fields — taking the worst
294
+ numeric across the CVSS versions present; `parse_report.py` by scraping the HTML). Then:
295
+ - **below HIGH** (score < 7.0, or a low/medium/none band) → **excluded** — out of scope, does
296
+ not redden the build. A medium/low unscored advisory is *not* a hard stop.
297
+ - **at/above HIGH & a CVE id** → a normal `findings` entry (a scoreless HIGH/CRITICAL carries
298
+ `score: null` with the band in `severity`).
299
+ - **at/above HIGH & no CVE id** (e.g. a HIGH/CRITICAL `GHSA-*`) → `unparsed`. This skill only
300
+ remediates CVEs, so it cannot fix these — routing them to `unparsed` (a hard stop) prevents
301
+ converging to "0 findings" on a build that is red because of one. Such a build stops for
302
+ human triage rather than being reported as fixed. Identifier (`cpe`) buttons, present on
303
+ every dependency, are always ignored.
304
+ - The `unparsed` bucket is a fail-safe signal, not noise: an at/above-HIGH non-CVE finding, or
305
+ an active finding whose severity could be determined **neither** numerically **nor** by band.
306
+ Treat any non-empty `unparsed` as a hard stop, identical in severity to the other hard stops
307
+ above — never converge or report success while it is non-empty. (`unparsed` is deduplicated by
308
+ advisory id + file, so the same finding on the same artifact never appears twice.)
309
+ - Malformed suppression XML is guarded at the verify step: `dependencyCheckAggregate` itself
310
+ validates the suppression file and fails the build if it's invalid.
311
+
312
+ ## Out of scope
313
+
314
+ - Creating a suppression file that doesn't exist.
315
+ - Adding new declarations for transitive-only dependencies (suppress instead).
316
+ - Merging the PR — open it and stop; merge is the user's call.
@@ -0,0 +1,79 @@
1
+ # Suppression policy & syntax (dependency-check)
2
+
3
+ ## Finding the suppression file (auto-discover, never create)
4
+ 1. Read the repo's Gradle config for the configured path:
5
+ `dependencyCheck { suppressionFile "..." }` or `suppressionFiles = [...]`
6
+ (check `build.gradle`, `build.gradle.kts`, and any `gradle/*.gradle`).
7
+ 2. If not configured there, glob `*suppress*.xml` — repo root first, then repo-wide.
8
+ 3. **If no file is found, STOP and report.** Do not create one. Do not suppress.
9
+
10
+ ## When to suppress vs bump
11
+ - Prefer a version bump of an **existing** declaration. Never add a new entry for a
12
+ transitive dependency.
13
+ - Suppress only when: no existing declaration to bump, no patched version exists, or the
14
+ finding is a false positive.
15
+
16
+ ## One suppression per CVE — never suppress the same CVE twice
17
+ - A given CVE id must appear in **at most one** `<suppress>` block across the entire file.
18
+ Before writing a suppression, scan the file (and any blocks you're adding this run) for the
19
+ CVE. If it's already covered, **do not add a second block** — extend the existing one.
20
+ - The same CVE often affects **several declared artifacts** (e.g. `httpcore` 4.x and
21
+ `httpcore5` 5.x). Suppress it **once**, in a single block that lists every affected
22
+ `<packageUrl>` (dependency-check allows unbounded `<packageUrl>` in one `<suppress>`). Do
23
+ **not** emit one block per artifact for the same CVE.
24
+ - `gen_suppression.py` takes repeated `--pkgurl` for exactly this — one block, many packages.
25
+
26
+ ## Permanent vs temporary
27
+ - **When in doubt, choose temporary.** A temporary suppression expires and re-flags; a wrong
28
+ permanent one hides a real CVE forever. Only go permanent when it is *confidently* a pure
29
+ false positive across **all** affected artifacts.
30
+ - If a CVE is a false positive for one artifact but a real-yet-unfixable finding for another
31
+ (mixed fate) → the single block is **temporary** (the tie-break rule above).
32
+ - **False positive (confident, all artifacts) → permanent**: omit `until`. Notes state
33
+ concisely why it does not apply (e.g. CPE mismatch, unreachable code path).
34
+ - **Otherwise → temporary**: `until="<today + 2 months>Z"` (the default; 2 months).
35
+ - One `<suppress>` block may cover multiple `<cve>` entries under a single notes summary.
36
+
37
+ ## Notes content — concise technical reason only
38
+ The `<notes>` text states **why the CVE is suppressed** and nothing else. Keep it to the
39
+ technical fact (CPE mismatch, unreachable code path, no patched release yet, patch not yet
40
+ scheduled). Then stop.
41
+
42
+ - **Never mention a JIRA ticket** — no `PX-…`, `HP-…`, or any `[A-Z]+-\d+` key. The `ticket`
43
+ parameter drives the fix branch; it does **not** track the suppression, so "Tracked by PX-…"
44
+ is simply false.
45
+ - **No boilerplate.** Do not append filler like "Expires; re-flags if unresolved" or "Added to
46
+ unblock builds" — the `until` attribute already encodes expiry; the sentence adds nothing.
47
+
48
+ ## Syntax
49
+ ```xml
50
+ <suppress until="2026-09-13Z">
51
+ <notes><![CDATA[No patched 4.x release; HTTP/2 HPACK decoder path does not exist in the 4.x line.]]></notes>
52
+ <packageUrl regex="true">pkg:maven/org\.apache\.httpcomponents/httpcore@.*</packageUrl>
53
+ <packageUrl regex="true">pkg:maven/org\.apache\.httpcomponents\.core5/httpcore5@.*</packageUrl>
54
+ <cve>CVE-2026-54399</cve>
55
+ <cve>CVE-2026-54428</cve>
56
+ </suppress>
57
+ ```
58
+
59
+ Generate blocks with `scripts/gen_suppression.py` — it handles the purl→regex escaping and
60
+ the today+2-months default date, and takes **repeated `--pkgurl`** to list every affected
61
+ artifact in one block:
62
+
63
+ ```
64
+ python "<plugin-root>/scripts/gen_suppression.py" \
65
+ --pkgurl pkg:maven/org.apache.httpcomponents/httpcore@4.4.16 \
66
+ --pkgurl pkg:maven/org.apache.httpcomponents.core5/httpcore5@5.4.2 \
67
+ --cve CVE-2026-54399 --cve CVE-2026-54428 --notes "..."
68
+ ```
69
+
70
+ Insert new blocks inside the root `<suppressions>` element of the discovered file. The script
71
+ emits `<suppress>` at column 0 with 4-space child indent; when inserting, **re-indent the whole
72
+ block to match the surrounding blocks** (typically `<suppress>` one level in, children one level
73
+ deeper) so the file stays consistent — dependency-check ignores the whitespace, but a mismatched
74
+ block is noise in review. Omit `--until` for the temporary default (2 months); pass `--permanent`
75
+ only for a confident, all-artifacts false positive.
76
+
77
+ ## Removing obsolete suppressions
78
+ After a successful bump, if an existing `<suppress>` entry now targets a CVE that no
79
+ longer appears in the report, remove that entry.
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """Generate an OWASP Dependency-Check <suppress> block for a finding.
3
+
4
+ Temporary suppression: pass --until YYYY-MM-DD (or let the skill compute
5
+ today + 2 months). Permanent (false positive): omit --until.
6
+ """
7
+ import argparse
8
+ import datetime
9
+ import re
10
+ import sys
11
+ import textwrap
12
+
13
+ NOTE_WIDTH = 100
14
+
15
+
16
+ def pkgurl_to_regex(package_url: str) -> str:
17
+ """Escape a Maven purl and match any version: `...@4.4.16` -> `...@.*`.
18
+
19
+ re.escape() does not escape `/` or `:` on Python >= 3.7, so no further
20
+ unescaping of those characters is needed after re.escape().
21
+ """
22
+ base = package_url.split("@", 1)[0]
23
+ return re.escape(base) + "@.*"
24
+
25
+
26
+ def default_until(today: datetime.date, months: int = 2) -> str:
27
+ month = today.month - 1 + months
28
+ year = today.year + month // 12
29
+ month = month % 12 + 1
30
+ day = min(today.day, 28)
31
+ return f"{year:04d}-{month:02d}-{day:02d}"
32
+
33
+
34
+ def wrap_notes(notes: str) -> list:
35
+ """Wrap notes for readability inside the CDATA. Breaks on whitespace only
36
+ (break_long_words / break_on_hyphens off) so CVE ids and version strings
37
+ like `CVE-2026-54428` or `5.5-beta1` are never split. Caller-supplied
38
+ newlines are honoured as paragraph boundaries. Returns physical lines."""
39
+ lines = []
40
+ for para in notes.split("\n"):
41
+ if not para.strip():
42
+ lines.append("")
43
+ continue
44
+ lines.extend(textwrap.wrap(
45
+ para, width=NOTE_WIDTH,
46
+ break_long_words=False, break_on_hyphens=False))
47
+ return lines
48
+
49
+
50
+ def suppress_block(package_urls, cves: list, notes: str, until) -> str:
51
+ """One <suppress> block. `package_urls` is a single purl or a list — the
52
+ same CVE affecting several declared artifacts is suppressed ONCE, in one
53
+ block listing every affected packageUrl (dependency-check allows unbounded
54
+ <packageUrl>). Never emit a separate block per artifact for the same CVE."""
55
+ if isinstance(package_urls, str):
56
+ package_urls = [package_urls]
57
+ open_tag = f'<suppress until="{until}Z">' if until else "<suppress>"
58
+ # One level of child indent, matching the dependency-check file's 4-space
59
+ # convention. The block is emitted with <suppress> at column 0; the skill
60
+ # re-indents the whole block by the file's base indent on insertion.
61
+ ind = " "
62
+ pkg_lines = "\n".join(
63
+ f'{ind}<packageUrl regex="true">{pkgurl_to_regex(p)}</packageUrl>'
64
+ for p in package_urls
65
+ )
66
+ cve_lines = "\n".join(f"{ind}<cve>{c}</cve>" for c in cves)
67
+ # A literal "]]>" inside notes would close the CDATA section early and
68
+ # corrupt the XML. Split it across two CDATA sections instead.
69
+ safe_notes = notes.replace("]]>", "]]]]><![CDATA[>")
70
+ note_lines = wrap_notes(safe_notes)
71
+ # Short notes stay on one line; anything that wraps (or carries explicit
72
+ # line breaks) is laid out on its own indented lines for readability.
73
+ if len(note_lines) <= 1:
74
+ inner = note_lines[0] if note_lines else ""
75
+ notes_el = f"{ind}<notes><![CDATA[{inner}]]></notes>"
76
+ else:
77
+ body = "\n".join(f"{ind}{ln}".rstrip() for ln in note_lines)
78
+ notes_el = f"{ind}<notes><![CDATA[\n{body}\n{ind}]]></notes>"
79
+ return (
80
+ f"{open_tag}\n"
81
+ f"{notes_el}\n"
82
+ f"{pkg_lines}\n"
83
+ f"{cve_lines}\n"
84
+ f"</suppress>"
85
+ )
86
+
87
+
88
+ def main(argv):
89
+ ap = argparse.ArgumentParser()
90
+ ap.add_argument("--pkgurl", action="append", required=True,
91
+ help="Affected package purl; repeat to cover the same CVE across "
92
+ "multiple declared artifacts in one block")
93
+ ap.add_argument("--cve", action="append", required=True)
94
+ ap.add_argument("--notes", required=True)
95
+ group = ap.add_mutually_exclusive_group()
96
+ group.add_argument("--until", help="YYYY-MM-DD; omit for the default (today + 2 months)")
97
+ group.add_argument("--permanent", action="store_true",
98
+ help="False-positive only: permanent suppression, no 'until'")
99
+ ns = ap.parse_args(argv)
100
+
101
+ # Policy: false positive -> permanent (no until); everything else ->
102
+ # temporary (today + 2 months). Omitting --until must NEVER silently
103
+ # produce a permanent suppression.
104
+ if ns.permanent:
105
+ until = None
106
+ elif ns.until:
107
+ until = ns.until
108
+ else:
109
+ until = default_until(datetime.date.today())
110
+
111
+ print(suppress_block(ns.pkgurl, ns.cve, ns.notes, until))
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main(sys.argv[1:]))