Claude_Code agent — after remediation
Claude_Code agent remediation result
- 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.
- Insecure. REACHABLE found the remediation list — all findings that are not proven noise. See the scan.
- 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.
| Scan | REACHABLE version | Timestamp (UTC) | Commit | Status | Actionable |
|---|---|---|---|---|---|
| Baseline | 1.0.0b199 | 2026-09-12 14:23:33 | a96ec233 | superseded | 11 |
| Baseline | 1.0.0b199 | 2026-09-12 14:27:26 | a96ec233 | complete | 11 |
| Baseline | 1.0.0b199 | 2026-09-12 15:18:55 | 7cd1f3a6 | complete | 6 |
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 runtime | 21-110 minutes |
|---|---|
| Planned model tokens | 53000-475000 total |
| Planned input tokens | 28000-280000 |
| Planned output tokens | 25000-195000 |
| Generation budget | 1800s |
| Remediation scope | 11 |
Run 2026-09-12 14:27:41 UTC · status partial after 2 agent fix attempt(s)
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 file | Bytes |
|---|---|
| portal/node_modules/typescript/lib/typescript.js | 9112572 |
| portal/node_modules/typescript/lib/_tsc.js | 6213092 |
| portal/node_modules/typescript/lib/lib.dom.d.ts | 1874901 |
| portal/node_modules/typescript/lib/lib.webworker.d.ts | 609557 |
| portal/node_modules/typescript/lib/typescript.d.ts | 588085 |
Remediation scope — 11 finding(s)
repo.db actionable count on the baseline scan. Table lists 11 ledger/validation row(s) as per-finding evidence.
| Order | Outcome | Severity | Type | Finding | File |
|---|---|---|---|---|---|
| 1 | Not fixed | MEDIUM | CWE | Express app without helmet - missing security headers | portal/src/server.ts:16 |
| 2 | Not fixed | MEDIUM | CWE | XSS: 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 |
| 3 | Not fixed | MEDIUM | CWE | XSS: 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 |
| 4 | Not fixed | MEDIUM | CWE | XSS: 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 |
| 5 | Gone, not attributed | CRITICAL | CWE | Flask route sends a non-literal file path — path traversal risk | docs-worker/app.py |
| 6 | Gone, not attributed | HIGH | CWE | SQL query with f-string interpolation — SQL injection risk | docs-worker/app.py |
| 7 | Gone, not attributed | MEDIUM | CWE | Found a template created with string formatting. This is susceptible to server-side template injection and cross-site scripting attacks. | docs-worker/app.py |
| 8 | Gone, not attributed | MEDIUM | CWE | Running flask app with host 0.0.0.0 could expose the server publicly. | docs-worker/app.py |
| 9 | Gone, not attributed | LOW | SECRET | Hardcoded Stripe-like live secret key detected (sk_live_*) | payments-api/db.go |
| 10 | Gone, not attributed | LOW | SECRET | Hardcoded Stripe-like live secret key detected (sk_live_*) | payments-api/db.go |
| 11 | Gone, not attributed | LOW | CONFIG | Found 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.
index 42b448d..c9e0a6c 100644 --- a/docs-worker/app.py +++ b/docs-worker/app.py 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") 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}) 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 = """ <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(): 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"} new file mode 100644 index 0000000..9e6ca2a --- /dev/null +++ b/docs-worker/test_pathsafe.py +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) index 220503e..3465556 100644 --- a/payments-api/db.go +++ b/payments-api/db.go } 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") index 8d5c78e..381e3b1 100644 --- a/payments-api/main.go +++ b/payments-api/main.go 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() 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) } } new file mode 100644 index 0000000..64c7315 --- /dev/null +++ b/payments-api/security_test.go +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 happened | Why it is excluded | Severity | Type | Finding | File | |
|---|---|---|---|---|---|---|
| Not fixed | not reachable | HIGH | CVE | GHSA-72gw-mp4g-v24j | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-g5hg-p3ph-g8qg | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-535w-7cp7-47q4 | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-wc9g-mqfw-jrwm | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-fjgf-rc76-4x9p | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-44fp-w29j-9vj5 | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CWE | SQL query built with fmt.Sprintf — SQL injection risk | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/payments-api/handlers_accounts.go | |
| Not fixed | not reachable | HIGH | CVE | GHSA-xf7r-hgr6-v32p | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-4pg4-qvpc-4q3h | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-v52c-386h-88mc | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | HIGH | CVE | GHSA-5528-5vmv-3xc2 | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Not fixed | not reachable | LOW | CVE | GHSA-qvfw-j98x-7q72 | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/routes/documents.js | |
| Gone, not attributed | not reachable | INFO | SUSPICIOUS | New dependency not present in prior version (6.15.2): es-define-property | pkg://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 scope | Severity | Type | Finding | File | |
|---|---|---|---|---|---|
| reachable, marked not actionable | MEDIUM | CWE | Express app without helmet - missing security headers | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/portal/src/server.ts | |
| reachability not established | MEDIUM | CWE | XSS: 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 established | MEDIUM | CWE | XSS: 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 established | MEDIUM | CWE | XSS: 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 actionable | CRITICAL | CWE | Flask route sends a non-literal file path — path traversal risk | agent-remediation/workspace-remediation/20260912-142724/iteration-02/proof-state/scanner-v0/docs-worker/app.py | |
| reachable, marked not actionable | MEDIUM | CWE | Running 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 established | INFO | SUSPICIOUS | New dependency not present in prior version (10.9.2): picocolors | pkg://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 findings | 11 |
|---|---|
| Iterations used | 2 |
| Final open findings | 6 |
| Noise excluded | 0 |
| Context only | 0 |
| Stop condition | Stopped with findings still open. |
Pass-by-pass breakdown
| Pass | Started with | Fixed | Left | Noise excluded | Context | Time | How it ended |
|---|---|---|---|---|---|---|---|
| 1 | 11 | 0 | 11 | 0 | 0 | 845.2s | killed at the budget |
| 2 | 11 | 5 | 6 | 0 | 0 | 756.3s | killed at the budget |
| Total | 11 | 5 | 6 | 0 | 0 | 1601.6s | 2 of 2 killed |
Iteration 1: fixed 0 issues; 11 issues left
- None
Iteration 2: fixed 5 issues; 6 issues left
- None
How long it took
Wall clock, read from the scanner's own output and the remediation database timestamps.
| Stage | Seconds |
|---|---|
| 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 passes | 1493.5s |