Claude_Code agent — after remediation

Claude_Code agent remediation result

  1. Generated. claude_code wrote this application during this run, from a prompt. Not written by a human, and not selected for being vulnerable. The prompt and generated code browser are linked below.
  2. Insecure. REACHABLE found the remediation list — all findings that are not proven noise. See the scan.
  3. Remediated. REACHABLE handed that remediation list to the same agent. The final scan has 6 open actionable finding(s); 5 resolved against the baseline scan.

Application evidence: generation prompt · workspace contents · code before remediation · fixed code · side-by-side code diff

Model disclosure: the application was written by sonnet from Anthropic API, pinned for this run and recorded in the run's evidence. The fix was made by the same agent, but through reachctl remediate, which selects the agent and does not pin its model — so the fixing model is whichever that agent's CLI resolved at run time, and is not asserted here.

Scanner provenance

REACHABLE wheel/build and scan timestamps, read from the scanner database rows that produced the page. Actionable counts are repo.db SIGNAL rows under the product ACTIONABLE_SQL filter.

ScanREACHABLE versionTimestamp (UTC)CommitStatusActionable
Baseline1.0.0b1992026-09-12 14:23:33a96ec233superseded11
Baseline1.0.0b1992026-09-12 14:27:26a96ec233complete11
Baseline1.0.0b1992026-09-12 15:18:557cd1f3a6complete6

Run estimate

Token estimates are recorded before the run starts. The remediation estimate is recalculated after REACHABLE has the scan database and knows how many findings are in scope.

Planned runtime21-110 minutes
Planned model tokens53000-475000 total
Planned input tokens28000-280000
Planned output tokens25000-195000
Generation budget1800s
Remediation scope11

Run 2026-09-12 14:27:41 UTC · status partial after 2 agent fix attempt(s)

INITIAL SCOPE
11
repo.db actionable (baseline)
CURRENT OPEN
6
repo.db actionable (final scan)
RESOLVED
5
scan delta (initial − open)

Run status: partial after 2 iteration(s).

Initial actionable (repo.db): 11. Resolved (scan delta): 5. Current open (repo.db): 6. Noise (repo.db baseline): 14. INITIAL, OPEN, and RESOLVED come from repo.db actionable counts (product ACTIONABLE_SQL on the baseline and final scans). They are not counted from SARIF, data.json, or remediation-audit row matching. The table below is the per-finding remediation ledger.

Open means still actionable on the final scan in repo.db. That can mean the iteration budget ended, the agent did not make the right change, or the finding needs a non-source/package/build change that the agent could not safely complete in this lane. Per-finding ledger rows below may still disagree with this scan total when matching is incomplete; the cards follow the database.

Generated workspace contents

The agent generated these files in the repository before scan. They are scanned and published as evidence. Dependency installs, build outputs, and compiled binaries can explain why one agent has more package or binary findings than another.

Workspace observations: build_directory: 1331, extensionless_executable: 8. Open the full JSON.

Largest generated fileBytes
portal/node_modules/typescript/lib/typescript.js9112572
portal/node_modules/typescript/lib/_tsc.js6213092
portal/node_modules/typescript/lib/lib.dom.d.ts1874901
portal/node_modules/typescript/lib/lib.webworker.d.ts609557
portal/node_modules/typescript/lib/typescript.d.ts588085

Remediation scope — 11 finding(s)

repo.db actionable count on the baseline scan. Table lists 11 ledger/validation row(s) as per-finding evidence.

OrderOutcomeSeverityTypeFindingFile
1Not fixedMEDIUMCWEExpress app without helmet - missing security headersportal/src/server.ts:16
2Not fixedMEDIUMCWEXSS: unescaped EJS output <%- footerHtml %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.portal/views/statement.ejs:31
3Not fixedMEDIUMCWEXSS: unescaped EJS output <%- customerNote %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.portal/views/statement.ejs:13
4Not fixedMEDIUMCWEXSS: unescaped EJS output <%- line.memo %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.portal/views/statement.ejs:24
5Gone, not attributedCRITICALCWEFlask route sends a non-literal file path — path traversal riskdocs-worker/app.py
6Gone, not attributedHIGHCWESQL query with f-string interpolation — SQL injection riskdocs-worker/app.py
7Gone, not attributedMEDIUMCWEFound a template created with string formatting. This is susceptible to server-side template injection and cross-site scripting attacks.docs-worker/app.py
8Gone, not attributedMEDIUMCWERunning flask app with host 0.0.0.0 could expose the server publicly.docs-worker/app.py
9Gone, not attributedLOWSECRETHardcoded Stripe-like live secret key detected (sk_live_*)payments-api/db.go
10Gone, not attributedLOWSECRETHardcoded Stripe-like live secret key detected (sk_live_*)payments-api/db.go
11Gone, not attributedLOWCONFIGFound an HTTP server without TLS. Use 'http.ListenAndServeTLS' instead. See https://golang.org/pkg/net/http/#ListenAndServeTLS for more information.payments-api/main.go

This is the remediation list REACHABLE handed to the agent, collapsed to each finding's final recorded outcome. The noise list below is excluded only when REACHABLE has explicit non-production or unreachable evidence.

The code the agent wrote

The staged patch, exactly as committed.

diff --git a/docs-worker/app.py b/docs-worker/app.py
index 42b448d..c9e0a6c 100644
--- a/docs-worker/app.py
+++ b/docs-worker/app.py
@@ -2,10 +2,12 @@ import os
 import threading
 import time
 
-from flask import Flask, g, jsonify, request, render_template_string, send_file
+from flask import Flask, g, jsonify, request, send_file
 
 from db import get_db, init_db
 from middleware import load_session, log_audit, require_role
+from pathsafe import resolve_within
+from search_query import build_search_query
 
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 STORAGE_ROOT = os.path.join(BASE_DIR, "storage")
@@ -39,18 +41,12 @@ def search_documents():
     status = request.args.get("status")
     q = request.args.get("q")
 
-    where = f"org_id = {org_id}"
-    if doc_type:
-        where += f" AND doc_type = '{doc_type}'"
-    if status:
-        where += f" AND status = '{status}'"
-    if q:
-        where += f" AND filename LIKE '%{q}%'"
-
-    query = f"SELECT id, filename, doc_type, status, uploaded_by, created_at FROM documents WHERE {where}"
+    # Build the filter with placeholders and bind every untrusted value; only
+    # SQL structure lives in the query string (see search_query.build_search_query).
+    query, params = build_search_query(org_id, doc_type, status, q)
 
     db = get_db()
-    rows = db.execute(query).fetchall()
+    rows = db.execute(query, params).fetchall()
     documents = [dict(row) for row in rows]
     return jsonify({"documents": documents, "query": query})
 
@@ -88,12 +84,19 @@ def download_document(org_id):
         return jsonify({"error": "file query parameter is required"}), 400
 
     org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")
-    target = os.path.join(org_dir, file_name)
 
-    if not os.path.exists(target):
-        return jsonify({"error": "document not found", "path": target}), 404
+    # Canonicalize the requested path and confirm it stays inside the org's
+    # storage directory before touching the filesystem. This blocks "..",
+    # absolute paths, null bytes and symlinked escapes (uploads share this
+    # directory, so an attacker could otherwise plant a symlink to escape it).
+    target = resolve_within(org_dir, file_name)
+    if target is None:
+        return jsonify({"error": "invalid file path"}), 400
+
+    if not os.path.isfile(target):
+        return jsonify({"error": "document not found"}), 404
 
-    return send_file(target, as_attachment=True, download_name=os.path.basename(file_name))
+    return send_file(target, as_attachment=True, download_name=os.path.basename(target))
 
 
 REPORT_TEMPLATE = """
@@ -103,11 +106,18 @@ REPORT_TEMPLATE = """
 <body>
   <h1>{{ title }}</h1>
   <p>Prepared for: {{ org_name }}</p>
-  <div class="analyst-note">{{ note | safe }}</div>
+  <div class="analyst-note">{{ note }}</div>
 </body>
 </html>
 """
 
+# Compile the report template once from a trusted constant. The caller only
+# ever supplies context values (title/org name/note) -- never the template
+# source -- so it cannot inject template syntax (SSTI). Flask's Jinja
+# environment autoescapes string templates, so the values are HTML-escaped in
+# the output as well (XSS).
+_REPORT_TEMPLATE = app.jinja_env.from_string(REPORT_TEMPLATE)
+
 
 @app.route("/api/reports/render", methods=["POST"])
 def render_report():
@@ -119,8 +129,7 @@ def render_report():
     org_name = payload.get("orgName", "")
     note = payload.get("note", "")
 
-    template_source = payload.get("templateOverride") or REPORT_TEMPLATE
-    html = render_template_string(template_source, title=title, org_name=org_name, note=note)
+    html = _REPORT_TEMPLATE.render(title=title, org_name=org_name, note=note)
     return html, 200, {"Content-Type": "text/html"}
 
 
diff --git a/docs-worker/test_pathsafe.py b/docs-worker/test_pathsafe.py
new file mode 100644
index 0000000..9e6ca2a
--- /dev/null
+++ b/docs-worker/test_pathsafe.py
@@ -0,0 +1,50 @@
+import os
+import shutil
+import tempfile
+
+from pathsafe import resolve_within
+
+
+def test_rejects_parent_traversal():
+    with tempfile.TemporaryDirectory() as base:
+        assert resolve_within(base, "../../etc/passwd") is None
+
+
+def test_rejects_absolute_path():
+    with tempfile.TemporaryDirectory() as base:
+        assert resolve_within(base, "/etc/passwd") is None
+
+
+def test_rejects_null_byte():
+    with tempfile.TemporaryDirectory() as base:
+        assert resolve_within(base, "report\x00.pdf") is None
+
+
+def test_rejects_empty_name():
+    with tempfile.TemporaryDirectory() as base:
+        assert resolve_within(base, "") is None
+
+
+def test_rejects_symlink_escape():
+    with tempfile.TemporaryDirectory() as base:
+        outside = tempfile.mkdtemp()
+        try:
+            secret = os.path.join(outside, "secret.txt")
+            with open(secret, "w") as fh:
+                fh.write("top secret")
+            link = os.path.join(base, "link")
+            os.symlink(secret, link)
+            # A symlink inside the base dir that points outside must be rejected,
+            # which lexical normalization would miss.
+            assert resolve_within(base, "link") is None
+        finally:
+            shutil.rmtree(outside, ignore_errors=True)
+
+
+def test_allows_legitimate_file():
+    with tempfile.TemporaryDirectory() as base:
+        name = "report.pdf"
+        path = os.path.join(base, name)
+        with open(path, "w") as fh:
+            fh.write("data")
+        assert resolve_within(base, name) == os.path.realpath(path)
diff --git a/payments-api/db.go b/payments-api/db.go
index 220503e..3465556 100644
--- a/payments-api/db.go
+++ b/payments-api/db.go
@@ -108,10 +108,18 @@ func seed() {
 	}
 	globexID, _ := res.LastInsertId()
 
-	db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
-		"sk_live_acme_ops_9f2c1a", acmeID, "admin", "Acme treasury ops key")
-	db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
-		"sk_live_globex_finance_4b71e0", globexID, "admin", "Globex finance key")
+	// API keys are secrets: read them from the environment (or a managed
+	// secret store) at runtime instead of committing live key material. If a
+	// key is not configured the seed skips it rather than falling back to a
+	// hardcoded credential.
+	if acmeKey := os.Getenv("ACME_API_KEY"); acmeKey != "" {
+		db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
+			acmeKey, acmeID, "admin", "Acme treasury ops key")
+	}
+	if globexKey := os.Getenv("GLOBEX_API_KEY"); globexKey != "" {
+		db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
+			globexKey, globexID, "admin", "Globex finance key")
+	}
 
 	accRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",
 		acmeID, "Acme Operating Account", "USD", 500000000, "active")
diff --git a/payments-api/main.go b/payments-api/main.go
index 8d5c78e..381e3b1 100644
--- a/payments-api/main.go
+++ b/payments-api/main.go
@@ -1,11 +1,25 @@
 package main
 
 import (
+	"crypto/tls"
 	"log"
 	"net/http"
 	"os"
 )
 
+// newServer builds the HTTP server with a modern TLS configuration. TLS
+// verification is left intact (no InsecureSkipVerify); a minimum protocol
+// version of TLS 1.2 is enforced for every connection.
+func newServer(addr string, handler http.Handler) *http.Server {
+	return &http.Server{
+		Addr:    addr,
+		Handler: handler,
+		TLSConfig: &tls.Config{
+			MinVersion: tls.VersionTLS12,
+		},
+	}
+}
+
 func main() {
 	initDB()
 	defer db.Close()
@@ -30,8 +44,18 @@ func main() {
 		port = "4001"
 	}
 
-	log.Printf("payments-api listening on :%s", port)
-	if err := http.ListenAndServe(":"+port, mux); err != nil {
+	// Certificate/key material is supplied at runtime (mount or secret store),
+	// never minted in code or committed to the repo. Refuse to start over
+	// plaintext so the listener always speaks TLS.
+	certFile := os.Getenv("TLS_CERT_FILE")
+	keyFile := os.Getenv("TLS_KEY_FILE")
+	if certFile == "" || keyFile == "" {
+		log.Fatal("TLS_CERT_FILE and TLS_KEY_FILE must be set; refusing to start an unencrypted listener")
+	}
+
+	srv := newServer(":"+port, mux)
+	log.Printf("payments-api listening on :%s (TLS)", port)
+	if err := srv.ListenAndServeTLS(certFile, keyFile); err != nil {
 		log.Fatal(err)
 	}
 }
diff --git a/payments-api/security_test.go b/payments-api/security_test.go
new file mode 100644
index 0000000..64c7315
--- /dev/null
+++ b/payments-api/security_test.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+	"crypto/tls"
+	"database/sql"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+)
+
+// TestNewServerEnforcesModernTLS verifies the listener is configured for TLS
+// with a modern minimum protocol version.
+func TestNewServerEnforcesModernTLS(t *testing.T) {
+	srv := newServer(":0", http.NewServeMux())
+	if srv.TLSConfig == nil {
+		t.Fatal("expected a TLS configuration on the server")
+	}
+	if srv.TLSConfig.MinVersion != tls.VersionTLS12 {
+		t.Fatalf("expected TLS min version 1.2, got %x", srv.TLSConfig.MinVersion)
+	}
+}
+
+// TestTLSAcceptsTrustedRejectsUntrusted proves the transport enforces
+// certificate verification: a peer that trusts the server cert succeeds while
+// a peer using default verification is rejected.
+func TestTLSAcceptsTrustedRejectsUntrusted(t *testing.T) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /healthz", healthz)
+
+	ts := httptest.NewUnstartedServer(mux)
+	ts.TLS = newServer("", mux).TLSConfig // exercise our TLS config
+	ts.StartTLS()
+	defer ts.Close()
+
+	// Trusted peer: the test client trusts the generated server certificate.
+	resp, err := ts.Client().Get(ts.URL + "/healthz")
+	if err != nil {
+		t.Fatalf("trusted client should succeed: %v", err)
+	}
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("expected 200 from trusted client, got %d", resp.StatusCode)
+	}
+
+	// Untrusted peer: default verification must reject the unknown authority.
+	if _, err := http.Get(ts.URL + "/healthz"); err == nil {
+		t.Fatal("untrusted client should fail TLS verification")
+	}
+}
+
+// TestSeedUsesEnvApiKeys proves the seed reads API keys from the environment
+// (legitimate flow) and never inserts a hardcoded sk_live_* credential.
+func TestSeedUsesEnvApiKeys(t *testing.T) {
+	tmp := t.TempDir()
+	var err error
+	db, err = sql.Open("sqlite", filepath.Join(tmp, "test.sqlite"))
+	if err != nil {
+		t.Fatalf("opening test db: %v", err)
+	}
+	defer db.Close()
+	if _, err := db.Exec(schema); err != nil {
+		t.Fatalf("applying schema: %v", err)
+	}
+
+	t.Setenv("ACME_API_KEY", "runtime-test-value-acme")
+	t.Setenv("GLOBEX_API_KEY", "runtime-test-value-globex")
+
+	seed()
+
+	var seeded int
+	if err := db.QueryRow("SELECT COUNT(*) FROM api_keys WHERE api_key = ?", "runtime-test-value-acme").Scan(&seeded); err != nil {
+		t.Fatalf("querying seeded key: %v", err)
+	}
+	if seeded != 1 {
+		t.Fatalf("expected acme key seeded from env, got %d", seeded)
+	}
+
+	var live int
+	if err := db.QueryRow("SELECT COUNT(*) FROM api_keys WHERE api_key LIKE 'sk_live_%'").Scan(&live); err != nil {
+		t.Fatalf("querying live keys: %v", err)
+	}
+	if live != 0 {
+		t.Fatalf("expected no hardcoded sk_live key, found %d", live)
+	}
+}

Noise — 13 finding(s)

All 13 are excluded for one reason: the application never reaches the vulnerable code. These are real advisories against real dependencies — not filtered out as test or scaffolding files. They are excluded from the remediation scope, and listed rather than hidden. 1 of 13 stopped appearing once the in-scope fixes landed, almost always because they shared a root cause with something that was in scope. REACHABLE never asked the agent to touch them and they are not counted as fixed — the point of excluding them is that they did not need fixing. The other 12 are unchanged.

What happenedWhy it is excludedSeverityTypeFindingFile
Not fixednot reachableHIGHCVEGHSA-72gw-mp4g-v24jagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-g5hg-p3ph-g8qgagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-535w-7cp7-47q4agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-wc9g-mqfw-jrwmagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-fjgf-rc76-4x9pagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-44fp-w29j-9vj5agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCWESQL query built with fmt.Sprintf — SQL injection riskagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/payments-api/handlers_accounts.go
Not fixednot reachableHIGHCVEGHSA-xf7r-hgr6-v32pagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-4pg4-qvpc-4q3hagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-v52c-386h-88mcagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableHIGHCVEGHSA-5528-5vmv-3xc2agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Not fixednot reachableLOWCVEGHSA-qvfw-j98x-7q72agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js
Gone, not attributednot reachableINFOSUSPICIOUSNew dependency not present in prior version (6.15.2): es-define-propertypkg://qs@6.15.3

Additional scan context — 7 row(s)

Not remediation scope and not counted as noise. These rows were present in scanner context but were not in the product's actionable validation export for this source-remediation pass, so they are not counted as fixed or open remediation work.

Why it is not in scopeSeverityTypeFindingFile
reachable, marked not actionableMEDIUMCWEExpress app without helmet - missing security headersagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/server.ts
reachability not establishedMEDIUMCWEXSS: unescaped EJS output <%- footerHtml %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/views/statement.ejs
reachability not establishedMEDIUMCWEXSS: unescaped EJS output <%- customerNote %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/views/statement.ejs
reachability not establishedMEDIUMCWEXSS: unescaped EJS output <%- line.memo %> renders raw HTML. Verify the expression does not contain user input. Safe patterns (include, partial, JSON.stringify, view composition) are excluded.agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/views/statement.ejs
reachable, marked not actionableCRITICALCWEFlask route sends a non-literal file path — path traversal riskagent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/docs-worker/app.py
reachable, marked not actionableMEDIUMCWERunning flask app with host 0.0.0.0 could expose the server publicly.agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/docs-worker/app.py
reachability not establishedINFOSUSPICIOUSNew dependency not present in prior version (10.9.2): picocolorspkg://jake@10.9.4

Remediation Attempt

Started with 11 findings to fix. Iteration 1 fixed 0 issues; 11 issues left. Iteration 2 fixed 5 issues; 6 issues left. 6 finding(s) still need attention.

6 security finding(s) still remain. Noise and context are listed separately on this page.

Starting findings11
Iterations used2
Final open findings6
Noise excluded0
Context only0
Stop conditionStopped with findings still open.

Pass-by-pass breakdown

PassStarted withFixedLeftNoise excludedContextTimeHow it ended
11101100845.2skilled at the budget
2115600756.3skilled at the budget
Total1156001601.6s2 of 2 killed

Iteration 1: fixed 0 issues; 11 issues left

Iteration 2: fixed 5 issues; 6 issues left

How long it took

Wall clock, read from the scanner's own output and the remediation database timestamps.

StageSeconds
Scan (before)155.2s
Rescan (after)117.9s
Agent pass 1 (writing the fix)480.4s
Agent pass 2 (writing the fix)310.6s
Agent pass 3 (writing the fix)480.4s
Agent pass 4 (writing the fix)222.0s
Remediation loop total (2 pass(es), incl. a rescan per pass)1602.6s
Agent time only, all passes1493.5s

← initial scan · this pipeline run