@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.
- package/package.json +1 -1
- package/teams/firstspirit-caas/mcp/jira-tools.py +2122 -0
- package/teams/firstspirit-caas/mcp/test_bamboo_artifacts.py +315 -0
- package/teams/firstspirit-caas/mcp/test_jira_links.py +192 -0
- package/teams/firstspirit-caas/mcp/test_update_jira_ticket.py +161 -0
- package/teams/firstspirit-caas/resources/mcp-setup.md +57 -0
- package/teams/firstspirit-caas/skills/chronicle/SKILL.md +105 -0
- package/teams/firstspirit-caas/skills/create-jira-ticket/SKILL.md +183 -0
- package/teams/firstspirit-caas/skills/get-jira-ticket/SKILL.md +125 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/SKILL.md +316 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/references/suppression.md +79 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/gen_suppression.py +116 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_json_report.py +183 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_report.py +255 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_gen_suppression.py +171 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_json_report.py +244 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_report.py +464 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.html +39 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.json +76 -0
- package/teams/firstspirit-caas/skills/handle-weekly-cve-plans/SKILL.md +238 -0
- package/teams/firstspirit-caas/skills/retrospect/SKILL.md +94 -0
- package/teams/firstspirit-caas/skills/retrospect/extract_prompts.py +392 -0
- package/teams/firstspirit-caas/skills/review/SKILL.md +131 -0
- package/teams/firstspirit-caas/skills/review-finalize/SKILL.md +103 -0
- package/teams/firstspirit-caas/skills/specify/SKILL.md +239 -0
- package/teams/firstspirit-caas/skills/summarise-for-jira-comment/SKILL.md +162 -0
- package/teams/firstspirit-caas/skills/write-releasenotes/SKILL.md +177 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract active OWASP Dependency-Check findings with CVSS >= 7.0 from the
|
|
3
|
+
JSON report (`dependency-check-report.json`).
|
|
4
|
+
|
|
5
|
+
Usage: parse_json_report.py <dependency-check-report.json> [--out <path>]
|
|
6
|
+
Emits the same worklist schema as parse_report.py (the HTML fallback): a
|
|
7
|
+
`findings` list and an `unparsed` hard-stop list. Prints JSON to stdout (or
|
|
8
|
+
--out). Exits non-zero with a one-line stderr error on failure.
|
|
9
|
+
|
|
10
|
+
Active vs suppressed is structural here, not inferred: a dependency's
|
|
11
|
+
`vulnerabilities` array holds active findings; `suppressedVulnerabilities`
|
|
12
|
+
holds the suppressed ones and is never read.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
THRESHOLD = 7.0
|
|
20
|
+
_CVE_ID = re.compile(r'^CVE-\d{4}-\d+$')
|
|
21
|
+
_HIGH_BANDS = {"CRITICAL", "HIGH"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _severity_for_score(score: float) -> str:
|
|
25
|
+
if score >= 9.0:
|
|
26
|
+
return "CRITICAL"
|
|
27
|
+
if score >= 7.0:
|
|
28
|
+
return "HIGH"
|
|
29
|
+
if score >= 4.0:
|
|
30
|
+
return "MEDIUM"
|
|
31
|
+
return "LOW"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _numeric_score(vuln: dict):
|
|
35
|
+
"""The worst numeric base score across the CVSS versions the report
|
|
36
|
+
carries (v2 `score`, v3/v4 `baseScore`), or None when none is present.
|
|
37
|
+
Taking the max keeps a >=7 score in any one version from being gated
|
|
38
|
+
below by a lower score in another."""
|
|
39
|
+
scores = []
|
|
40
|
+
for key, field in (("cvssv3", "baseScore"), ("cvssv4", "baseScore"), ("cvssv2", "score")):
|
|
41
|
+
obj = vuln.get(key)
|
|
42
|
+
if isinstance(obj, dict) and isinstance(obj.get(field), (int, float)):
|
|
43
|
+
scores.append(float(obj[field]))
|
|
44
|
+
return round(max(scores), 1) if scores else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _score_and_severity(vuln: dict):
|
|
48
|
+
"""(score, severity): a numeric CVSS wins (severity derived from it);
|
|
49
|
+
else the qualitative `severity` band; else (None, None)."""
|
|
50
|
+
score = _numeric_score(vuln)
|
|
51
|
+
if score is not None:
|
|
52
|
+
return score, _severity_for_score(score)
|
|
53
|
+
band = vuln.get("severity")
|
|
54
|
+
if band:
|
|
55
|
+
return None, band.upper()
|
|
56
|
+
return None, None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _affected_versions(vuln: dict) -> list:
|
|
60
|
+
out = []
|
|
61
|
+
for entry in vuln.get("vulnerableSoftware", []):
|
|
62
|
+
sw = entry.get("software", {})
|
|
63
|
+
cpe = sw.get("id")
|
|
64
|
+
if not cpe:
|
|
65
|
+
continue
|
|
66
|
+
bounds = []
|
|
67
|
+
if sw.get("versionStartIncluding"):
|
|
68
|
+
bounds.append(f">= {sw['versionStartIncluding']}")
|
|
69
|
+
if sw.get("versionStartExcluding"):
|
|
70
|
+
bounds.append(f"> {sw['versionStartExcluding']}")
|
|
71
|
+
if sw.get("versionEndIncluding"):
|
|
72
|
+
bounds.append(f"<= {sw['versionEndIncluding']}")
|
|
73
|
+
if sw.get("versionEndExcluding"):
|
|
74
|
+
bounds.append(f"< {sw['versionEndExcluding']}")
|
|
75
|
+
out.append(f"{cpe} versions {', '.join(bounds)}" if bounds else cpe)
|
|
76
|
+
return list(dict.fromkeys(out))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dependency_fields(dep: dict):
|
|
80
|
+
packages = dep.get("packages") or []
|
|
81
|
+
pkgurl = packages[0].get("id") if packages else None
|
|
82
|
+
return dep.get("fileName", ""), pkgurl, dep.get("sha1")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse(report: dict) -> dict:
|
|
86
|
+
findings = []
|
|
87
|
+
unparsed = []
|
|
88
|
+
seen = set()
|
|
89
|
+
seen_unparsed = set()
|
|
90
|
+
for dep in report.get("dependencies", []):
|
|
91
|
+
file_name, pkgurl, sha1 = _dependency_fields(dep)
|
|
92
|
+
included_by = [ref["reference"] for ref in dep.get("includedBy", [])
|
|
93
|
+
if ref.get("reference")]
|
|
94
|
+
for vuln in dep.get("vulnerabilities", []):
|
|
95
|
+
advisory_id = vuln.get("name")
|
|
96
|
+
if not advisory_id:
|
|
97
|
+
continue
|
|
98
|
+
score, severity = _score_and_severity(vuln)
|
|
99
|
+
|
|
100
|
+
if score is None and severity is None:
|
|
101
|
+
_add_unparsed(unparsed, seen_unparsed, advisory_id, file_name, pkgurl, sha1,
|
|
102
|
+
"no CVSS score or severity band could be parsed")
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if score is not None:
|
|
106
|
+
if score < THRESHOLD:
|
|
107
|
+
continue
|
|
108
|
+
elif severity not in _HIGH_BANDS:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if not _CVE_ID.match(advisory_id):
|
|
112
|
+
_add_unparsed(unparsed, seen_unparsed, advisory_id, file_name, pkgurl, sha1,
|
|
113
|
+
"non-CVE advisory id at/above CVSS 7.0 (not remediated by this skill)")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
key = (advisory_id, file_name)
|
|
117
|
+
if key in seen:
|
|
118
|
+
continue
|
|
119
|
+
seen.add(key)
|
|
120
|
+
references = list(dict.fromkeys(
|
|
121
|
+
r["url"] for r in vuln.get("references", [])
|
|
122
|
+
if r.get("url") and r["url"] != "#"))
|
|
123
|
+
findings.append({
|
|
124
|
+
"cveId": advisory_id,
|
|
125
|
+
"score": score,
|
|
126
|
+
"severity": severity,
|
|
127
|
+
"dependency": {
|
|
128
|
+
"fileName": file_name,
|
|
129
|
+
"packageUrl": pkgurl or None,
|
|
130
|
+
"sha1": sha1 or None,
|
|
131
|
+
},
|
|
132
|
+
"description": vuln.get("description"),
|
|
133
|
+
"references": references,
|
|
134
|
+
"affectedVersions": _affected_versions(vuln),
|
|
135
|
+
"includedBy": included_by,
|
|
136
|
+
})
|
|
137
|
+
return {"findings": findings, "unparsed": unparsed}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _add_unparsed(unparsed, seen, advisory_id, file_name, pkgurl, sha1, reason):
|
|
141
|
+
key = (advisory_id, file_name)
|
|
142
|
+
if key in seen:
|
|
143
|
+
return
|
|
144
|
+
seen.add(key)
|
|
145
|
+
unparsed.append({
|
|
146
|
+
"cveId": advisory_id,
|
|
147
|
+
"fileName": file_name,
|
|
148
|
+
"packageUrl": pkgurl or None,
|
|
149
|
+
"sha1": sha1 or None,
|
|
150
|
+
"reason": reason,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv):
|
|
155
|
+
ap = argparse.ArgumentParser()
|
|
156
|
+
ap.add_argument("report")
|
|
157
|
+
ap.add_argument("--out")
|
|
158
|
+
ns = ap.parse_args(argv)
|
|
159
|
+
try:
|
|
160
|
+
with open(ns.report, encoding="utf-8") as fh:
|
|
161
|
+
report = json.load(fh)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
print(f"parse_json_report.py: cannot read report: {exc}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
except json.JSONDecodeError as exc:
|
|
166
|
+
print(f"parse_json_report.py: report is not valid JSON: {exc}", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
data = parse(report)
|
|
169
|
+
text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
170
|
+
if ns.out:
|
|
171
|
+
try:
|
|
172
|
+
with open(ns.out, "w", encoding="utf-8") as fh:
|
|
173
|
+
fh.write(text)
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
print(f"parse_json_report.py: cannot write output: {exc}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
else:
|
|
178
|
+
print(text)
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract active OWASP Dependency-Check findings with CVSS >= 7.0 from the
|
|
3
|
+
HTML report Bamboo publishes.
|
|
4
|
+
|
|
5
|
+
Usage: parse_report.py <dependency-check-report.html> [--out <path>]
|
|
6
|
+
Active findings are those with a per-CVE "suppress" button; already-suppressed
|
|
7
|
+
entries have no button and are skipped. Prints JSON to stdout (or --out).
|
|
8
|
+
Exits non-zero with a one-line stderr error on failure.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import html as _html
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
THRESHOLD = 7.0
|
|
17
|
+
|
|
18
|
+
# Match the full <button ...> start tag for a suppress button. `[^>]*` (not
|
|
19
|
+
# `.*`) bounds the match so it can never cross a `>` into a neighboring tag --
|
|
20
|
+
# one button's attributes can never bleed into another's. The id value is
|
|
21
|
+
# matched loosely so buttons of every kind are captured here and classified by
|
|
22
|
+
# `data-type-to-suppress` below; the match stage never decides what is a
|
|
23
|
+
# finding. Attribute order is NOT assumed: each data-* attribute is pulled out
|
|
24
|
+
# of the matched tag text separately by _ATTR below, so a differently-ordered
|
|
25
|
+
# real report can't silently drop a finding.
|
|
26
|
+
_BUTTON_TAG = re.compile(
|
|
27
|
+
r'<button\b[^>]*data-id-to-suppress="[^"]+"[^>]*>'
|
|
28
|
+
)
|
|
29
|
+
_CVE_ID = re.compile(r'^CVE-\d{4}-\d+$')
|
|
30
|
+
_ATTR = re.compile(r'(data-[a-zA-Z0-9-]+)="([^"]*)"')
|
|
31
|
+
|
|
32
|
+
# dependency-check's htmlReport.vsl emits three kinds of suppress button
|
|
33
|
+
# (verified against the template):
|
|
34
|
+
# data-type-to-suppress="cpe" -> a per-dependency IDENTIFIER
|
|
35
|
+
# button, rendered for every dependency's CPE regardless of whether it
|
|
36
|
+
# has any vulnerability. Ubiquitous and benign -- it never reddens the
|
|
37
|
+
# build, so it must be ignored, not treated as a finding.
|
|
38
|
+
# data-type-to-suppress="cve" -> a CVE vulnerability.
|
|
39
|
+
# data-type-to-suppress="vulnerabilityName"-> a non-CVE vulnerability (GHSA,
|
|
40
|
+
# NPM advisory, RETIRE, etc.). The id may still be CVE-shaped.
|
|
41
|
+
# Every non-CPE button is classified by SEVERITY (below), not by type:
|
|
42
|
+
# below-HIGH findings are excluded, at/above-HIGH CVEs become findings, and
|
|
43
|
+
# at/above-HIGH non-CVEs go to `unparsed` (a hard stop -- real but not
|
|
44
|
+
# remediable by this skill).
|
|
45
|
+
_IDENTIFIER_TYPES = {"cpe"}
|
|
46
|
+
_ID_MARKER = re.compile(r'data-id-to-suppress=')
|
|
47
|
+
_SCORE = re.compile(r'Base Score:\s*(?P<sev>CRITICAL|HIGH|MEDIUM|LOW)\s*\((?P<score>[\d.]+)\)')
|
|
48
|
+
# Fallback for a bare CVSS score line with no "Base Score:" prefix, e.g. the
|
|
49
|
+
# CVSSv2 rendering `CVSSv2: Score: 7.5 (AV:N/AC:L/...)`. Only used when the
|
|
50
|
+
# primary _SCORE regex above finds nothing -- never overrides a match.
|
|
51
|
+
_SCORE_FALLBACK = re.compile(r'\bScore:\s*(?P<score>[\d.]+)')
|
|
52
|
+
# JS/NPM (and some other) advisories carry NO numeric CVSS -- dependency-check
|
|
53
|
+
# renders them as `Unscored: Severity: medium`. The qualitative band still
|
|
54
|
+
# tells us whether the finding is at/above HIGH, so it must NOT be treated as
|
|
55
|
+
# "score undeterminable". Consulted only when both numeric regexes miss.
|
|
56
|
+
_QUAL_SEVERITY = re.compile(r'(?:Unscored:\s*)?Severity:\s*(?P<band>CRITICAL|HIGH|MEDIUM|LOW|NONE)', re.I)
|
|
57
|
+
_HIGH_BANDS = {"CRITICAL", "HIGH"}
|
|
58
|
+
|
|
59
|
+
# The report already carries the advisory context needed to decide a fix:
|
|
60
|
+
# the prose description (often naming fixed/affected versions), the advisory
|
|
61
|
+
# reference URLs, and the "Vulnerable Software & Versions" ranges. Capturing
|
|
62
|
+
# these lets the skill consult the report FIRST and web-search only for what
|
|
63
|
+
# the report doesn't contain.
|
|
64
|
+
_PRE = re.compile(r'<pre[^>]*>(.*?)</pre>', re.S)
|
|
65
|
+
_ANCHOR = re.compile(r'<a\b[^>]*?href="([^"]+)"[^>]*>(.*?)</a>', re.S)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean(fragment: str) -> str:
|
|
69
|
+
"""Strip tags, unescape entities, collapse whitespace."""
|
|
70
|
+
return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _context(raw_block: str) -> tuple:
|
|
74
|
+
"""Pull (description, references, affectedVersions) out of one finding's
|
|
75
|
+
raw HTML block. Missing pieces come back as None / []."""
|
|
76
|
+
pre = _PRE.search(raw_block)
|
|
77
|
+
description = _clean(pre.group(1)) if pre else None
|
|
78
|
+
|
|
79
|
+
ref_start = raw_block.find("References:")
|
|
80
|
+
vs_start = raw_block.find("Vulnerable Software")
|
|
81
|
+
|
|
82
|
+
references = []
|
|
83
|
+
if ref_start != -1:
|
|
84
|
+
ref_end = vs_start if vs_start != -1 else len(raw_block)
|
|
85
|
+
for href, _text in _ANCHOR.findall(raw_block[ref_start:ref_end]):
|
|
86
|
+
href = _html.unescape(href)
|
|
87
|
+
if href and href != "#":
|
|
88
|
+
references.append(href)
|
|
89
|
+
|
|
90
|
+
affected = []
|
|
91
|
+
if vs_start != -1:
|
|
92
|
+
for _href, text in _ANCHOR.findall(raw_block[vs_start:]):
|
|
93
|
+
t = _clean(text)
|
|
94
|
+
if t.startswith("cpe:") or "version" in t:
|
|
95
|
+
affected.append(t)
|
|
96
|
+
|
|
97
|
+
# De-dupe, preserve order.
|
|
98
|
+
references = list(dict.fromkeys(references))
|
|
99
|
+
affected = list(dict.fromkeys(affected))
|
|
100
|
+
return description, references, affected
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _severity_for_score(score: float) -> str:
|
|
104
|
+
if score >= 9.0:
|
|
105
|
+
return "CRITICAL"
|
|
106
|
+
if score >= 7.0:
|
|
107
|
+
return "HIGH"
|
|
108
|
+
if score >= 4.0:
|
|
109
|
+
return "MEDIUM"
|
|
110
|
+
return "LOW"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _score_and_severity(tail: str):
|
|
114
|
+
"""Classify a finding block's severity. Returns (score, severity):
|
|
115
|
+
- (float, band) -- a numeric CVSS score was found.
|
|
116
|
+
- (None, band) -- no number, but an explicit qualitative band
|
|
117
|
+
('Unscored: Severity: medium').
|
|
118
|
+
- (None, None) -- nothing determinable (genuine fail-safe case).
|
|
119
|
+
Numeric score always wins; the qualitative band is a last resort."""
|
|
120
|
+
sm = _SCORE.search(tail)
|
|
121
|
+
if sm:
|
|
122
|
+
return float(sm.group("score")), sm.group("sev")
|
|
123
|
+
fm = _SCORE_FALLBACK.search(tail)
|
|
124
|
+
if fm:
|
|
125
|
+
score = float(fm.group("score"))
|
|
126
|
+
return score, _severity_for_score(score)
|
|
127
|
+
qm = _QUAL_SEVERITY.search(tail)
|
|
128
|
+
if qm:
|
|
129
|
+
return None, qm.group("band").upper()
|
|
130
|
+
return None, None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse(html_text: str) -> dict:
|
|
134
|
+
findings = []
|
|
135
|
+
unparsed = []
|
|
136
|
+
seen = set()
|
|
137
|
+
seen_unparsed = set()
|
|
138
|
+
for bm in _BUTTON_TAG.finditer(html_text):
|
|
139
|
+
attrs = dict(_ATTR.findall(bm.group(0)))
|
|
140
|
+
advisory_id = attrs.get("data-id-to-suppress")
|
|
141
|
+
if not advisory_id:
|
|
142
|
+
continue
|
|
143
|
+
dep = attrs.get("data-display-name", "")
|
|
144
|
+
sha1 = attrs.get("data-sha1")
|
|
145
|
+
pkgurl = attrs.get("data-pkgurl")
|
|
146
|
+
suppress_type = attrs.get("data-type-to-suppress", "").lower()
|
|
147
|
+
|
|
148
|
+
# Identifier (CPE) suppress buttons appear on every dependency and do
|
|
149
|
+
# NOT redden the build -- skip them. Match on the type attribute, and
|
|
150
|
+
# fall back to the id shape when the attribute is absent (a bare
|
|
151
|
+
# `cpe:...` id is never a vulnerability).
|
|
152
|
+
if suppress_type in _IDENTIFIER_TYPES or advisory_id.startswith("cpe:"):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Search the whole current finding block: from the end of this
|
|
156
|
+
# button up to the start of the NEXT suppress button (or EOF). A
|
|
157
|
+
# fixed-size lookahead window would let a long description push the
|
|
158
|
+
# score line out of range and silently drop the finding.
|
|
159
|
+
nm = _ID_MARKER.search(html_text, bm.end())
|
|
160
|
+
block_end = nm.start() if nm else len(html_text)
|
|
161
|
+
raw_block = html_text[bm.end():block_end]
|
|
162
|
+
tail = _html.unescape(re.sub(r"<[^>]+>", " ", raw_block))
|
|
163
|
+
|
|
164
|
+
score, severity = _score_and_severity(tail)
|
|
165
|
+
|
|
166
|
+
if score is None and severity is None:
|
|
167
|
+
# Fail-safe: an active (suppress-button) finding whose severity
|
|
168
|
+
# could be determined neither numerically nor qualitatively must
|
|
169
|
+
# NEVER be silently dropped -- surface it so a human/verify-step
|
|
170
|
+
# investigates instead of quietly converging to "0 findings".
|
|
171
|
+
_add_unparsed(unparsed, seen_unparsed, advisory_id, dep, pkgurl, sha1,
|
|
172
|
+
"no CVSS score or severity band could be parsed")
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Below-HIGH findings (numeric < 7.0, or a low/medium/none qualitative
|
|
176
|
+
# band) do not reach the CVSS>=7.0 gate that reddens the build -- they
|
|
177
|
+
# are out of this skill's scope and are excluded, not surfaced.
|
|
178
|
+
if score is not None:
|
|
179
|
+
if score < THRESHOLD:
|
|
180
|
+
continue
|
|
181
|
+
elif severity not in _HIGH_BANDS:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# At/above HIGH from here on.
|
|
185
|
+
if not _CVE_ID.match(advisory_id):
|
|
186
|
+
# Real, high-severity, but no CVE id (e.g. a HIGH/CRITICAL GHSA or
|
|
187
|
+
# NPM advisory): this skill only remediates CVEs, so it cannot fix
|
|
188
|
+
# it -- route to `unparsed` (a hard stop) rather than converge to
|
|
189
|
+
# "0 findings" on a build that is red because of it.
|
|
190
|
+
_add_unparsed(unparsed, seen_unparsed, advisory_id, dep, pkgurl, sha1,
|
|
191
|
+
"non-CVE advisory id at/above CVSS 7.0 (not remediated by this skill)")
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
key = (advisory_id, dep)
|
|
195
|
+
if key in seen:
|
|
196
|
+
continue
|
|
197
|
+
seen.add(key)
|
|
198
|
+
description, references, affected = _context(raw_block)
|
|
199
|
+
findings.append({
|
|
200
|
+
"cveId": advisory_id,
|
|
201
|
+
"score": score,
|
|
202
|
+
"severity": severity,
|
|
203
|
+
"dependency": {
|
|
204
|
+
"fileName": dep,
|
|
205
|
+
"packageUrl": pkgurl or None,
|
|
206
|
+
"sha1": sha1 or None,
|
|
207
|
+
},
|
|
208
|
+
"description": description,
|
|
209
|
+
"references": references,
|
|
210
|
+
"affectedVersions": affected,
|
|
211
|
+
})
|
|
212
|
+
return {"findings": findings, "unparsed": unparsed}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _add_unparsed(unparsed, seen, advisory_id, dep, pkgurl, sha1, reason):
|
|
216
|
+
"""Append one deduplicated unparsed entry (same (id, file) never twice)."""
|
|
217
|
+
key = (advisory_id, dep)
|
|
218
|
+
if key in seen:
|
|
219
|
+
return
|
|
220
|
+
seen.add(key)
|
|
221
|
+
unparsed.append({
|
|
222
|
+
"cveId": advisory_id,
|
|
223
|
+
"fileName": dep,
|
|
224
|
+
"packageUrl": pkgurl or None,
|
|
225
|
+
"sha1": sha1 or None,
|
|
226
|
+
"reason": reason,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv):
|
|
231
|
+
ap = argparse.ArgumentParser()
|
|
232
|
+
ap.add_argument("report")
|
|
233
|
+
ap.add_argument("--out")
|
|
234
|
+
ns = ap.parse_args(argv)
|
|
235
|
+
try:
|
|
236
|
+
with open(ns.report, encoding="utf-8") as fh:
|
|
237
|
+
data = parse(fh.read())
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
print(f"parse_report.py: cannot read report: {exc}", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
242
|
+
if ns.out:
|
|
243
|
+
try:
|
|
244
|
+
with open(ns.out, "w", encoding="utf-8") as fh:
|
|
245
|
+
fh.write(text)
|
|
246
|
+
except OSError as exc:
|
|
247
|
+
print(f"parse_report.py: cannot write output: {exc}", file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
else:
|
|
250
|
+
print(text)
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Run: python3 plugins/planetexpress/skills/handle-red-cve-plan/scripts/test_gen_suppression.py"""
|
|
2
|
+
import contextlib
|
|
3
|
+
import datetime
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
10
|
+
_spec = importlib.util.spec_from_file_location(
|
|
11
|
+
"gen_suppression", os.path.join(_here, "gen_suppression.py"))
|
|
12
|
+
_mod = importlib.util.module_from_spec(_spec)
|
|
13
|
+
_spec.loader.exec_module(_mod)
|
|
14
|
+
|
|
15
|
+
PKG = "pkg:maven/org.apache.httpcomponents/httpcore@4.4.16"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestGen(unittest.TestCase):
|
|
19
|
+
def test_pkgurl_to_regex(self):
|
|
20
|
+
self.assertEqual(
|
|
21
|
+
_mod.pkgurl_to_regex(PKG),
|
|
22
|
+
r"pkg:maven/org\.apache\.httpcomponents/httpcore@.*")
|
|
23
|
+
|
|
24
|
+
def test_temporary_has_until(self):
|
|
25
|
+
block = _mod.suppress_block(PKG, ["CVE-2026-54399"], "note", until="2026-09-13")
|
|
26
|
+
self.assertIn('<suppress until="2026-09-13Z">', block)
|
|
27
|
+
self.assertIn("<cve>CVE-2026-54399</cve>", block)
|
|
28
|
+
self.assertIn(r'regex="true">pkg:maven/org\.apache\.httpcomponents/httpcore@.*<', block)
|
|
29
|
+
self.assertIn("<![CDATA[note]]>", block)
|
|
30
|
+
|
|
31
|
+
def test_permanent_omits_until(self):
|
|
32
|
+
block = _mod.suppress_block(PKG, ["CVE-1"], "fp", until=None)
|
|
33
|
+
self.assertIn("<suppress>", block)
|
|
34
|
+
self.assertNotIn("until=", block)
|
|
35
|
+
|
|
36
|
+
def test_multiple_cves(self):
|
|
37
|
+
block = _mod.suppress_block(PKG, ["CVE-1", "CVE-2"], "n", until=None)
|
|
38
|
+
self.assertIn("<cve>CVE-1</cve>", block)
|
|
39
|
+
self.assertIn("<cve>CVE-2</cve>", block)
|
|
40
|
+
|
|
41
|
+
def test_default_until_two_months(self):
|
|
42
|
+
self.assertEqual(_mod.default_until(datetime.date(2026, 7, 13)), "2026-09-13")
|
|
43
|
+
|
|
44
|
+
# -- One suppression per CVE across multiple affected artifacts --------
|
|
45
|
+
|
|
46
|
+
def test_multiple_package_urls_in_one_block(self):
|
|
47
|
+
"""The same CVE affecting several declared artifacts is suppressed
|
|
48
|
+
ONCE, in a single block that lists every affected packageUrl -- not a
|
|
49
|
+
separate block per artifact."""
|
|
50
|
+
pkgs = [
|
|
51
|
+
"pkg:maven/org.apache.httpcomponents/httpcore@4.4.16",
|
|
52
|
+
"pkg:maven/org.apache.httpcomponents.core5/httpcore5@5.4.2",
|
|
53
|
+
]
|
|
54
|
+
block = _mod.suppress_block(pkgs, ["CVE-2026-54399", "CVE-2026-54428"], "n", until="2026-09-13")
|
|
55
|
+
self.assertEqual(block.count("<suppress"), 1)
|
|
56
|
+
self.assertEqual(block.count("<packageUrl"), 2)
|
|
57
|
+
self.assertIn(r'httpcomponents/httpcore@.*<', block)
|
|
58
|
+
self.assertIn(r'httpcomponents\.core5/httpcore5@.*<', block)
|
|
59
|
+
# Each CVE appears exactly once, in this single block.
|
|
60
|
+
self.assertEqual(block.count("<cve>CVE-2026-54399</cve>"), 1)
|
|
61
|
+
self.assertEqual(block.count("<cve>CVE-2026-54428</cve>"), 1)
|
|
62
|
+
|
|
63
|
+
def test_single_pkgurl_string_still_supported(self):
|
|
64
|
+
block = _mod.suppress_block(PKG, ["CVE-1"], "n", until=None)
|
|
65
|
+
self.assertEqual(block.count("<packageUrl"), 1)
|
|
66
|
+
|
|
67
|
+
# -- Fix-3: CDATA safety in notes -------------------------------------
|
|
68
|
+
|
|
69
|
+
def test_notes_with_cdata_close_sequence_is_escaped(self):
|
|
70
|
+
"""A `]]>` inside notes must not be embedded raw -- that sequence
|
|
71
|
+
closes a CDATA section early and would corrupt the XML (any XML
|
|
72
|
+
after it stops being escaped, potentially breaking the document or,
|
|
73
|
+
worse, being interpreted as XML)."""
|
|
74
|
+
notes = "before ]]> after"
|
|
75
|
+
block = _mod.suppress_block(PKG, ["CVE-1"], notes, until=None)
|
|
76
|
+
self.assertNotIn("before ]]> after", block)
|
|
77
|
+
self.assertIn("before ]]]]><![CDATA[> after", block)
|
|
78
|
+
# The only unescaped "]]>" left must be the CDATA section's own close.
|
|
79
|
+
self.assertEqual(block.count("]]>"), block.count("<![CDATA["))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# -- Long notes are wrapped onto their own lines ----------------------
|
|
83
|
+
|
|
84
|
+
def test_long_notes_are_wrapped_multiline(self):
|
|
85
|
+
notes = (
|
|
86
|
+
"CVE-2026-54399 / CVE-2026-54428 are Apache HttpComponents Core 5.x findings "
|
|
87
|
+
"(advisory: 5.4.2 and earlier, 5.5-beta1 and earlier). The flagged artifact is "
|
|
88
|
+
"httpcore 4.4.16 (transitive via jersey-apache-connector), the final 4.x release "
|
|
89
|
+
"with no further 4.x fix. Over-match of the NVD CPE range onto the 4.x artifact."
|
|
90
|
+
)
|
|
91
|
+
block = _mod.suppress_block(PKG, ["CVE-2026-54399"], notes, until="2026-09-13")
|
|
92
|
+
cdata = block.split("<![CDATA[", 1)[1].split("]]>", 1)[0]
|
|
93
|
+
self.assertIn("\n", cdata) # laid out over multiple lines
|
|
94
|
+
# CVE ids and version strings are never split across a line break.
|
|
95
|
+
for token in ("CVE-2026-54399", "CVE-2026-54428", "5.5-beta1"):
|
|
96
|
+
self.assertIn(token, cdata)
|
|
97
|
+
self.assertNotIn("\n".join(token.split("-")), cdata)
|
|
98
|
+
|
|
99
|
+
def test_short_notes_stay_inline(self):
|
|
100
|
+
block = _mod.suppress_block(PKG, ["CVE-1"], "short note", until=None)
|
|
101
|
+
self.assertIn("<notes><![CDATA[short note]]></notes>", block)
|
|
102
|
+
|
|
103
|
+
def test_caller_newlines_preserved_as_paragraphs(self):
|
|
104
|
+
block = _mod.suppress_block(PKG, ["CVE-1"], "line one\nline two", until=None)
|
|
105
|
+
cdata = block.split("<![CDATA[", 1)[1].split("]]>", 1)[0]
|
|
106
|
+
self.assertIn("line one", cdata)
|
|
107
|
+
self.assertIn("line two", cdata)
|
|
108
|
+
self.assertIn("\n", cdata)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestPkgurlToRegexNoDeadCode(unittest.TestCase):
|
|
112
|
+
# -- Fix-4: dead-code cleanup regression guard ------------------------
|
|
113
|
+
|
|
114
|
+
def test_pkgurl_to_regex_still_matches_unchanged(self):
|
|
115
|
+
"""re.escape() doesn't escape `/` or `:` on Python >= 3.7, so the
|
|
116
|
+
old .replace(r"\\/","/").replace(r"\\:",":") calls were no-ops.
|
|
117
|
+
Removing them must not change behaviour."""
|
|
118
|
+
self.assertEqual(
|
|
119
|
+
_mod.pkgurl_to_regex(PKG),
|
|
120
|
+
r"pkg:maven/org\.apache\.httpcomponents/httpcore@.*")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestCli(unittest.TestCase):
|
|
124
|
+
# -- Fix-2: CLI must default to TEMPORARY, not permanent -------------
|
|
125
|
+
|
|
126
|
+
def _run(self, argv):
|
|
127
|
+
out = io.StringIO()
|
|
128
|
+
with contextlib.redirect_stdout(out):
|
|
129
|
+
rc = _mod.main(argv)
|
|
130
|
+
return rc, out.getvalue()
|
|
131
|
+
|
|
132
|
+
def test_no_until_no_permanent_defaults_to_temporary(self):
|
|
133
|
+
rc, out = self._run(["--pkgurl", PKG, "--cve", "CVE-2026-1", "--notes", "n"])
|
|
134
|
+
self.assertEqual(rc, 0)
|
|
135
|
+
self.assertIn('until="', out)
|
|
136
|
+
|
|
137
|
+
def test_permanent_flag_omits_until(self):
|
|
138
|
+
rc, out = self._run(
|
|
139
|
+
["--pkgurl", PKG, "--cve", "CVE-2026-1", "--notes", "fp", "--permanent"])
|
|
140
|
+
self.assertEqual(rc, 0)
|
|
141
|
+
self.assertIn("<suppress>", out)
|
|
142
|
+
self.assertNotIn("until=", out)
|
|
143
|
+
|
|
144
|
+
def test_explicit_until_is_used(self):
|
|
145
|
+
rc, out = self._run(
|
|
146
|
+
["--pkgurl", PKG, "--cve", "CVE-2026-1", "--notes", "n", "--until", "2026-09-13"])
|
|
147
|
+
self.assertEqual(rc, 0)
|
|
148
|
+
self.assertIn('until="2026-09-13Z"', out)
|
|
149
|
+
|
|
150
|
+
def test_repeated_pkgurl_yields_one_block_many_packageurls(self):
|
|
151
|
+
rc, out = self._run([
|
|
152
|
+
"--pkgurl", "pkg:maven/g/a@1", "--pkgurl", "pkg:maven/g/b@2",
|
|
153
|
+
"--cve", "CVE-2026-1", "--notes", "n",
|
|
154
|
+
])
|
|
155
|
+
self.assertEqual(rc, 0)
|
|
156
|
+
self.assertEqual(out.count("<suppress"), 1)
|
|
157
|
+
self.assertEqual(out.count("<packageUrl"), 2)
|
|
158
|
+
|
|
159
|
+
def test_permanent_and_until_together_is_an_error(self):
|
|
160
|
+
stderr = io.StringIO()
|
|
161
|
+
with contextlib.redirect_stderr(stderr):
|
|
162
|
+
with self.assertRaises(SystemExit) as cm:
|
|
163
|
+
self._run([
|
|
164
|
+
"--pkgurl", PKG, "--cve", "CVE-2026-1", "--notes", "n",
|
|
165
|
+
"--permanent", "--until", "2026-09-13",
|
|
166
|
+
])
|
|
167
|
+
self.assertEqual(cm.exception.code, 2)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
unittest.main()
|