Skip to content

Commit 4876819

Browse files
committed
Harden runtime scanning and CI posture
1 parent bbf3450 commit 4876819

42 files changed

Lines changed: 682 additions & 424 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/audit-python-deps.py

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010

1111
import datetime as dt
1212
import json
13+
import re
1314
import shutil
1415
import subprocess
1516
import sys
17+
import tempfile
1618
from pathlib import Path
1719
from typing import Any
1820

@@ -25,6 +27,12 @@
2527
Path("services/quarantine/requirements.lock"),
2628
]
2729

30+
NO_DEPS_REQUIREMENT_FILES = [
31+
(Path("files/scripts/diffusion-cuda.lock"), frozenset()),
32+
(Path("files/scripts/diffusion-rocm.lock"), frozenset()),
33+
(Path("files/scripts/diffusion-cpu.lock"), frozenset({"torch"})),
34+
]
35+
2836
WAIVERS_FILE = Path(".github/vuln-waivers.json")
2937

3038

@@ -60,12 +68,39 @@ def extract_findings(data: Any) -> list[tuple[str, str, str]]:
6068
return findings
6169

6270

63-
def run_audit(req: Path) -> tuple[int, Any | None, str]:
71+
def strip_requirement_blocks(req: Path, package_names: frozenset[str]) -> Path:
72+
"""Return an auditable copy with selected pip-compile package blocks removed."""
73+
if not package_names:
74+
return req
75+
76+
package_re = re.compile(r"^([A-Za-z0-9_.-]+)==")
77+
output = tempfile.NamedTemporaryFile(
78+
"w",
79+
encoding="utf-8",
80+
delete=False,
81+
prefix=f"{req.stem}-audit-",
82+
suffix=".txt",
83+
)
84+
skip = False
85+
with output:
86+
for line in req.read_text(encoding="utf-8").splitlines():
87+
match = package_re.match(line)
88+
if match:
89+
name = match.group(1).lower().replace("_", "-")
90+
skip = name in package_names
91+
if not skip:
92+
output.write(line + "\n")
93+
return Path(output.name)
94+
95+
96+
def run_audit(req: Path, *, no_deps: bool = False) -> tuple[int, Any | None, str]:
97+
extra_args = ["--no-deps", "--disable-pip"] if no_deps else []
6498
proc = subprocess.run(
6599
[
66100
*pip_audit_cmd(),
67101
"--strict",
68102
"--desc",
103+
*extra_args,
69104
"-r",
70105
str(req),
71106
"-f",
@@ -83,6 +118,45 @@ def run_audit(req: Path) -> tuple[int, Any | None, str]:
83118
return proc.returncode, data, stderr
84119

85120

121+
def audit_one(
122+
req: Path,
123+
waivers: set[str],
124+
*,
125+
no_deps: bool = False,
126+
label: Path | None = None,
127+
) -> int:
128+
display = label or req
129+
code, data, stderr = run_audit(req, no_deps=no_deps)
130+
if data is None:
131+
print(f"::error::{display}: pip-audit produced no parseable JSON")
132+
if stderr:
133+
print(stderr)
134+
return 1
135+
136+
errors = 0
137+
findings = extract_findings(data)
138+
for package, vuln_id, description in findings:
139+
if vuln_id in waivers:
140+
print(f"WAIVED: {display}: {package} {vuln_id}")
141+
else:
142+
print(f"::error::{display}: {package}: {vuln_id} - {description}")
143+
errors += 1
144+
145+
if errors:
146+
return errors
147+
if findings:
148+
print(f"OK: all findings waived for {display}")
149+
else:
150+
print(f"OK: no vulnerabilities in {display}")
151+
152+
if code not in (0, 1):
153+
print(f"::error::{display}: pip-audit failed with exit code {code}")
154+
if stderr:
155+
print(stderr)
156+
return 1
157+
return 0
158+
159+
86160
def main() -> int:
87161
waivers = load_waivers()
88162
errors = 0
@@ -94,35 +168,23 @@ def main() -> int:
94168
errors += 1
95169
continue
96170

97-
code, data, stderr = run_audit(req)
98-
if data is None:
99-
print(f"::error::{req}: pip-audit produced no parseable JSON")
100-
if stderr:
101-
print(stderr)
102-
errors += 1
103-
continue
171+
errors += audit_one(req, waivers)
104172

105-
findings = extract_findings(data)
106-
unwaived = 0
107-
for package, vuln_id, description in findings:
108-
if vuln_id in waivers:
109-
print(f"WAIVED: {req}: {package} {vuln_id}")
110-
else:
111-
print(f"::error::{req}: {package}: {vuln_id} - {description}")
112-
unwaived += 1
113-
114-
if unwaived:
115-
errors += unwaived
116-
elif findings:
117-
print(f"OK: all findings waived for {req}")
118-
else:
119-
print(f"OK: no vulnerabilities in {req}")
120-
121-
if code not in (0, 1):
122-
print(f"::error::{req}: pip-audit failed with exit code {code}")
123-
if stderr:
124-
print(stderr)
173+
for req, skipped_packages in NO_DEPS_REQUIREMENT_FILES:
174+
print(f"=== pip-audit {req} (no-deps) ===")
175+
if not req.exists():
176+
print(f"::error::{req} is missing")
125177
errors += 1
178+
continue
179+
audit_path = strip_requirement_blocks(req, skipped_packages)
180+
if skipped_packages:
181+
skipped = ", ".join(sorted(skipped_packages))
182+
print(f"NOTE: {req}: skipped non-PyPI local-version package(s): {skipped}")
183+
try:
184+
errors += audit_one(audit_path, waivers, no_deps=True, label=req)
185+
finally:
186+
if audit_path != req:
187+
audit_path.unlink(missing_ok=True)
126188

127189
if errors:
128190
print(f"FAIL: {errors} Python dependency audit error(s)")

.github/vuln-waivers.json

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,5 @@
11
{
22
"_comment": "Vulnerability waivers for CI dependency-audit job. Each entry documents a reviewed finding that is temporarily accepted. Waivers MUST include: id, reason, reviewer, expires (YYYY-MM-DD). Expired waivers are ignored and the finding will fail CI again.",
33
"go": [],
4-
"python": [
5-
{
6-
"id": "GHSA-5239-wwwm-4pmq",
7-
"package": "pygments",
8-
"reason": "ReDoS in AdlLexer — local-only exploit, not reachable from our usage (no archetype syntax highlighting). Awaiting upstream fix.",
9-
"reviewer": "sec_ai",
10-
"expires": "2026-06-27"
11-
},
12-
{
13-
"id": "GHSA-gc5v-m9x4-r6x2",
14-
"package": "requests",
15-
"reason": "Predictable temp path in extract_zipped_paths() — we do not call this function. Standard requests usage is not affected per advisory.",
16-
"reviewer": "sec_ai",
17-
"expires": "2026-06-27"
18-
}
19-
]
4+
"python": []
205
}

.github/workflows/ci.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2828
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
2929
with:
30-
go-version: "1.25.10"
30+
go-version: "1.26.3"
3131
cache-dependency-path: services/${{ matrix.service }}/go.sum
3232

3333
- name: Build
@@ -268,7 +268,7 @@ jobs:
268268
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
269269
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
270270
with:
271-
go-version: "1.25.10"
271+
go-version: "1.26.3"
272272

273273
- name: Install cosign (signing & attestation)
274274
run: |
@@ -457,7 +457,7 @@ jobs:
457457

458458
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
459459
with:
460-
go-version: "1.25.10"
460+
go-version: "1.26.3"
461461

462462
- name: Install Python dependencies
463463
run: pip install -r requirements-ci.txt
@@ -487,7 +487,7 @@ jobs:
487487

488488
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
489489
with:
490-
go-version: "1.25.10"
490+
go-version: "1.26.3"
491491

492492
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
493493
with:
@@ -509,7 +509,7 @@ jobs:
509509

510510
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
511511
with:
512-
go-version: "1.25.10"
512+
go-version: "1.26.3"
513513

514514
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
515515
with:
@@ -820,7 +820,7 @@ jobs:
820820

821821
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
822822
with:
823-
go-version: "1.25.10"
823+
go-version: "1.26.3"
824824

825825
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
826826
with:

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ jobs:
8686
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
8787
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
8888
with:
89-
go-version: "1.25.10"
89+
go-version: "1.26.3"
9090
cache-dependency-path: services/${{ matrix.service }}/go.sum
9191

9292
- name: Build (linux/amd64)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
From fe5bddd7ac531140101cdf82aa952c7e3dd6c97d Mon Sep 17 00:00:00 2001
2+
From: Seth Larson <seth@python.org>
3+
Date: Wed, 22 Apr 2026 14:22:31 -0500
4+
Subject: [PATCH] gh-90309: Base64-encode cookie values embedded in JS (cherry
5+
picked from commit 76b3923d688c0efc580658476c5f525ec8735104)
6+
7+
Co-authored-by: Seth Larson <seth@python.org>
8+
---
9+
Lib/http/cookies.py | 8 +++--
10+
Lib/test/test_http_cookies.py | 29 ++++++++++++-------
11+
...6-04-21-13-46-30.gh-issue-90309.srvj9q.rst | 3 ++
12+
3 files changed, 27 insertions(+), 13 deletions(-)
13+
create mode 100644 Misc/NEWS.d/next/Security/2026-04-21-13-46-30.gh-issue-90309.srvj9q.rst
14+
15+
diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py
16+
index d5b8ba939bee7c..5c5b14788dc2f0 100644
17+
--- a/Lib/http/cookies.py
18+
+++ b/Lib/http/cookies.py
19+
@@ -391,17 +391,21 @@ def __repr__(self):
20+
return '<%s: %s>' % (self.__class__.__name__, self.OutputString())
21+
22+
def js_output(self, attrs=None):
23+
+ import base64
24+
# Print javascript
25+
output_string = self.OutputString(attrs)
26+
if _has_control_character(output_string):
27+
raise CookieError("Control characters are not allowed in cookies")
28+
+ # Base64-encode value to avoid template
29+
+ # injection in cookie values.
30+
+ output_encoded = base64.b64encode(output_string.encode('utf-8')).decode("ascii")
31+
return """
32+
<script type="text/javascript">
33+
<!-- begin hiding
34+
- document.cookie = \"%s\";
35+
+ document.cookie = atob(\"%s\");
36+
// end hiding -->
37+
</script>
38+
- """ % (output_string.replace('"', r'\"'))
39+
+ """ % (output_encoded,)
40+
41+
def OutputString(self, attrs=None):
42+
# Build up our result
43+
diff --git a/Lib/test/test_http_cookies.py b/Lib/test/test_http_cookies.py
44+
index 33da395717deb0..4884b07c95b9c5 100644
45+
--- a/Lib/test/test_http_cookies.py
46+
+++ b/Lib/test/test_http_cookies.py
47+
@@ -1,5 +1,5 @@
48+
# Simple test suite for http/cookies.py
49+
-
50+
+import base64
51+
import copy
52+
import unittest
53+
import doctest
54+
@@ -152,17 +152,19 @@ def test_load(self):
55+
56+
self.assertEqual(C.output(['path']),
57+
'Set-Cookie: Customer="WILE_E_COYOTE"; Path=/acme')
58+
- self.assertEqual(C.js_output(), r"""
59+
+ cookie_encoded = base64.b64encode(b'Customer="WILE_E_COYOTE"; Path=/acme; Version=1').decode('ascii')
60+
+ self.assertEqual(C.js_output(), fr"""
61+
<script type="text/javascript">
62+
<!-- begin hiding
63+
- document.cookie = "Customer=\"WILE_E_COYOTE\"; Path=/acme; Version=1";
64+
+ document.cookie = atob("{cookie_encoded}");
65+
// end hiding -->
66+
</script>
67+
""")
68+
- self.assertEqual(C.js_output(['path']), r"""
69+
+ cookie_encoded = base64.b64encode(b'Customer="WILE_E_COYOTE"; Path=/acme').decode('ascii')
70+
+ self.assertEqual(C.js_output(['path']), fr"""
71+
<script type="text/javascript">
72+
<!-- begin hiding
73+
- document.cookie = "Customer=\"WILE_E_COYOTE\"; Path=/acme";
74+
+ document.cookie = atob("{cookie_encoded}");
75+
// end hiding -->
76+
</script>
77+
""")
78+
@@ -267,17 +269,19 @@ def test_quoted_meta(self):
79+
80+
self.assertEqual(C.output(['path']),
81+
'Set-Cookie: Customer="WILE_E_COYOTE"; Path=/acme')
82+
- self.assertEqual(C.js_output(), r"""
83+
+ expected_encoded_cookie = base64.b64encode(b'Customer=\"WILE_E_COYOTE\"; Path=/acme; Version=1').decode('ascii')
84+
+ self.assertEqual(C.js_output(), fr"""
85+
<script type="text/javascript">
86+
<!-- begin hiding
87+
- document.cookie = "Customer=\"WILE_E_COYOTE\"; Path=/acme; Version=1";
88+
+ document.cookie = atob("{expected_encoded_cookie}");
89+
// end hiding -->
90+
</script>
91+
""")
92+
- self.assertEqual(C.js_output(['path']), r"""
93+
+ expected_encoded_cookie = base64.b64encode(b'Customer=\"WILE_E_COYOTE\"; Path=/acme').decode('ascii')
94+
+ self.assertEqual(C.js_output(['path']), fr"""
95+
<script type="text/javascript">
96+
<!-- begin hiding
97+
- document.cookie = "Customer=\"WILE_E_COYOTE\"; Path=/acme";
98+
+ document.cookie = atob("{expected_encoded_cookie}");
99+
// end hiding -->
100+
</script>
101+
""")
102+
@@ -368,13 +372,16 @@ def test_setter(self):
103+
self.assertEqual(
104+
M.output(),
105+
"Set-Cookie: %s=%s; Path=/foo" % (i, "%s_coded_val" % i))
106+
+ expected_encoded_cookie = base64.b64encode(
107+
+ ("%s=%s; Path=/foo" % (i, "%s_coded_val" % i)).encode("ascii")
108+
+ ).decode('ascii')
109+
expected_js_output = """
110+
<script type="text/javascript">
111+
<!-- begin hiding
112+
- document.cookie = "%s=%s; Path=/foo";
113+
+ document.cookie = atob("%s");
114+
// end hiding -->
115+
</script>
116+
- """ % (i, "%s_coded_val" % i)
117+
+ """ % (expected_encoded_cookie,)
118+
self.assertEqual(M.js_output(), expected_js_output)
119+
for i in ["foo bar", "foo@bar"]:
120+
# Try some illegal characters
121+
diff --git a/Misc/NEWS.d/next/Security/2026-04-21-13-46-30.gh-issue-90309.srvj9q.rst b/Misc/NEWS.d/next/Security/2026-04-21-13-46-30.gh-issue-90309.srvj9q.rst
122+
new file mode 100644
123+
index 00000000000000..d7d376737e4ad1
124+
--- /dev/null
125+
+++ b/Misc/NEWS.d/next/Security/2026-04-21-13-46-30.gh-issue-90309.srvj9q.rst
126+
@@ -0,0 +1,3 @@
127+
+Base64-encode values when embedding cookies to JavaScript using the
128+
+:meth:`http.cookies.BaseCookie.js_output` method to avoid injection
129+
+and escaping.

0 commit comments

Comments
 (0)