Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude local secrets from the container context

When a developer builds this image from a checkout containing the repository's ignored .env credential file, COPY . . includes that file because .gitignore does not filter Docker build contexts and no .dockerignore exists. The credentials then remain in the final image and its layers, exposing them to deployments and anyone with image access; add a .dockerignore that excludes .env*, .git, and other local artifacts while explicitly retaining only safe examples if needed.

Useful? React with 👍 / 👎.

RUN pip install --no-cache-dir .
CMD ["python","service.py"]
73 changes: 73 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>pubskill audit</title>
<style>
body{font-family:sans-serif;max-width:800px;margin:auto;padding:24px}
input{width:100%;padding:12px;box-sizing:border-box}
button{margin-top:10px;padding:12px 18px}
.finding{border:1px solid #ccc;border-radius:8px;padding:12px;margin:12px 0}
.meta{font-size:.9em;opacity:.7}
pre{white-space:pre-wrap}
</style>
</head>
<body>
<h1>Repository Audit</h1>

<input id="repo" placeholder="https://github.com/owner/repo.git">
<button onclick="runAudit()">Audit</button>

<div id="out"></div>

<script>
async function runAudit() {
const out = document.getElementById("out");
out.innerHTML = "<p>Running audit...</p>";

const response = await fetch("/audit", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
repo_url: document.getElementById("repo").value
})
});

const r = await response.json();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report transport and non-JSON failures

When fetch() rejects or Cloud Run returns a proxy-generated non-JSON 5xx response, response.json() throws before the response.ok branch runs. With no surrounding error handling, the page leaves “Running audit...” displayed indefinitely and produces only an unhandled promise rejection; catch both transport and response-parsing failures and replace the progress message with an actionable error.

Useful? React with 👍 / 👎.


if (!response.ok) {
out.innerHTML = `<pre>${r.error || "Audit failed"}</pre>`;
return;
}

let html = `
<h2>${r.target?.remote || "Repository"}</h2>
<p class="meta">Commit: ${r.target?.commit || "unknown"}</p>
<h3>${r.findings.length} findings</h3>
`;

for (const f of r.findings) {
html += `
<div class="finding">
<strong>${f.id} — ${f.surface}</strong>
<p>${f.claim}</p>
<div class="meta">
Evidence: ${f.evidence}<br>
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Render audit fields as text, not HTML

When auditing an attacker-controlled repository, fields such as claim and evidence contain raw repository strings; for example, a pyproject.toml script name can contain an <img onerror=...> payload. Interpolating those values into innerHTML executes the payload in the service origin; the same unsafe pattern also handles the remote, hmmm, and error fields. Escape every value or construct the result with textContent/DOM nodes.

Useful? React with 👍 / 👎.

Class: ${f.class}
</div>
</div>
`;
}

if (r.hmmm?.length) {
html += `<h3>hmmm</h3><ul>` +
r.hmmm.map(x => `<li>${x}</li>`).join("") +
`</ul>`;
}

out.innerHTML = html;
}
</script>
</body>
</html>
61 changes: 61 additions & 0 deletions service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import json
import os
import re
import subprocess
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

from pubskill_lib.audit import audit_path

REPO = re.compile(r"^https://github\.com/[^/]+/[^/]+(?:\.git)?$")


class Handler(BaseHTTPRequestHandler):
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)

def do_GET(self):
data = Path("index.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)

def do_POST(self):
if self.path != "/audit":
return self.reply(404, {"error": "not found"})

try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(min(length, 8192)))
repo_url = body["repo_url"]

if not REPO.fullmatch(repo_url):
return self.reply(400, {"error": "public github.com repository required"})

with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "repo")
subprocess.run(
["git", "clone", "--depth=1", repo_url, target],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound clone resource consumption

On this unauthenticated public endpoint, every request can start a concurrent clone into instance-local storage without a byte, disk, or concurrency limit. git clone -h defines --depth only as “create a shallow clone of that depth,” so a repository with a very large current tree—or a burst of ordinary requests—can still exhaust disk, threads, or autoscaling budget before the 120-second timeout; add admission controls and enforce hard resource limits around cloning.

Useful? React with 👍 / 👎.

check=True,
timeout=120,
)
result = audit_path(target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refuse out-of-tree symlinks before auditing

When a public repository commits README.md as a symlink to /dev/zero, Git preserves that symlink during clone and audit_path() eventually follows it in _read_text() using an unbounded Path.read_text(). Because only the clone subprocess has a timeout and the audit runs inside the HTTP process, one request can hang or OOM the Cloud Run instance; validate that inspected files are regular files contained beneath target, or isolate and resource-limit the audit.

Useful? React with 👍 / 👎.


self.reply(200, result)

except Exception as exc:
self.reply(500, {"error": str(exc)})
Comment on lines +54 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Classify expected audit failures before returning 500

When a POST contains malformed JSON, omits repo_url, supplies a non-string value, or names an inaccessible repository, parsing, lookup, validation, or cloning raises into this catch-all and the service reports HTTP 500. These are routine client or upstream failures, so treating them as internal faults encourages inappropriate retries and pollutes operational error rates; validate request bodies separately with 400/413 responses and map clone rejection or timeout to a stable 4xx/5xx response before reserving 500 for unexpected faults.

Useful? React with 👍 / 👎.



ThreadingHTTPServer(
("0.0.0.0", int(os.environ.get("PORT", "8080"))),
Handler,
).serve_forever()
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the prohibited hosted service

This starts a network-facing remote-repository service even though the required matching HANDOFF.md explicitly lists “Do not host a SaaS” as a v0.2 non-goal and leaves remote URL inspection outside v0.2. Shipping this server expands the public product surface contrary to the repository's binding work order; retain the documented local-path CLI rather than adding the hosted endpoint.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.