@conterra/vuln-scan 0.0.36 → 0.1.0

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.
package/README.md CHANGED
@@ -511,9 +511,9 @@ JIRA_API_TOKEN=<token> # Jira API token (https://id.atlassian.com/manage-p
511
511
 
512
512
  NOTE: These variables can also be placed in a `.env` file in the working directory.
513
513
 
514
- ### Configuration – `jira.json`
514
+ ### Configuration – `jira-conf.json`
515
515
 
516
- Create a `jira.json` file (path is configurable) that defines the Jira instance and the routing rules:
516
+ Create a `jira-conf.json` file (path is configurable) that defines the Jira instance and the routing rules:
517
517
 
518
518
  ```json
519
519
  {
@@ -523,9 +523,18 @@ Create a `jira.json` file (path is configurable) that defines the Jira instance
523
523
  {
524
524
  "jiraProject": "PLATFORM",
525
525
  "issueType": "CVE",
526
- "issueLabels": ["security", "cve"],
527
- "boardId": 12,
528
- "assignToActiveSprint": true,
526
+ "issueLabels": ["security"],
527
+ "addToBoard": [
528
+ {
529
+ "sprintBoardId": 10,
530
+ "issueLabels": ["team-a"],
531
+ "matchProjects": ["mapapps@*"]
532
+ },
533
+ {
534
+ "issueLabels": ["team-b"],
535
+ "matchProjects": ["smartfinder@*"]
536
+ }
537
+ ],
529
538
  "reportProjects": [
530
539
  "mapapps@*",
531
540
  "smartfinder@4.*"
@@ -540,18 +549,27 @@ Create a `jira.json` file (path is configurable) that defines the Jira instance
540
549
  }
541
550
  ```
542
551
 
543
- | Field | Required | Description |
544
- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
545
- | `jiraUrl` | ✓ | Base URL of the Jira instance |
546
- | `issues` | ✓ | List of routing rules |
547
- | `issues[].jiraProject` | ✓ | Jira project key, e.g. `PLATFORM` |
548
- | `issues[].issueType` | ✓ | Jira issue type name, e.g. `Bug`, `CVE` |
549
- | `issues[].reportProjects` | ✓ | Glob patterns (`*` wildcard) for scan project identifiers (`name@version`) |
550
- | `issues[].issueLabels` | – | Labels to attach to every created issue |
551
- | `issues[].boardId` | – | Scrum board ID needed to resolve the active sprint |
552
- | `issues[].assignToActiveSprint` | – | When `true` and `boardId` is set, the issue is added to the currently active sprint. If no sprint is found or the API call fails the issue is still created without a sprint assignment. |
552
+ | Field | Required | Description |
553
+ | ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
554
+ | `jiraUrl` | ✓ | Base URL of the Jira instance |
555
+ | `issues` | ✓ | List of routing rules |
556
+ | `issues[].jiraProject` | ✓ | Jira project key, e.g. `PLATFORM` |
557
+ | `issues[].issueType` | ✓ | Jira issue type name, e.g. `Bug`, `CVE` |
558
+ | `issues[].reportProjects` | ✓ | Glob patterns (`*` wildcard) for scan project identifiers (`name@version`) |
559
+ | `issues[].issueLabels` | – | Global labels attached to every issue created by this rule, merged with any labels from `addToBoard` entries |
560
+ | `issues[].addToBoard` | – | Per-board routing rules (see table below). Enables Scrum sprint assignment and/or Kanban label routing |
561
+ | `addToBoard[].sprintBoardId` | – | Scrum board ID. When set, the issue is assigned to the currently active sprint of that board via `customfield_10020`. Omit for Kanban boards. |
562
+ | `addToBoard[].issueLabels` | – | Labels contributed by this board entry (see routing rules below) |
563
+ | `addToBoard[].matchProjects` | – | Glob patterns to activate this entry. Defaults to `["*"]` (matches any project) |
564
+
565
+ **`addToBoard` routing rules:**
553
566
 
554
- **Routing:** The `reportProjects` patterns are matched against each scan project identifier (`name@version`). The `*` wildcard matches any sequence of characters. Each routing rule is evaluated independently, so a vulnerability can be reported to multiple Jira projects. Rules that match no project are silently skipped.
567
+ - Each entry in `addToBoard` is checked: its `matchProjects` patterns are tested against the scan projects affected by the current vulnerability. If none match, the entry is skipped.
568
+ - **Scrum (sprintBoardId set):** The **first** matching entry with a `sprintBoardId` claims the sprint. All subsequent matching sprint entries – including their labels – are ignored. This avoids ambiguous multi-sprint assignments, which Jira does not support.
569
+ - **Kanban (no sprintBoardId):** Matching entries always contribute their labels. This allows issues to appear on Kanban boards that filter by label.
570
+ - The final label set on the created issue is: `issueLabels` (top-level) + labels from the winning sprint entry (if any) + labels from all matching Kanban entries.
571
+
572
+ **`reportProjects` routing:** The `reportProjects` patterns are matched against each scan project identifier (`name@version`). The `*` wildcard matches any sequence of characters. Each routing rule is evaluated independently, so a vulnerability can be reported to multiple Jira projects. Rules that match no project are silently skipped.
555
573
 
556
574
  ### CLI Usage
557
575
 
@@ -561,8 +579,8 @@ $ ct-vuln-jira-report [options]
561
579
 
562
580
  ```
563
581
  Options:
564
- -c, --jira-config <path> Path to jira.json configuration file
565
- (default: ./jira.json)
582
+ -c, --jira-config <path> Path to jira-conf.json configuration file
583
+ (default: ./jira-conf.json)
566
584
  -s, --summary <path> Path to scan-summary.json produced by ct-vuln-scan
567
585
  (default: ./output/scan-summary.json)
568
586
  --dry-run Print what would be created without calling Jira
@@ -572,37 +590,16 @@ Options:
572
590
  Examples:
573
591
 
574
592
  ```sh
575
- # Use defaults (./jira.json + ./output/scan-summary.json)
593
+ # Use defaults (./jira-conf.json + ./output/scan-summary.json)
576
594
  $ ct-vuln-jira-report
577
595
 
578
596
  # Custom paths
579
- $ ct-vuln-jira-report --jira-config ./config/jira.json --summary ./output/scan-summary.json
597
+ $ ct-vuln-jira-report --jira-config ./config/jira-conf.json --summary ./output/scan-summary.json
580
598
 
581
599
  # Preview without creating issues
582
600
  $ ct-vuln-jira-report --dry-run
583
601
  ```
584
602
 
585
- ### Issue Title Format
586
-
587
- ```
588
- [<SEVERITY>] <Vuln-Id>: <Vuln-Title>
589
- ```
590
-
591
- Example: `[HIGH] CVE-2024-0001: Remote code execution in foo-lib`
592
-
593
- ### Issue Description Structure
594
-
595
- Each created issue contains the following sections (in Jira ADF format):
596
-
597
- | Section | Content |
598
- | ------------------------ | --------------------------------------------------------- |
599
- | **CVE Info** | Vulnerability description and dependency tree (ASCII art) |
600
- | **Betroffene Artefakte** | List of affected component purls |
601
- | **Gemeldete Produkte** | Scan projects matched by this routing rule |
602
- | **Einwertung** | Placeholder for severity/impact assessment |
603
- | **Betroffene Produkte** | Placeholder for final affected/not-affected determination |
604
- | **Maßnahmen** | Placeholder for remediation steps |
605
-
606
603
  ## Dev Notes
607
604
 
608
605
  Setup:
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- "use strict";var P=require("path"),v=require("zx");var x=require("fs/promises");function A(t,e=""){return T(t,e)}function T(t,e="",r=new Set){if(r.has(t.purl))return`${e}- ${t.purl} (cycle detected)`;r.add(t.purl);let n=r.size===1,s=`${e}- ${t.purl}`;for(let i of t.usedBy){let o=T(i,e+(n?" ":" "),new Set(r));o!==""&&(s+=`
3
- `,s+=o)}return s}async function I(t){let{scanSummaryPath:e,jiraConfigPath:r,jiraUser:n,jiraApiToken:s,dryRun:i=!1}=t,[o,c]=await Promise.all([M(e),F(r)]),a={created:[],skipped:[],errors:[]},u=new $(c.jiraUrl,n,s),y=new Map;for(let[d,S]of Object.entries(o.vulnerabilities)){let E=o.vulnerabilityToProject[d]??[];for(let p of c.issues){let w=E.filter(f=>K(f,p.reportProjects));if(w.length===0)continue;let b=_(S);try{let f=await u.issueExists(p.jiraProject,d);if(f){console.log(`[skip] Issue for ${d} already exists: ${f}`),a.skipped.push(`${p.jiraProject}::${d}`);continue}if(i){console.log(`[dry-run] Would create issue in ${p.jiraProject}: "${b}"`),console.log(` Matching projects: ${w.join(", ")}`),a.created.push(`${p.jiraProject}::${d}`);continue}let g;if(p.assignToActiveSprint&&p.boardId!=null){let h=p.boardId;if(!y.has(h)){let B=await u.getActiveSprintId(h).catch(O=>(console.warn(`[warn] Could not resolve active sprint for boardId ${h}: ${String(O)}`),null));y.set(h,B)}g=y.get(h)??void 0}let U=G(S,w),k=await u.createIssue({projectKey:p.jiraProject,issueType:p.issueType,summary:b,description:U,labels:p.issueLabels,sprintId:g});console.log(`[created] ${k} in ${p.jiraProject}: "${b}"`),a.created.push(k)}catch(f){let g=`Failed to process ${d} for project ${p.jiraProject}: ${String(f)}`;console.error(`[error] ${g}`),a.errors.push(g)}}}return a}async function M(t){let e=await(0,x.readFile)(t,"utf8");return JSON.parse(e)}async function F(t){let e=await(0,x.readFile)(t,"utf8"),r=JSON.parse(e);if(!r.issues&&r.issue&&(r.issues=r.issue),!r.jiraUrl||typeof r.jiraUrl!="string")throw new Error("jira.json: missing or invalid 'jiraUrl' field");if(!Array.isArray(r.issues))throw new Error("jira.json: missing or invalid 'issues' array");return r}function D(t){return t.replace(/[.+?^${}()|[\]\\]/g,"\\$&")}function L(t,e){let r=e.split("*").map(D).join(".*");return new RegExp(`^${r}$`).test(t)}function K(t,e){return e.some(r=>L(t,r))}function _(t){let e=t.title?`: ${t.title}`:"";return`[${t.severity}] ${t.id}${e}`}function V(...t){return{type:"doc",version:1,content:t}}function l(t,e){return{type:"heading",attrs:{level:t},content:[{type:"text",text:e}]}}function z(t){let e=t.split(/\r?\n/),r=[],n=0;for(;n<e.length;){let s=e[n],i=s.match(/^```(\w*)$/);if(i){let a=i[1]??"text",u=[];for(n++;n<e.length&&!e[n].startsWith("```");)u.push(e[n]),n++;n++,r.push(R(u.join(`
4
- `),a));continue}let o=s.match(/^(#{1,3})\s+(.+)$/);if(o){let a=Math.min(o[1].length,3);r.push(l(a,o[2].trim())),n++;continue}if(/^[-*]\s/.test(s)){let a=[];for(;n<e.length&&/^[-*]\s/.test(e[n]);){let u=e[n].replace(/^[-*]\s+/,"");a.push({type:"listItem",content:[{type:"paragraph",content:j(u)}]}),n++}r.push({type:"bulletList",content:a});continue}if(/^\d+\.\s/.test(s)){let a=[];for(;n<e.length&&/^\d+\.\s/.test(e[n]);){let u=e[n].replace(/^\d+\.\s+/,"");a.push({type:"listItem",content:[{type:"paragraph",content:j(u)}]}),n++}r.push({type:"orderedList",content:a});continue}if(s.trim()===""){n++;continue}let c=[];for(;n<e.length&&e[n].trim()!==""&&!/^(#{1,3}\s|[-*]\s|\d+\.\s|```)/.test(e[n]);)c.push(e[n]),n++;if(c.length>0){let a=[];for(let u=0;u<c.length;u++)a.push(...j(c[u])),u<c.length-1&&a.push({type:"hardBreak"});r.push({type:"paragraph",content:a})}}return r}function j(t){let e=[],r=/(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|_(.+?)_)/g,n=0,s;for(;(s=r.exec(t))!==null;)s.index>n&&e.push({type:"text",text:t.slice(n,s.index)}),s[2]!==void 0?e.push({type:"text",text:s[2],marks:[{type:"strong"}]}):s[3]!==void 0?e.push({type:"text",text:s[3],marks:[{type:"em"}]}):s[4]!==void 0?e.push({type:"text",text:s[4],marks:[{type:"code"}]}):s[5]!==void 0&&e.push({type:"text",text:s[5],marks:[{type:"em"}]}),n=s.index+s[0].length;return n<t.length&&e.push({type:"text",text:t.slice(n)}),e.length>0?e:[{type:"text",text:t}]}function R(t,e="text"){return{type:"codeBlock",attrs:{language:e},content:[{type:"text",text:t}]}}function C(t){return{type:"bulletList",content:t.map(e=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:e}]}]}))}}function H(t){return{type:"bulletList",content:t.map(e=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:e,marks:[{type:"link",attrs:{href:e}}]}]}]}))}}function m(t){return{type:"paragraph",content:[{type:"text",text:t,marks:[{type:"em"}]}]}}function q(t){return!t||t.length===0?"":t.map(e=>A(e,"")).join(`
2
+ "use strict";var A=require("path"),K=require("zx");var N=require("fs/promises");function E(r,e=""){return B(r,e)}function B(r,e="",n=new Set){if(n.has(r.purl))return`${e}- ${r.purl} (cycle detected)`;n.add(r.purl);let t=n.size===1,s=`${e}- ${r.purl}`;for(let i of r.usedBy){let o=B(i,e+(t?" ":" "),new Set(n));o!==""&&(s+=`
3
+ `,s+=o)}return s}async function M(r){let{scanSummaryPath:e,jiraConfigPath:n,jiraUser:t,jiraApiToken:s,dryRun:i=!1}=r,[o,c]=await Promise.all([H(e),W(n)]),a={created:[],skipped:[],errors:[]},u=new k(c.jiraUrl,t,s),x=new Map;for(let[g,P]of Object.entries(o.vulnerabilities)){let V=o.vulnerabilityToProject[g]??[];for(let p of c.issues){let h=V.filter(m=>O(m,p.reportProjects));if(h.length===0)continue;let $=Q(P);try{let m=await u.issueExists(p.jiraProject,g);if(m){console.log(`[skip] Issue for ${g} already exists: ${m}`),a.skipped.push(`${p.jiraProject}::${g}`);continue}if(i){console.log(`[dry-run] Would create issue in ${p.jiraProject}: "${$}"`),console.log(` Matching projects: ${h.join(", ")}`),a.created.push(`${p.jiraProject}::${g}`);continue}let y,C=[],I=!1;for(let l of p.addToBoard??[]){let T=l.matchProjects??["*"];if(h.some(d=>O(d,T)))if(l.sprintBoardId!=null){if(!I){I=!0;let d=l.sprintBoardId;if(!x.has(d)){let j=await u.getActiveSprintId(d).catch(z=>(console.warn(`[warn] Could not resolve active sprint for boardId ${d}: ${String(z)}`),null));x.set(d,j)}y=x.get(d)??void 0,l.issueLabels&&C.push(...l.issueLabels)}}else l.issueLabels&&C.push(...l.issueLabels)}let R=[...p.issueLabels??[],...C],J=new Map;if(o.projectsToComponents)for(let l of h){let T=o.projectsToComponents[l]??{},w=[];for(let d of P.components){let j=T[d];j&&w.push(j)}w.length>0&&J.set(l,w)}let _=re(P,h,J),v=await u.createIssue({projectKey:p.jiraProject,issueType:p.issueType,summary:$,description:_,labels:R.length>0?R:void 0,sprintId:y});console.log(`[created] ${v} in ${p.jiraProject}: "${$}"`),a.created.push(v)}catch(m){let y=`Failed to process ${g} for project ${p.jiraProject}: ${String(m)}`;console.error(`[error] ${y}`),a.errors.push(y)}}}return a}async function H(r){let e=await(0,N.readFile)(r,"utf8");return JSON.parse(e)}async function W(r){let e=await(0,N.readFile)(r,"utf8"),n=JSON.parse(e);if(!n.issues&&n.issue&&(n.issues=n.issue),!n.jiraUrl||typeof n.jiraUrl!="string")throw new Error("jira-conf.json: missing or invalid 'jiraUrl' field");if(!Array.isArray(n.issues))throw new Error("jira-conf.json: missing or invalid 'issues' array");return n}function q(r){return r.replace(/[.+?^${}()|[\]\\]/g,"\\$&")}function G(r,e){let n=e.split("*").map(q).join(".*");return new RegExp(`^${n}$`).test(r)}function O(r,e){return e.some(n=>G(r,n))}function Q(r){let e=r.title?`: ${r.title}`:"";return`[${r.severity}] ${r.id}${e}`}function X(...r){return{type:"doc",version:1,content:r}}function f(r,e){return{type:"heading",attrs:{level:r},content:[{type:"text",text:e}]}}function Y(r){let e=r.split(/\r?\n/),n=[],t=0;for(;t<e.length;){let s=e[t],i=s.match(/^```(\w*)$/);if(i){let a=i[1]??"text",u=[];for(t++;t<e.length&&!e[t].startsWith("```");)u.push(e[t]),t++;t++,n.push(L(u.join(`
4
+ `),a));continue}let o=s.match(/^(#{1,3})\s+(.+)$/);if(o){let a=Math.min(o[1].length,3);n.push(f(a,o[2].trim())),t++;continue}if(/^[-*]\s/.test(s)){let a=[];for(;t<e.length&&/^[-*]\s/.test(e[t]);){let u=e[t].replace(/^[-*]\s+/,"");a.push({type:"listItem",content:[{type:"paragraph",content:S(u)}]}),t++}n.push({type:"bulletList",content:a});continue}if(/^\d+\.\s/.test(s)){let a=[];for(;t<e.length&&/^\d+\.\s/.test(e[t]);){let u=e[t].replace(/^\d+\.\s+/,"");a.push({type:"listItem",content:[{type:"paragraph",content:S(u)}]}),t++}n.push({type:"orderedList",content:a});continue}if(s.trim()===""){t++;continue}let c=[];for(;t<e.length&&e[t].trim()!==""&&!/^(#{1,3}\s|[-*]\s|\d+\.\s|```)/.test(e[t]);)c.push(e[t]),t++;if(c.length>0){let a=[];for(let u=0;u<c.length;u++)a.push(...S(c[u])),u<c.length-1&&a.push({type:"hardBreak"});n.push({type:"paragraph",content:a})}}return n}function S(r){let e=[],n=/(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|_(.+?)_)/g,t=0,s;for(;(s=n.exec(r))!==null;)s.index>t&&e.push({type:"text",text:r.slice(t,s.index)}),s[2]!==void 0?e.push({type:"text",text:s[2],marks:[{type:"strong"}]}):s[3]!==void 0?e.push({type:"text",text:s[3],marks:[{type:"em"}]}):s[4]!==void 0?e.push({type:"text",text:s[4],marks:[{type:"code"}]}):s[5]!==void 0&&e.push({type:"text",text:s[5],marks:[{type:"em"}]}),t=s.index+s[0].length;return t<r.length&&e.push({type:"text",text:r.slice(t)}),e.length>0?e:[{type:"text",text:r}]}function L(r,e="text"){return{type:"codeBlock",attrs:{language:e},content:[{type:"text",text:r}]}}function Z(r){return{type:"bulletList",content:r.map(e=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:e}]}]}))}}function ee(r){return{type:"bulletList",content:r.map(e=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:e,marks:[{type:"link",attrs:{href:e}}]}]}]}))}}function U(r,...e){return{type:"expand",attrs:{title:r},content:e}}function b(r){return{type:"paragraph",content:[{type:"text",text:r,marks:[{type:"em"}]}]}}function te(r){return!r||r.length===0?"":r.map(e=>E(e,"")).join(`
5
5
 
6
- `)}function G(t,e){let r=[];if(r.push(l(1,"CVE Info (automatisch generiert)")),t.refs&&t.refs.length>0&&r.push(H(t.refs)),t.description&&r.push(...z(t.description)),r.push(l(2,"Betroffene Artefakte")),t.components&&t.components.length>0){r.push(C(t.components));let n=q(t.componentTree??[]);n&&(r.push(l(3,"Verwendungsbaum")),r.push(R(n)))}else r.push(m("Keine Komponenten bekannt."));return r.push(l(2,"Gemeldete Produkte")),r.push(C(e.slice().sort())),r.push(l(2,"Einwertung")),r.push(m("Einwertung des CVE in Bezug auf die gemeldeten Produkte. Dies kann eine Bewertung der Schwere, der Auswirkungen und der Wahrscheinlichkeit eines Angriffs umfassen.")),r.push(l(2,"Betroffene Produkte")),r.push(m("Abschlie\xDFende Darstellung, welche Produkte tats\xE4chlich betroffen oder nicht betroffen sind.")),r.push(l(2,"Ma\xDFnahmen")),r.push(m("\xDCbersicht der zu ergreifenden Ma\xDFnahmen, um den CVE zu beheben oder zu mitigieren. Dies kann Patches, Updates oder Konfigurations\xE4nderungen umfassen.")),V(...r)}var $=class{authHeader;baseUrl;constructor(e,r,n){this.baseUrl=e.replace(/\/$/,""),this.authHeader=`Basic ${Buffer.from(`${r}:${n}`).toString("base64")}`}get headers(){return{Authorization:this.authHeader,"Content-Type":"application/json",Accept:"application/json"}}async safeJson(e,r){let n=await e.text();try{return JSON.parse(n)}catch{let s=n.slice(0,500).replace(/\s+/g," ").trim();throw new Error(`${r}: expected JSON but got (HTTP ${e.status}): ${s}`)}}async issueExists(e,r){let n=r.replace(/"/g,'\\"'),s=`project = "${e}" AND summary ~ "\\"${n}\\""`,i=`${this.baseUrl}/rest/api/3/search/jql?jql=${encodeURIComponent(s)}&maxResults=1&fields=summary`,o=await fetch(i,{method:"GET",headers:this.headers});if(!o.ok){let a=await o.text();throw new Error(`Jira search failed (${o.status}): ${a}`)}return(await this.safeJson(o,"Jira search")).issues[0]?.key??null}async createIssue(e){let r={project:{key:e.projectKey},issuetype:{name:e.issueType},summary:e.summary,description:e.description};e.labels&&e.labels.length>0&&(r.labels=e.labels),e.sprintId!=null&&(r.customfield_10020=e.sprintId);let n={fields:r},s=await fetch(`${this.baseUrl}/rest/api/3/issue`,{method:"POST",headers:this.headers,body:JSON.stringify(n)});if(!s.ok){let o=await s.text();throw new Error(`Jira create issue failed (${s.status}): ${o}`)}return(await this.safeJson(s,"Jira create issue")).key}async getActiveSprintId(e){let r=`${this.baseUrl}/rest/agile/1.0/board/${e}/sprint?state=active&maxResults=1`,n=await fetch(r,{method:"GET",headers:this.headers});if(!n.ok){let i=await n.text();throw new Error(`Jira agile sprint lookup failed (${n.status}): ${i}`)}return(await this.safeJson(n,"Jira sprint lookup")).values[0]?.id??null}};v.dotenv.config();var N=process.env;function W(t){let e=t.slice(2),r=process.cwd(),n=(0,P.join)(r,"jira.json"),s=(0,P.join)(r,"output","scan-summary.json"),i=!1;for(let o=0;o<e.length;o++){let c=e[o];(c==="--jira-config"||c==="-c")&&e[o+1]?n=e[++o]:(c==="--summary"||c==="-s")&&e[o+1]?s=e[++o]:c==="--dry-run"?i=!0:c==="--help"||c==="-h"?(J(),process.exit(0)):(console.error(`Unknown argument: ${c}`),J(),process.exit(1))}return{jiraConfigPath:n,scanSummaryPath:s,dryRun:i}}function J(){console.log(`Usage: ct-vuln-jira-report [options]
6
+ `)}function re(r,e,n=new Map){let t=[];if(t.push(f(1,"CVE Info (automatisch generiert)")),r.refs&&r.refs.length>0&&t.push(ee(r.refs)),r.description){let s=Y(r.description);t.push(U("Beschreibung",...s))}t.push(f(2,"Betroffene Artefakte")),r.components&&r.components.length>0?t.push(Z(r.components)):t.push(b("Keine Komponenten bekannt.")),t.push(f(2,"Gemeldet f\xFCr"));for(let s of e.slice().sort()){t.push({type:"paragraph",content:[{type:"text",text:s,marks:[{type:"strong"}]}]});let i=n.get(s);if(i&&i.length>0){let o=te(i);o&&t.push(U("Verwendungsbaum",L(o)))}}return t.push(f(2,"Einwertung")),t.push(b("Einwertung des CVE in Bezug auf die gemeldeten Projekte/Produkte. Dies kann eine Bewertung der Schwere, der Auswirkungen und der Wahrscheinlichkeit eines Angriffs umfassen.")),t.push(f(2,"Betroffen sind")),t.push(b("Abschlie\xDFende Darstellung, welche Projekte/Produkte tats\xE4chlich betroffen oder nicht betroffen sind.")),t.push(f(2,"Ma\xDFnahmen")),t.push(b("\xDCbersicht der zu ergreifenden Ma\xDFnahmen, um den CVE zu beheben oder zu mitigieren. Dies kann Patches, Updates oder Konfigurations\xE4nderungen umfassen.")),X(...t)}var k=class{authHeader;baseUrl;constructor(e,n,t){this.baseUrl=e.replace(/\/$/,""),this.authHeader=`Basic ${Buffer.from(`${n}:${t}`).toString("base64")}`}get headers(){return{Authorization:this.authHeader,"Content-Type":"application/json",Accept:"application/json"}}async safeJson(e,n){let t=await e.text();try{return JSON.parse(t)}catch{let s=t.slice(0,500).replace(/\s+/g," ").trim();throw new Error(`${n}: expected JSON but got (HTTP ${e.status}): ${s}`)}}async issueExists(e,n){let t=n.replace(/"/g,'\\"'),s=`project = "${e}" AND summary ~ "\\"${t}\\""`,i=`${this.baseUrl}/rest/api/3/search/jql?jql=${encodeURIComponent(s)}&maxResults=1&fields=summary`,o=await fetch(i,{method:"GET",headers:this.headers});if(!o.ok){let a=await o.text();throw new Error(`Jira search failed (${o.status}): ${a}`)}return(await this.safeJson(o,"Jira search")).issues[0]?.key??null}async createIssue(e){let n={project:{key:e.projectKey},issuetype:{name:e.issueType},summary:e.summary,description:e.description};e.labels&&e.labels.length>0&&(n.labels=e.labels),e.sprintId!=null&&(n.customfield_10020=e.sprintId);let t={fields:n},s=await fetch(`${this.baseUrl}/rest/api/3/issue`,{method:"POST",headers:this.headers,body:JSON.stringify(t)});if(!s.ok){let o=await s.text();throw new Error(`Jira create issue failed (${s.status}): ${o}`)}return(await this.safeJson(s,"Jira create issue")).key}async getActiveSprintId(e){let n=`${this.baseUrl}/rest/agile/1.0/board/${e}/sprint?state=active&maxResults=1`,t=await fetch(n,{method:"GET",headers:this.headers});if(!t.ok){let i=await t.text();throw new Error(`Jira agile sprint lookup failed (${t.status}): ${i}`)}return(await this.safeJson(t,"Jira sprint lookup")).values[0]?.id??null}};K.dotenv.config();var F=process.env;function ne(r){let e=r.slice(2),n=process.cwd(),t=(0,A.join)(n,"jira-conf.json"),s=(0,A.join)(n,"output","scan-summary.json"),i=!1;for(let o=0;o<e.length;o++){let c=e[o];(c==="--jira-config"||c==="-c")&&e[o+1]?t=e[++o]:(c==="--summary"||c==="-s")&&e[o+1]?s=e[++o]:c==="--dry-run"?i=!0:c==="--help"||c==="-h"?(D(),process.exit(0)):(console.error(`Unknown argument: ${c}`),D(),process.exit(1))}return{jiraConfigPath:t,scanSummaryPath:s,dryRun:i}}function D(){console.log(`Usage: ct-vuln-jira-report [options]
7
7
 
8
8
  Options:
9
- -c, --jira-config <path> Path to jira.json configuration file
10
- (default: ./jira.json)
9
+ -c, --jira-config <path> Path to jira-conf.json configuration file
10
+ (default: ./jira-conf.json)
11
11
  -s, --summary <path> Path to scan-summary.json produced by ct-vuln-scan
12
12
  (default: ./output/scan-summary.json)
13
13
  --dry-run Print what would be created without calling Jira
@@ -16,4 +16,4 @@ Options:
16
16
  Required environment variables:
17
17
  JIRA_USER Jira account e-mail address
18
18
  JIRA_API_TOKEN Jira API token (see https://id.atlassian.com/manage-profile/security/api-tokens)
19
- `)}(async()=>{let{jiraConfigPath:t,scanSummaryPath:e,dryRun:r}=W(process.argv),n=N.JIRA_USER,s=N.JIRA_API_TOKEN;(!n||!s)&&(console.error("Error: JIRA_USER and JIRA_API_TOKEN environment variables must be set."),process.exit(1)),console.log(`Jira reporter${r?" [dry-run]":""}`),console.log(` config: ${t}`),console.log(` summary: ${e}`),console.log("");try{let i=await I({scanSummaryPath:e,jiraConfigPath:t,jiraUser:n,jiraApiToken:s,dryRun:r});console.log(""),console.log("=== Jira Report Summary ==="),console.log(` Created : ${i.created.length}`),console.log(` Skipped : ${i.skipped.length}`),console.log(` Errors : ${i.errors.length}`),i.errors.length>0&&(process.exitCode=1)}catch(i){console.error(`Fatal error: ${String(i)}`),process.exit(1)}})();
19
+ `)}(async()=>{let{jiraConfigPath:r,scanSummaryPath:e,dryRun:n}=ne(process.argv),t=F.JIRA_USER,s=F.JIRA_API_TOKEN;(!t||!s)&&(console.error("Error: JIRA_USER and JIRA_API_TOKEN environment variables must be set."),process.exit(1)),console.log(`Jira reporter${n?" [dry-run]":""}`),console.log(` config: ${r}`),console.log(` summary: ${e}`),console.log("");try{let i=await M({scanSummaryPath:e,jiraConfigPath:r,jiraUser:t,jiraApiToken:s,dryRun:n});console.log(""),console.log("=== Jira Report Summary ==="),console.log(` Created : ${i.created.length}`),console.log(` Skipped : ${i.skipped.length}`),console.log(` Errors : ${i.errors.length}`),i.errors.length>0&&(process.exitCode=1)}catch(i){console.error(`Fatal error: ${String(i)}`),process.exit(1)}})();
@@ -41,20 +41,18 @@
41
41
  "type": "string"
42
42
  },
43
43
  "issueLabels": {
44
- "description": "Optional list of Jira labels to apply to every issue created by this rule. Example: [\"security\", \"cve\"].",
44
+ "description": "Global Jira labels applied to every issue created by this rule, regardless of addToBoard matching. Merged with any labels from matching addToBoard entries. Example: [\"security\", \"cve\"].",
45
45
  "type": "array",
46
46
  "items": {
47
47
  "type": "string"
48
48
  }
49
49
  },
50
- "boardId": {
51
- "description": "Jira Scrum board ID. Required when assignToActiveSprint is true. Used to look up the currently active sprint via the Jira Agile API.",
52
- "type": "integer"
53
- },
54
- "assignToActiveSprint": {
55
- "description": "When true and boardId is set, the created issue is assigned to the currently active sprint of that board. If no active sprint is found the issue is still created without a sprint assignment.",
56
- "type": "boolean",
57
- "default": false
50
+ "addToBoard": {
51
+ "description": "Per-board routing rules. Each entry can target a Scrum sprint board (via sprintBoardId) and/or add labels for Kanban board routing. Sprint assignment: the first matching entry with a sprintBoardId wins; subsequent matching sprint entries and their labels are ignored. Kanban entries (no sprintBoardId) always contribute their labels.",
52
+ "type": "array",
53
+ "items": {
54
+ "$ref": "#/$defs/BoardConfig"
55
+ }
58
56
  },
59
57
  "reportProjects": {
60
58
  "description": "Glob patterns for project identifiers (name@version) whose vulnerabilities should be routed to this Jira project. Use \"*\" to match any string. Examples: \"mapapps@*\", \"smartfinder@4.*\", \"*\".",
@@ -66,6 +64,31 @@
66
64
  }
67
65
  }
68
66
  }
67
+ },
68
+ "BoardConfig": {
69
+ "description": "Per-board routing rule within addToBoard. Controls which projects activate this entry, whether to assign the issue to an active Scrum sprint, and which labels to attach.",
70
+ "type": "object",
71
+ "additionalProperties": false,
72
+ "properties": {
73
+ "sprintBoardId": {
74
+ "description": "Jira Scrum board ID. When set, the issue is assigned to the currently active sprint of that board. Omit for Kanban boards (routing is handled by Jira's board JQL filter).",
75
+ "type": "integer"
76
+ },
77
+ "issueLabels": {
78
+ "description": "Labels to attach when this entry matches. For sprint entries: included only if this is the first matching sprint entry. For Kanban entries (no sprintBoardId): always aggregated.",
79
+ "type": "array",
80
+ "items": {
81
+ "type": "string"
82
+ }
83
+ },
84
+ "matchProjects": {
85
+ "description": "Glob patterns for project identifiers (name@version) that activate this board entry. Defaults to [\"*\"] when omitted (matches any project). Example: [\"mapapps@*\", \"smartfinder@4.*\"].",
86
+ "type": "array",
87
+ "items": {
88
+ "type": "string"
89
+ }
90
+ }
91
+ }
69
92
  }
70
93
  }
71
94
  }
@@ -9,6 +9,7 @@
9
9
  "vulnerabilities",
10
10
  "vulnerabilityToProject",
11
11
  "projectToVulnerability",
12
+ "projectsToComponents",
12
13
  "noLongerDetectedVulnerabilities"
13
14
  ],
14
15
  "additionalProperties": false,
@@ -51,6 +52,16 @@
51
52
  }
52
53
  }
53
54
  },
55
+ "projectsToComponents": {
56
+ "description": "Map of project identifier (name@version) to a map of component purl to its reverse dependency tree node. Contains the component usage trees for all vulnerable components per project.",
57
+ "type": "object",
58
+ "additionalProperties": {
59
+ "type": "object",
60
+ "additionalProperties": {
61
+ "$ref": "#/$defs/ComponentNode"
62
+ }
63
+ }
64
+ },
54
65
  "noLongerDetectedVulnerabilities": {
55
66
  "description": "Map of CVE/vulnerability ID to the list of project identifiers (name@version) where the vulnerability was declared but is no longer detected.",
56
67
  "type": "object",
@@ -90,7 +101,7 @@
90
101
  "ShortVulnInfo": {
91
102
  "description": "Condensed vulnerability information for a single CVE/vulnerability.",
92
103
  "type": "object",
93
- "required": ["id", "severity", "title", "description", "components", "componentTree", "refs"],
104
+ "required": ["id", "severity", "title", "description", "components", "refs"],
94
105
  "additionalProperties": false,
95
106
  "properties": {
96
107
  "id": {
@@ -115,13 +126,6 @@
115
126
  "type": "string"
116
127
  }
117
128
  },
118
- "componentTree": {
119
- "description": "Reverse dependency tree nodes showing how affected components are used.",
120
- "type": "array",
121
- "items": {
122
- "$ref": "#/$defs/ComponentNode"
123
- }
124
- },
125
129
  "refs": {
126
130
  "description": "List of reference URLs (advisories, NVD entries, etc.).",
127
131
  "type": "array",