Skip to content

ci: run pdp-tester via Docker runtime instead of k3d - #319

Merged
dshoen619 merged 6 commits into
mainfrom
david/fix-pdp-tester-ci-docker-runtime
Jul 8, 2026
Merged

ci: run pdp-tester via Docker runtime instead of k3d#319
dshoen619 merged 6 commits into
mainfrom
david/fix-pdp-tester-ci-docker-runtime

Conversation

@dshoen619

Copy link
Copy Markdown
Contributor

Problem

The pdp-tester CI job fails at Start k3d cluster on every PR:

Downloading k3d@v5.4.6 ... curl: (22) The requested URL returned error: 404
Failed to install k3d

AbsaOSS/k3d-action@v2.4.0 defaults to k3d v5.4.6, whose GitHub release has no checksums.txt. k3d's install.sh now SHA256-verifies the downloaded binary against that file, so the download 404s. It's environmental (the job passed on main in May) and now blocks the tester on all branches.

Fix

Switch pdp-tester to the tester's Docker runtime backend (--docker, via aiodocker), which runs the PDP as a local container — no k3d / Helm / kubectl. This mirrors pdp-tester's own pdp-tester-docker CI job (the runtime the maintainers recommend for CI).

  • pip install -e ".[docker]" then python -m pdp_tester --docker --local --tag next --skip-generate
  • LOCAL_IMAGE=permitio/pdp-v2 pins it to the locally built permitio/pdp-v2:next image (loaded from the build-pdp-image artifact) — nothing pulled from Docker Hub
  • Kept START_TIMEOUT=180 (matching the old Helm setting) + a docker logs post-mortem step
  • Net: 34 insertions, 62 deletions

Verified

Green on the branch where this originated (#318). Job logs show:

Using local image: permitio/pdp-v2
Launching 1 PDPs of 1 versions: ['next']
GET /healthy ... 200 OK
All 24 tests passed! 🎉

(~2m30s). Split out from #318 so it lands on main and applies to all PRs.

🤖 Generated with Claude Code

The pdp-tester job fails at the "Start k3d cluster" step on every PR:
  Downloading k3d@v5.4.6 ... curl: (22) ... error: 404
  Failed to install k3d

AbsaOSS/k3d-action@v2.4.0 defaults to k3d v5.4.6, whose GitHub release has
no checksums.txt; k3d's install.sh now SHA256-verifies the downloaded binary
against that file, so the download 404s. This is environmental (the job
passed on main in May) and now breaks pdp-tester on all branches.

Switch the job to the pdp-tester Docker runtime backend (aiodocker), which
launches the PDP as a local container with no k3d/Helm/kubectl -- mirroring
pdp-tester's own `pdp-tester-docker` CI job. LOCAL_IMAGE pins it to the
locally built permitio/pdp-v2:next image (loaded from the build artifact) so
nothing is pulled from Docker Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the pdp-tester GitHub Actions job to avoid k3d/Kubernetes setup failures by running pdp-tester against the locally-built PDP image using the Docker runtime backend.

Changes:

  • Replace k3d + Helm deployment flow with pdp-tester’s --docker --local execution path.
  • Install pdp-tester with its Docker extra (pip install -e ".[docker]") and run tests against permitio/pdp-v2:next loaded from the workflow artifact.
  • Add an always-run post-mortem step that lists PDP-managed containers and tails their logs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +134 to 139
set -o pipefail
python -m pdp_tester --docker --local --tag next --skip-generate 2>&1 | tee /tmp/tester.log
if grep -q "test cases failed" /tmp/tester.log; then
echo "::error::Some test cases failed!"
exit 1
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d6c7cc4. The tester's exit status is now captured explicitly with || status=$? so -e no longer aborts the step before /tmp/tester.log is parsed. We always run the grep check (emitting the Some test cases failed! annotation) and then fail on a non-zero exit status with a distinct annotation.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:ca635a014e3f3aed42c5e673918d3bf74367bafc465090271a44d8bbe2c31150
vulnerabilitiescritical: 0 high: 3 medium: 6 low: 1
platformlinux/amd64
size218 MB
packages247
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 11 low: 4
critical: 0 high: 2 medium: 2 low: 1 starlette 0.50.0 (pypi)

pkg:pypi/starlette@0.50.0

high 7.5: CVE--2026--54283 Allocation of Resources Without Limits or Throttling

Affected range>=0.4.1
<1.3.1
Fixed version1.3.1
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.275%
EPSS Percentile19th percentile
Description

Summary

request.form() accepts max_fields and max_part_size to bound resource consumption while parsing form data. These limits are enforced for multipart/form-data, but silently ignored for application/x-www-form-urlencoded. An unauthenticated attacker can therefore send a urlencoded body with an arbitrarily large number of fields or an arbitrarily large field, even when the application configured limits it believed would apply.

Details

request.form() dispatches to a different parser depending on the Content-Type. For multipart/form-data the max_files, max_fields, and max_part_size limits are forwarded to the parser, but for application/x-www-form-urlencoded the parser is constructed without them. It has no max_fields or max_part_size parameter to receive them, and it appends every field with no count check and accumulates each field's name and value with no size check. The configured limits are therefore both unreachable and unenforced for url-encoded bodies.

Because the url-encoded parser does its work synchronously between stream reads, the two attack shapes have different effects:

  • Field count drives CPU and event-loop blocking. A body of ~1,000,000 fields (a sub-10MB payload such as f0=v&f1=v&...) blocks the worker's event loop for several seconds while parsing, during which the worker serves no other request.
  • Field size drives memory. A single large field value (e.g. a 50MB value) is buffered in full to build the FormData, forcing memory allocation proportional to the request body.

The equivalent multipart/form-data request is correctly rejected with 400 Too many fields / 400 Field exceeded maximum size.

Impact

This Denial of service (DoS) vulnerability affects all applications built with Starlette (or FastAPI) that call request.form() on application/x-www-form-urlencoded requests. A single request with a very large number of fields blocks the event loop for several seconds, and a single request with a very large field forces unbounded memory allocation; in either case, parallel requests can render the service unusable. A reverse proxy that enforces a request body size limit reduces but does not eliminate the exposure, since a sub-10MB body is already enough to block the event loop.

Mitigation

Upgrade to a patched version, which forwards max_fields and max_part_size to the url-encoded parser and enforces them while parsing, raising before the oversized field or excess fields are accumulated. The defaults match multipart/form-data (max_fields=1000, max_part_size=1MB) and can be customized via request.form(max_fields=..., max_part_size=...).

high 7.5: CVE--2026--48818 Server-Side Request Forgery (SSRF)

Affected range<1.1.0
Fixed version1.1.0
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS Score0.368%
EPSS Percentile29th percentile
Description

Summary

When serving static files on Windows, StaticFiles resolves the requested path with os.path.realpath. If a UNC path (such as \\attacker.com\share) reaches the resolver, realpath causes the process to open a connection to the remote host over SMB (port 445). This is a server-side request forgery (SSRF) that leaks the service account's NTLMv2 credentials to the attacker-controlled host, which can then be cracked offline or relayed to other hosts.

Details

StaticFiles.lookup_path() joins the requested path onto the served directory and calls os.path.realpath on the result before checking containment with os.path.commonpath. On Windows, a UNC path is absolute, so os.path.join discards the served directory and realpath resolves the bare UNC path, triggering the outbound SMB connection and NTLM authentication before the containment check rejects the path. The HTTP response is a benign 404, but the credential disclosure has already happened. POSIX systems are not affected.

This only affects the default configuration (follow_symlink=False), which uses os.path.realpath. The follow_symlink=True branch uses os.path.abspath, which performs no I/O.

Impact

Applications running on Windows that serve files with StaticFiles (directly, or via a framework built on Starlette such as FastAPI) in the default configuration are affected. StaticFiles is typically unauthenticated, so any client can trigger the SMB connection and leak the service account's NTLMv2 hash. A secondary impact is discovering internal hosts reachable over SMB by timing responses for valid versus invalid addresses.

Mitigation

Applications not running on Windows are not affected. On Windows, serving static files through a dedicated web server (such as nginx or IIS) instead of StaticFiles avoids the issue. Blocking outbound SMB (port 445) from the application host prevents the credential disclosure even if a UNC path is resolved.

medium 6.5: CVE--2026--48710 Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

Affected range<=1.0.0
Fixed version1.0.1
CVSS Score6.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
EPSS Score1.438%
EPSS Percentile70th percentile
Description

Summary

In affected versions, the HTTP Host request header was not validated before being used to reconstruct request.url. Because the routing algorithm relies on the raw HTTP path while request.url is rebuilt from the Host header, a malformed header could make request.url.path differ from the path that was actually requested. Middleware and endpoints that apply security restrictions based on request.url (rather than the raw scope path) could therefore be bypassed.

Details

When a client requests http://example.com/foo, it sends:

GET /foo HTTP/1.1
Host: example.com

Affected versions reconstructed the URL by concatenating http://{host}{path} and re-parsing the result. The Host value is only valid as a uri-host [ ":" port ] per RFC 9112 §3.2, where uri-host follows the restricted host grammar of RFC 3986 §3.2.2. When it contains characters outside that grammar - notably /, ?, or # - those characters move the path/query/fragment boundaries during re-parsing, so the parsed request.url.path no longer matches the path the server actually received. For example:

GET /foo HTTP/1.1
Host: example.com/abc?bar=

reconstructs to http://example.com/abc?bar=/foo, whose parsed path is /abc - even though routing used the real path /foo. The router still dispatches to /foo and the endpoint executes, but any middleware or code that reads request.url.path sees /abc, so path-based authorization checks can be bypassed.

Impact

Any application running an affected version that relies on request.url (or request.url.path) for security-sensitive decisions is affected. The most common case is middleware that gates access to certain path prefixes based on request.url.path. Deployments fronted by a proxy or load balancer are mitigated only if that proxy rejects or normalizes the malformed Host header before forwarding and the application does not trust attacker-controlled host headers (e.g. X-Forwarded-Host) elsewhere.

Mitigation

Upgrade to a patched version, which validates the Host header against the grammar of RFC 9112 §3.2 / RFC 3986 §3.2.2 when constructing request.url and falls back to scope["server"] for malformed values.

medium 5.3: CVE--2026--48817 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')

Affected range<1.1.0
Fixed version1.1.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.213%
EPSS Percentile12th percentile
Description

Summary

When dispatching a request, HTTPEndpoint selects the handler by lowercasing the HTTP method and looking it up as an attribute with getattr, without restricting the lookup to a known set of HTTP verbs.

When an HTTPEndpoint subclass is registered through Route(...) without an explicit methods= argument, the route does not constrain the method and every method reaches the endpoint. If a non-standard HTTP method whose lowercased name matches an attribute on the endpoint subclass reaches the endpoint, that attribute is invoked as if it were a request handler. An attacker can use this to reach methods that were never meant to be HTTP handlers, such as internal helpers, without the authorization checks applied by the intended public handler.

Details

HTTPEndpoint uses the client-supplied method name to resolve an instance attribute, without validating it against the set of HTTP verbs the endpoint supports. A method such as _DO_DELETE therefore resolves an attribute like _do_delete and invokes it. Non-standard methods are valid RFC 9110 token methods, so an endpoint must not treat the method name as a trusted attribute selector.

Impact

An application is affected when all of the following hold:

  • It defines an HTTPEndpoint subclass and registers it via Route(...) without an explicit methods= argument.
  • The subclass defines additional methods whose names match a non-standard HTTP-method token shape and that accept a single request argument and return a response.

This also affects frameworks built on Starlette, like FastAPI.

Mitigation

Register HTTPEndpoint subclasses with an explicit methods= argument on the Route, listing only the HTTP verbs the endpoint supports. The route then rejects any other method with 405 Method Not Allowed before it reaches the endpoint, so non-standard methods cannot resolve an attribute.

low 3.7: CVE--2026--54282 Improper Input Validation

Affected range<1.3.0
Fixed version1.3.0
CVSS Score3.7
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N
EPSS Score0.187%
EPSS Percentile8th percentile
Description

Summary

In affected versions, the HTTP request path is not validated before being used to reconstruct request.url. Because request.url is rebuilt by concatenating {scheme}://{host}{path} and re-parsing the result, a path that does not begin with / (for example @<!-- -->google.com) moves the authority boundary during re-parsing, so request.url.hostname and request.url.netloc become attacker-controlled. Code that reads request.url.hostname (rather than the Host header or scope) can therefore be misled into trusting an attacker-supplied host.

Details

When a client requests a path that does not start with /:

GET @<!-- -->google.com HTTP/1.1
Host: localhost

affected versions reconstruct the URL as http://localhost@<!-- -->google.com. Per RFC 3986 §3.2.1, the substring before @ in the authority is userinfo, so re-parsing yields username = "localhost" and hostname = "google.com", with an empty path:

request.url          == "http://localhost@<!-- -->google.com"
request.url.hostname == "google.com"
request.url.path     == ""

The root cause is that the path is concatenated directly after the host without a separating /, and without validating that it begins with one. Only the Host header was validated when constructing request.url; the path was not.

This requires an ASGI server that forwards a request-target lacking a leading / into scope["path"].

Impact

Any application running an affected version that uses request.url, request.url.netloc, or request.url.hostname for a security-sensitive decision (host-based authorization, redirect/callback base, SSRF target, cache key, audit log) may be affected, when no fronting proxy or load balancer rejects the malformed request-target first.

Note that this is less exploitable than GHSA-86qp-5c8j-p5mr: there, the poison is carried in the Host header, so the real path still routes to a valid endpoint while request.url.path lies. Here, the poison must be carried in the path itself, and that path (@<!-- -->google.com) does not match any registered route, so routing returns 404 and no endpoint handler runs. The exposure is limited to code that reads request.url before routing - notably middleware - or in 404/exception handlers.

Mitigation

Upgrade to a patched version, which prevents the request path from crossing into the URL authority. The request above instead yields http://localhost/@<!-- -->google.com with request.url.hostname == "localhost".

critical: 0 high: 1 medium: 0 low: 0 oras.land/oras-go/v2 2.6.1 (golang)

pkg:golang/oras.land/oras-go/v2@2.6.1

high 7.1: CVE--2026--50163 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Affected range<=2.6.1
Fixed versionNot Fixed
CVSS Score7.1
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N
Description

Root cause

The tar-extraction helper ensureLinkPath at content/file/utils.go:262-275 validates that a hardlink's target resolves inside the extract base, but then returns the original unresolved target string back to the caller:

func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)  // resolved FOR VALIDATION
    }
    if _, err := resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", err
    }
    return target, nil   // <-- returns the ORIGINAL target, not the validated path
}

The caller for TypeLink hardlinks then does:

case tar.TypeLink:
    var target string
    if target, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(target, filePath)
    }

os.Link(oldname, newname) wraps the link(2) system call. From the link(2) man page:

oldpath and newpath are interpreted relative to the current working directory of the calling process.

So when target (i.e., header.Linkname) is a relative path, os.Link resolves it against the process's current working directory, not against filepath.Dir(link) as the validation assumed.

Attack

An attacker who controls an OCI-compliant registry (or any artifact source the victim consumes via oras pull) crafts a tarball layer with:

  • A regular file: payload.tar.gz/README.txt.
  • A hardlink entry: Typeflag=TypeLink, Name=payload.tar.gz/evil_cwd_link, Linkname="victim.secret" (relative).

and marks the layer descriptor with io.deis.oras.content.unpack: "true" (a standard annotation that tells oras-go to auto-extract).

When a victim runs oras pull (or any Go code using content.File), the extraction:

  1. Validates payload.tar.gz/evil_cwd_link — passes.
  2. Calls ensureLinkPath(dirPath, "payload.tar.gz", filePath, "victim.secret"):
    • path = filepath.Join(filepath.Dir(filePath), "victim.secret") = <extract_base>/payload.tar.gz/victim.secret → inside base → validation passes.
    • Returns target = "victim.secret" (NOT path).
  3. Calls os.Link("victim.secret", "<extract_base>/payload.tar.gz/evil_cwd_link").
  4. link(2) resolves relative oldname="victim.secret" against process CWD → creates a hardlink inside the extract tree pointing to <invoker_CWD>/victim.secret.

The resulting hardlink and the CWD file share an inode — reading one reads the other; writing to one writes to the other.


Proof of Concept

Tested on Ubuntu 24.04.4 LTS with oras CLI v1.3.0 (SHA-256 040e140304b7dbdd9b40dacd798e2303cea44ad84eeb210750afdf15f1dcf8b4, downloaded from https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_linux_amd64.tar.gz).

Reproduction script (standalone, ~50 lines) attached. Summary of key steps:

# 1. Place victim file in the future CWD.
mkdir -p cwd-space extract
echo "TOP SECRET FROM CWD" > cwd-space/victim.secret

# 2. Craft malicious tarball with a TypeLink entry whose Linkname is RELATIVE.
python3 -c '
import tarfile, io, os
with tarfile.open("cwd-space/payload.tar.gz", "w:gz", format=tarfile.GNU_FORMAT) as t:
    info = tarfile.TarInfo(name="payload.tar.gz/README.txt")
    c = b"pulled from registry"; info.size = len(c); info.mode = 0o644
    info.uid = os.getuid(); info.gid = os.getgid()
    t.addfile(info, io.BytesIO(c))

    link = tarfile.TarInfo(name="payload.tar.gz/evil_cwd_link")
    link.type = tarfile.LNKTYPE
    link.linkname = "victim.secret"   # RELATIVE
    link.mode = 0o644; link.uid = os.getuid(); link.gid = os.getgid()
    t.addfile(link)
'

# 3. Push to OCI layout, patch in the unpack annotation, pull from cwd-space.
(cd cwd-space && oras push --oci-layout ../layout:v1 \
    payload.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip)
# ... patch layout/blobs/sha256/<manifest> to add
#     io.deis.oras.content.unpack: "true" on layers[0].annotations ...

(cd cwd-space && oras pull --oci-layout ../layout:v1 --output ../extract)

# 4. Observe inode sharing.
stat -c '%i' extract/payload.tar.gz/evil_cwd_link   # → 6554160
stat -c '%i' cwd-space/victim.secret                # → 6554160 (SAME)
cat extract/payload.tar.gz/evil_cwd_link             # → "TOP SECRET FROM CWD"

Observed output:

evil_cwd_link (inside extract dir): inode=6554160
victim.secret  (in invoker CWD):    inode=6554160
*** ESCAPE CONFIRMED ***
Reading through the extract-dir hardlink yields the CWD file contents:
TOP SECRET FROM CWD

A library-level regression test is also provided (poc_test.go) that drops into content/file/utils_test.go and runs via go test ./content/file/... -run TestPoC — output shows identical inode match for consumers of the library API.


Impact

Primary: arbitrary-CWD-file read primitive. An attacker-controlled OCI artifact, when pulled by a victim using the oras CLI or any Go program using oras-go/v2/content/file, can create a hardlink inside the victim's extract tree pointing to an arbitrary file in the victim's process CWD (that the invoker UID is permitted to read). Reading the extract-tree hardlink yields that file's contents verbatim.

Secondary: inode-sharing tampering primitive. Any tool that later modifies the extract-tree hardlink (write, chmod, truncate, etc.) modifies the CWD file through the shared inode. This violates the "writes inside the extract dir are confined" invariant that downstream tooling (CI systems, container-image builders, artifact scanners) typically depends on.

High-severity chains:

  • CI pipelines where oras pull runs from a project workspace containing secrets/credentials (.env, .git/config, service-account tokens). The pulled artifact can hardlink those secrets into a location later archived/mounted/published.
  • Container orchestration where the extract dir is bind-mounted into a lower-trust container while the pull-invoker's CWD is higher-trust. Hardlinks created in the extract tree expose invoker-CWD files across the trust boundary.
  • Kubernetes operators / Flux source-controller using oras-go to fetch artifacts; their CWD is typically / or /root — very sensitive.
  • Multi-tenant registry proxies that use oras-go to fetch and re-serve artifacts; each proxy process has a CWD with configuration, keys, or per-tenant state.

Not affected:

  • oras push (tarball creation side): tarDirectory in the same file explicitly skips hardlink generation (line 65 comment: "We don't support hard links and treat it as regular files"), so pushed content cannot trigger this on the server.
  • Symlink extraction path (TypeSymlink): os.Symlink stores the target string verbatim and does not CWD-resolve at creation time. The current ensureLinkPath return-of-target is correct for symlinks (the existing validation correctly models the symlink-follow path).

Attack-surface boundary (fs.protected_hardlinks)

On Linux with fs.protected_hardlinks=1 (default on modern distros), link(2) additionally requires the linking user to have READ + WRITE permission on the source file (per may_linkat() in the kernel). Verified on Ubuntu 24.04: as non-root, ln /etc/passwd /tmp/x returns EPERM, and the same via the oras PoC path returns link passwd /tmp/.../evil_passwd: operation not permitted.

So the attacker cannot use this bug to read arbitrary root-owned files (e.g., /etc/shadow) when the victim invokes oras pull as a regular user. The attack surface depends on the invocation context:

Invocation context Reachable file classes
oras pull run by a regular user Any file the user OWNS or has write access to in the process CWD: .env, .git/config, .aws/credentials, ~/.ssh/config, project-local secrets, CI workspace files.
oras pull run as root (systemd without User=, container entrypoint default root, Kubernetes operator) Every file on the host filesystem. /etc/shadow, /root/.ssh/id_rsa, bind-mounted host paths, service private keys.

The user-context attack surface alone is sufficient for supply-chain-grade impact: CI pipelines and developer machines routinely hold API keys, signing keys, and cloud credentials in user-owned files in the working directory. The root-context escalation makes the bug Critical in mainstream Kubernetes/GitOps tooling where oras-go is adopted for artifact distribution.


Proposed fix

Change ensureLinkPath to expose both the verbatim target (for symlinks) and the resolved absolute path (for hardlinks); have the TypeLink case use the resolved path.

// Current behavior preserved for TypeSymlink. TypeLink switches to the resolved
// path to avoid CWD-resolution mismatch at os.Link time.
func ensureLinkPath(baseAbs, baseRel, link, target string) (symlinkTarget, hardlinkPath string, err error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)
    }
    if _, err = resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", "", err
    }
    return target, path, nil
}
case tar.TypeLink:
    var absTarget string
    if _, absTarget, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(absTarget, filePath)
    }
case tar.TypeSymlink:
    var symTarget string
    symTarget, _, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname)
    if err != nil { return err }
    if err = os.Symlink(symTarget, filePath); err != nil { ... }

Regression test to add:

Extend Test_extractTarDirectory_HardLink with a third sub-test that:

  1. Creates a sentinel file in the test's t.TempDir() (or an explicitly os.Chdir-entered directory) with a known name, e.g. sentinel.txt.
  2. Builds a tarball containing a TypeLink entry with Linkname: "sentinel.txt" (relative).
  3. Extracts.
  4. Asserts either extractTarDirectory returned an error, OR the resulting hardlink's inode does NOT match the sentinel's inode.
critical: 0 high: 0 medium: 1 low: 0 sqlparse 0.5.0 (pypi)

pkg:pypi/sqlparse@0.5.0

medium 6.9: GHSA--27jp--wm6q--gp25 Allocation of Resources Without Limits or Throttling

Affected range<=0.5.3
Fixed version0.5.4
CVSS Score6.9
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Description

Summary

The below gist hangs while attempting to format a long list of tuples.

This was found while drafting a regression test for Dja
ngo 5.2's composite primary key feature
, which allows querying composite fields with tuples.

critical: 0 high: 0 medium: 1 low: 0 util-linux 2.41-r9 (apk)

pkg:apk/alpine/util-linux@2.41-r9?os_name=alpine&os_version=3.22

medium : CVE--2026--27456

Affected range<=2.41-r9
Fixed versionNot Fixed
EPSS Score0.118%
EPSS Percentile2nd percentile
Description
critical: 0 high: 0 medium: 1 low: 0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp 1.42.0 (golang)

pkg:golang/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@1.42.0

medium 5.3: CVE--2026--39882 Memory Allocation with Excessive Size Value

Affected range<1.43.0
Fixed version1.43.0
CVSS Score5.3
CVSS VectorCVSS:3.1/AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.190%
EPSS Percentile9th percentile
Description

overview:
this report shows that the otlp HTTP exporters (traces/metrics/logs) read the full HTTP response body into an in-memory bytes.Buffer without a size cap.

this is exploitable for memory exhaustion when the configured collector endpoint is attacker-controlled (or a network attacker can mitm the exporter connection).

severity

HIGH

not claiming: this is a remote dos against every default deployment.
claiming: if the exporter sends traces to an untrusted collector endpoint (or over a network segment where mitm is realistic), that endpoint can crash the process via a large response body.

callsite (pinned):

  • exporters/otlp/otlptrace/otlptracehttp/client.go:199
  • exporters/otlp/otlptrace/otlptracehttp/client.go:230
  • exporters/otlp/otlpmetric/otlpmetrichttp/client.go:170
  • exporters/otlp/otlpmetric/otlpmetrichttp/client.go:201
  • exporters/otlp/otlplog/otlploghttp/client.go:190
  • exporters/otlp/otlplog/otlploghttp/client.go:221

permalinks (pinned):

root cause:
each exporter client reads resp.Body using io.Copy(&respData, resp.Body) into a bytes.Buffer on both success and error paths, with no upper bound.

impact:
a malicious collector can force large transient heap allocations during export (peak memory scales with attacker-chosen response size) and can potentially crash the instrumented process (oom).

affected component:

  • go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
  • go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
  • go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp

repro (local-only):

unzip poc.zip -d poc
cd poc
make canonical resp_bytes=33554432 chunk_delay_ms=0

expected output contains:

[CALLSITE_HIT]: otlptracehttp.UploadTraces::io.Copy(resp.Body)
[PROOF_MARKER]: resp_bytes=33554432 peak_alloc_bytes=118050512

control (same env, patched target):

unzip poc.zip -d poc
cd poc
make control resp_bytes=33554432 chunk_delay_ms=0

expected control output contains:

[CALLSITE_HIT]: otlptracehttp.UploadTraces::io.Copy(resp.Body)
[NC_MARKER]: resp_bytes=33554432 peak_alloc_bytes=512232

attachments: poc.zip (attached)

PR_DESCRIPTION.md

attack_scenario.md

poc.zip

Fixed in: open-telemetry/opentelemetry-go#8108

critical: 0 high: 0 medium: 1 low: 0 busybox 1.37.0-r20 (apk)

pkg:apk/alpine/busybox@1.37.0-r20?os_name=alpine&os_version=3.22

medium : CVE--2025--60876

Affected range<=1.37.0-r20
Fixed versionNot Fixed
EPSS Score0.258%
EPSS Percentile17th percentile
Description

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🔍 Vulnerabilities of permitio/pdp-v2:next

📦 Image Reference permitio/pdp-v2:next
digestsha256:ca635a014e3f3aed42c5e673918d3bf74367bafc465090271a44d8bbe2c31150
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
platformlinux/amd64
size218 MB
packages247
📦 Base Image python:3.10-alpine3.22
also known as
  • 3.10.20-alpine3.22
digestsha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd
vulnerabilitiescritical: 1 high: 13 medium: 11 low: 4

The default GitHub Actions shell runs with -e, so with pipefail a
non-zero exit from pdp_tester aborted the step before the log was
parsed, meaning the ::error:: annotation never ran. Capture the exit
status explicitly so we always parse the log and fail consistently.

Addresses Copilot review comment on PR #319.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

PER-15361

Addresses the code review of the Docker-runtime pdp-tester job:

- timeout-minutes: 15 on the job. The old k3d path was bounded by
  `kubectl wait --timeout=600s`; the tester's own max_running_time only
  applies in interval mode, so run-once CI had no cap and a hung run
  would hold the runner until GitHub's 360-minute default.
- Cache pip deps on setup-python (keyed on pdp-tester/pyproject.toml)
  so runs don't re-download from PyPI and survive transient outages.
- Post-mortem step: hoist the label filter to a variable, iterate over
  container names directly (dropping the per-container `docker inspect
  | sed`), and truncate at the daemon with `docker logs --tail 200`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — PER-15361 (pdp-tester CI: k3d -> Docker runtime)

What this PR does: Replaces the pdp-tester CI job's Kubernetes path (k3d cluster + image import + Helm deploy + kubectl wait/kubectl logs + teardown) with the pdp-tester aiodocker Docker-runtime backend: set up Python 3.12, pip install -e ".[docker]", then python -m pdp_tester --docker --local --tag next --skip-generate against the locally-built permitio/pdp-v2:next image. It also adds a timeout-minutes: 15 job cap, pip dependency caching, explicit non-zero-exit handling, and a docker-based post-mortem log dump. Motivation: the AbsaOSS/k3d-action installer 404s (its default k3d v5.4.6 ships no checksums.txt).

Verdict: APPROVE. No Postable finding (severity rule: none postable -> approve). The migration is correct and well-isolated: the removed k3d/Helm/kubectl steps leave no dangling references (only explanatory comments mention them), pdp-tester is a leaf job (nothing has needs: pdp-tester), and the env wiring maps 1:1 to the old Helm --sets — permit.apiUrl -> API_URL, tests.startTimeout=180 -> START_TIMEOUT, pdp.image + pdp.localTags[0]=next -> LOCAL_IMAGE + --tag next + --local, tests.skipGenerate=true -> --skip-generate, PDP_TESTER_API_KEY -> TOKEN. The Docker-runtime path is genuinely exercised: pdp-tester is green in CI (it launches the PDP as a local container and asserts on both a test cases failed log line and a non-zero exit status).

Findings

No postable findings.

Informational

# Sev Ref Category Description
I1 INFO pytests (CI) Mergeability / coordination pytests is RED on this branch, but not because of this diff: it is the repo-wide aioresponses / aiohttp-3.14 breakage (34 failures on clean main too). The fix (horizon/tests/conftest.py) currently rides in the sibling PR #318; until it lands on main (or is included here), #319 stays blocked on an unrelated failing check.
I2 INFO docker-scout (CI) Pre-existing The docker-scout high/critical gate is red from residual base-image CVEs; unrelated to this CI-only change (this branch does not touch image contents or requirements.txt).
I3 INFO security/snyk (CI) Infra The Snyk check failed on a quota limit ("used your limit of private tests"), not a vulnerability finding.
I4 INFO .github/workflows/tests.yml:152 Prior review (resolved) Copilot's earlier flag that the default -e shell would abort before /tmp/tester.log is parsed was addressed in d6c7cc4 — the step now captures status=$? so -e no longer aborts, always runs the grep check, and fails distinctly on a non-zero tester exit. Verified in the current code; not re-raised.
I5 INFO .github/workflows/tests.yml (uses:) Supply-chain The PR removes third-party AbsaOSS/k3d-action@v2.4.0 and adds first-party actions/setup-python@v5 at the repo's standard unpinned @vN tag. No PDP-repo SHA-pinning rule exists (no workflow pins actions to a SHA), so the change conforms to convention and net-reduces third-party action surface.
I6 INFO cross-PR (#318) Duplication This same migration is also bundled into PR #318 (commit 75d974b) as an older copy lacking this PR's timeout-minutes / pip-cache / exit-status hardening. Both edit the same file and will conflict; de-conflict by letting one PR own the migration (flagged on #318).

Blast radius: pdp-tester is a leaf job (no dependents). The helm/kubectl usage in deploy_sidecar.yml and test_release.yml is unrelated (PDP chart deploy / release smoke test) and untouched. tests.yml is invoked by release.yml via secrets: inherit, so PDP_TESTER_API_KEY / CLONE_REPO_TOKEN still reach the job — behavior unchanged there.

Isolation / scope: Well-isolated — one file, one purpose, no drive-by changes. The only entanglement is external (the duplicate copy in #318).

dshoen619 and others added 3 commits July 7, 2026 12:34
aiohttp 3.14 added a required `stream_writer` kwarg to
ClientResponse.__init__, which the test mock library aioresponses
(latest, 0.7.9) does not pass. A fresh CI install resolves aiohttp to
3.14.x (the >=3.13.3 pin allowed <4), so every aioresponses-backed
horizon test fails with:

  TypeError: ClientResponse.__init__() missing 1 required
  keyword-only argument: 'stream_writer'

(34 failed on this PR's run; main's last green run predates aiohttp
3.14). Cap to <3.14 until aioresponses ships 3.14 support. The >=3.13.3
floor preserves the CVE-2025-69223/69227/69228/69229 fixes.

Reproduced locally: aiohttp 3.14.1 + aioresponses 0.7.9 -> TypeError;
aiohttp 3.13.5 + aioresponses 0.7.9 -> passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main (#318) independently did the same k3d->docker pdp-tester rework and
also fixed the aiohttp 3.14 / aioresponses incompatibility via a
conftest.py shim that injects the required stream_writer, keeping
aiohttp on 3.14.x for its June 2026 security fixes.

Conflict resolution:
- .github/workflows/tests.yml: kept our version of the pdp-tester job
  (superset of main's rework, adding timeout-minutes, pip cache,
  exit-status capture, and simplified post-mortem).
- requirements.txt: dropped our aiohttp<3.14 cap in favor of main's
  conftest.py shim (main's approach avoids reintroducing the 3.14-only
  CVEs); kept main's cryptography bump and starlette note.
- horizon/tests/conftest.py: took main's stream_writer compat shim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 merged commit 02f7655 into main Jul 8, 2026
8 of 9 checks passed
@dshoen619
dshoen619 deleted the david/fix-pdp-tester-ci-docker-runtime branch July 8, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants