docs-worker/app.py
Before remediation
1import os2import threading3import time45from flask import Flask, g, jsonify, request, render_template_string, send_file67from db import get_db, init_db8from middleware import load_session, log_audit, require_role910BASE_DIR = os.path.dirname(os.path.abspath(__file__))11STORAGE_ROOT = os.path.join(BASE_DIR, "storage")1213app = Flask(__name__)14app.before_request(load_session)15app.after_request(log_audit)161718@app.route("/healthz", methods=["GET"])19def healthz():20 return jsonify({"status": "ok", "service": "docs-worker"})212223@app.route("/metrics", methods=["GET"])24def metrics():25 db = get_db()26 docs = db.execute("SELECT COUNT(*) AS c FROM documents").fetchone()["c"]27 processed = db.execute(28 "SELECT COUNT(*) AS c FROM documents WHERE status = 'processed'"29 ).fetchone()["c"]30 return jsonify({"documents_total": docs, "documents_processed": processed})313233@app.route("/api/documents/search", methods=["GET"])34def search_documents():35 """Lets ops search the document library by type, status and a free36 text filename match, combining whatever filters were passed."""37 org_id = g.org_id38 doc_type = request.args.get("doc_type")39 status = request.args.get("status")40 q = request.args.get("q")4142 where = f"org_id = {org_id}"43 if doc_type:44 where += f" AND doc_type = '{doc_type}'"45 if status:46 where += f" AND status = '{status}'"47 if q:48 where += f" AND filename LIKE '%{q}%'"4950 query = f"SELECT id, filename, doc_type, status, uploaded_by, created_at FROM documents WHERE {where}"5152 db = get_db()53 rows = db.execute(query).fetchall()54 documents = [dict(row) for row in rows]55 return jsonify({"documents": documents, "query": query})565758@app.route("/api/documents/<org_id>/upload", methods=["POST"])59@require_role("admin", "analyst")60def upload_document(org_id):61 uploaded = request.files.get("document")62 if uploaded is None:63 return jsonify({"error": "document file is required"}), 4006465 doc_type = request.form.get("docType", "other")66 dest_name = request.form.get("filename") or uploaded.filename6768 org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")69 os.makedirs(org_dir, exist_ok=True)7071 dest_path = os.path.join(org_dir, dest_name)72 uploaded.save(dest_path)7374 db = get_db()75 cur = db.execute(76 "INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, 'uploaded', ?)",77 (org_id, dest_name, doc_type, g.user_email),78 )79 db.commit()8081 return jsonify({"id": cur.lastrowid, "filename": dest_name, "orgId": org_id}), 201828384@app.route("/api/documents/<org_id>/download", methods=["GET"])85def download_document(org_id):86 file_name = request.args.get("file")87 if not file_name:88 return jsonify({"error": "file query parameter is required"}), 4008990 org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")91 target = os.path.join(org_dir, file_name)9293 if not os.path.exists(target):94 return jsonify({"error": "document not found", "path": target}), 4049596 return send_file(target, as_attachment=True, download_name=os.path.basename(file_name))979899REPORT_TEMPLATE = """100<!DOCTYPE html>101<html>102<head><meta charset="utf-8"><title>{{ title }}</title></head>103<body>104 <h1>{{ title }}</h1>105 <p>Prepared for: {{ org_name }}</p>106 <div class="analyst-note">{{ note | safe }}</div>107</body>108</html>109"""110111112@app.route("/api/reports/render", methods=["POST"])113def render_report():114 """Renders an ad-hoc analyst report. The title/org name/note fields are115 supplied by the caller so analysts can annotate the report however they116 like before it goes out to the customer."""117 payload = request.get_json(force=True, silent=True) or {}118 title = payload.get("title", "Document Review Report")119 org_name = payload.get("orgName", "")120 note = payload.get("note", "")121122 template_source = payload.get("templateOverride") or REPORT_TEMPLATE123 html = render_template_string(template_source, title=title, org_name=org_name, note=note)124 return html, 200, {"Content-Type": "text/html"}125126127def _process_document_job(document_id, org_id):128 time.sleep(1)129 db = get_db()130 db.execute("UPDATE documents SET status = 'processed' WHERE id = ?", (document_id,))131 db.execute(132 "INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)",133 (org_id, "docs-worker", "document.processed", f"document_id={document_id}"),134 )135 db.commit()136137138@app.route("/api/jobs/process", methods=["POST"])139@require_role("admin", "analyst")140def dispatch_processing_job():141 payload = request.get_json(force=True, silent=True) or {}142 document_id = payload.get("documentId")143 if not document_id:144 return jsonify({"error": "documentId is required"}), 400145146 thread = threading.Thread(target=_process_document_job, args=(document_id, g.org_id), daemon=True)147 thread.start()148149 return jsonify({"documentId": document_id, "status": "processing"}), 202150151152if __name__ == "__main__":153 init_db()154 app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 4002)))
After remediation
1import os2import threading3import time45from flask import Flask, g, jsonify, request, send_file67from db import get_db, init_db8from middleware import load_session, log_audit, require_role9from pathsafe import resolve_within10from search_query import build_search_query1112BASE_DIR = os.path.dirname(os.path.abspath(__file__))13STORAGE_ROOT = os.path.join(BASE_DIR, "storage")1415app = Flask(__name__)16app.before_request(load_session)17app.after_request(log_audit)181920@app.route("/healthz", methods=["GET"])21def healthz():22 return jsonify({"status": "ok", "service": "docs-worker"})232425@app.route("/metrics", methods=["GET"])26def metrics():27 db = get_db()28 docs = db.execute("SELECT COUNT(*) AS c FROM documents").fetchone()["c"]29 processed = db.execute(30 "SELECT COUNT(*) AS c FROM documents WHERE status = 'processed'"31 ).fetchone()["c"]32 return jsonify({"documents_total": docs, "documents_processed": processed})333435@app.route("/api/documents/search", methods=["GET"])36def search_documents():37 """Lets ops search the document library by type, status and a free38 text filename match, combining whatever filters were passed."""39 org_id = g.org_id40 doc_type = request.args.get("doc_type")41 status = request.args.get("status")42 q = request.args.get("q")4344 # Build the filter with placeholders and bind every untrusted value; only45 # SQL structure lives in the query string (see search_query.build_search_query).46 query, params = build_search_query(org_id, doc_type, status, q)4748 db = get_db()49 rows = db.execute(query, params).fetchall()50 documents = [dict(row) for row in rows]51 return jsonify({"documents": documents, "query": query})525354@app.route("/api/documents/<org_id>/upload", methods=["POST"])55@require_role("admin", "analyst")56def upload_document(org_id):57 uploaded = request.files.get("document")58 if uploaded is None:59 return jsonify({"error": "document file is required"}), 4006061 doc_type = request.form.get("docType", "other")62 dest_name = request.form.get("filename") or uploaded.filename6364 org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")65 os.makedirs(org_dir, exist_ok=True)6667 dest_path = os.path.join(org_dir, dest_name)68 uploaded.save(dest_path)6970 db = get_db()71 cur = db.execute(72 "INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, 'uploaded', ?)",73 (org_id, dest_name, doc_type, g.user_email),74 )75 db.commit()7677 return jsonify({"id": cur.lastrowid, "filename": dest_name, "orgId": org_id}), 201787980@app.route("/api/documents/<org_id>/download", methods=["GET"])81def download_document(org_id):82 file_name = request.args.get("file")83 if not file_name:84 return jsonify({"error": "file query parameter is required"}), 4008586 org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")8788 # Canonicalize the requested path and confirm it stays inside the org's89 # storage directory before touching the filesystem. This blocks "..",90 # absolute paths, null bytes and symlinked escapes (uploads share this91 # directory, so an attacker could otherwise plant a symlink to escape it).92 target = resolve_within(org_dir, file_name)93 if target is None:94 return jsonify({"error": "invalid file path"}), 4009596 if not os.path.isfile(target):97 return jsonify({"error": "document not found"}), 4049899 return send_file(target, as_attachment=True, download_name=os.path.basename(target))100101102REPORT_TEMPLATE = """103<!DOCTYPE html>104<html>105<head><meta charset="utf-8"><title>{{ title }}</title></head>106<body>107 <h1>{{ title }}</h1>108 <p>Prepared for: {{ org_name }}</p>109 <div class="analyst-note">{{ note }}</div>110</body>111</html>112"""113114# Compile the report template once from a trusted constant. The caller only115# ever supplies context values (title/org name/note) -- never the template116# source -- so it cannot inject template syntax (SSTI). Flask's Jinja117# environment autoescapes string templates, so the values are HTML-escaped in118# the output as well (XSS).119_REPORT_TEMPLATE = app.jinja_env.from_string(REPORT_TEMPLATE)120121122@app.route("/api/reports/render", methods=["POST"])123def render_report():124 """Renders an ad-hoc analyst report. The title/org name/note fields are125 supplied by the caller so analysts can annotate the report however they126 like before it goes out to the customer."""127 payload = request.get_json(force=True, silent=True) or {}128 title = payload.get("title", "Document Review Report")129 org_name = payload.get("orgName", "")130 note = payload.get("note", "")131132 html = _REPORT_TEMPLATE.render(title=title, org_name=org_name, note=note)133 return html, 200, {"Content-Type": "text/html"}134135136def _process_document_job(document_id, org_id):137 time.sleep(1)138 db = get_db()139 db.execute("UPDATE documents SET status = 'processed' WHERE id = ?", (document_id,))140 db.execute(141 "INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)",142 (org_id, "docs-worker", "document.processed", f"document_id={document_id}"),143 )144 db.commit()145146147@app.route("/api/jobs/process", methods=["POST"])148@require_role("admin", "analyst")149def dispatch_processing_job():150 payload = request.get_json(force=True, silent=True) or {}151 document_id = payload.get("documentId")152 if not document_id:153 return jsonify({"error": "documentId is required"}), 400154155 thread = threading.Thread(target=_process_document_job, args=(document_id, g.org_id), daemon=True)156 thread.start()157158 return jsonify({"documentId": document_id, "status": "processing"}), 202159160161if __name__ == "__main__":162 init_db()163 app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 4002)))
docs-worker/test_pathsafe.py
Before remediation
missing
After remediation
1import os2import shutil3import tempfile45from pathsafe import resolve_within678def test_rejects_parent_traversal():9 with tempfile.TemporaryDirectory() as base:10 assert resolve_within(base, "../../etc/passwd") is None111213def test_rejects_absolute_path():14 with tempfile.TemporaryDirectory() as base:15 assert resolve_within(base, "/etc/passwd") is None161718def test_rejects_null_byte():19 with tempfile.TemporaryDirectory() as base:20 assert resolve_within(base, "report\x00.pdf") is None212223def test_rejects_empty_name():24 with tempfile.TemporaryDirectory() as base:25 assert resolve_within(base, "") is None262728def test_rejects_symlink_escape():29 with tempfile.TemporaryDirectory() as base:30 outside = tempfile.mkdtemp()31 try:32 secret = os.path.join(outside, "secret.txt")33 with open(secret, "w") as fh:34 fh.write("top secret")35 link = os.path.join(base, "link")36 os.symlink(secret, link)37 # A symlink inside the base dir that points outside must be rejected,38 # which lexical normalization would miss.39 assert resolve_within(base, "link") is None40 finally:41 shutil.rmtree(outside, ignore_errors=True)424344def test_allows_legitimate_file():45 with tempfile.TemporaryDirectory() as base:46 name = "report.pdf"47 path = os.path.join(base, name)48 with open(path, "w") as fh:49 fh.write("data")50 assert resolve_within(base, name) == os.path.realpath(path)
payments-api/db.go
Before remediation
1package main23import (4 "database/sql"5 "log"6 "os"7 "path/filepath"89 _ "modernc.org/sqlite"10)1112var db *sql.DB1314const schema = `15CREATE TABLE IF NOT EXISTS organizations (16 id INTEGER PRIMARY KEY AUTOINCREMENT,17 name TEXT NOT NULL,18 plan TEXT NOT NULL DEFAULT 'standard'19);2021CREATE TABLE IF NOT EXISTS api_keys (22 api_key TEXT PRIMARY KEY,23 org_id INTEGER NOT NULL,24 role TEXT NOT NULL DEFAULT 'service',25 label TEXT26);2728CREATE TABLE IF NOT EXISTS accounts (29 id INTEGER PRIMARY KEY AUTOINCREMENT,30 org_id INTEGER NOT NULL,31 name TEXT NOT NULL,32 currency TEXT NOT NULL DEFAULT 'USD',33 balance_cents INTEGER NOT NULL DEFAULT 0,34 status TEXT NOT NULL DEFAULT 'active'35);3637CREATE TABLE IF NOT EXISTS beneficiaries (38 id INTEGER PRIMARY KEY AUTOINCREMENT,39 org_id INTEGER NOT NULL,40 name TEXT NOT NULL,41 bank_name TEXT,42 iban TEXT,43 status TEXT NOT NULL DEFAULT 'active'44);4546CREATE TABLE IF NOT EXISTS transfers (47 id INTEGER PRIMARY KEY AUTOINCREMENT,48 org_id INTEGER NOT NULL,49 account_id INTEGER NOT NULL,50 beneficiary_id INTEGER,51 amount_cents INTEGER NOT NULL,52 currency TEXT NOT NULL DEFAULT 'USD',53 status TEXT NOT NULL DEFAULT 'pending',54 memo TEXT,55 created_at TEXT NOT NULL DEFAULT (datetime('now')),56 settled_at TEXT57);5859CREATE TABLE IF NOT EXISTS audit_log (60 id INTEGER PRIMARY KEY AUTOINCREMENT,61 org_id INTEGER,62 actor TEXT,63 action TEXT NOT NULL,64 detail TEXT,65 ip TEXT,66 created_at TEXT NOT NULL DEFAULT (datetime('now'))67);68`6970func initDB() {71 dataDir := "data"72 if err := os.MkdirAll(dataDir, 0o755); err != nil {73 log.Fatalf("creating data dir: %v", err)74 }7576 dbPath := filepath.Join(dataDir, "payments.sqlite")77 var err error78 db, err = sql.Open("sqlite", dbPath)79 if err != nil {80 log.Fatalf("opening database: %v", err)81 }8283 if _, err := db.Exec(schema); err != nil {84 log.Fatalf("applying schema: %v", err)85 }8687 seed()88}8990func seed() {91 var count int92 if err := db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count); err != nil {93 log.Fatalf("counting organizations: %v", err)94 }95 if count > 0 {96 return97 }9899 res, err := db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Acme Manufacturing", "enterprise")100 if err != nil {101 log.Fatalf("seeding organizations: %v", err)102 }103 acmeID, _ := res.LastInsertId()104105 res, err = db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Globex Trading", "standard")106 if err != nil {107 log.Fatalf("seeding organizations: %v", err)108 }109 globexID, _ := res.LastInsertId()110111 db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",112 "sk_live_acme_ops_9f2c1a", acmeID, "admin", "Acme treasury ops key")113 db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",114 "sk_live_globex_finance_4b71e0", globexID, "admin", "Globex finance key")115116 accRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",117 acmeID, "Acme Operating Account", "USD", 500000000, "active")118 if err != nil {119 log.Fatalf("seeding accounts: %v", err)120 }121 acmeAccID, _ := accRes.LastInsertId()122123 globexAccRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",124 globexID, "Globex Trading Account", "EUR", 250000000, "active")125 if err != nil {126 log.Fatalf("seeding accounts: %v", err)127 }128 globexAccID, _ := globexAccRes.LastInsertId()129130 benRes, err := db.Exec("INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, ?)",131 acmeID, "Northwind Supplies", "First Union Bank", "GB29NWBK60161331926819", "active")132 if err != nil {133 log.Fatalf("seeding beneficiaries: %v", err)134 }135 northwindID, _ := benRes.LastInsertId()136137 db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)138 VALUES (?, ?, ?, ?, ?, ?, ?)`,139 acmeID, acmeAccID, northwindID, 340000, "USD", "pending", "Q3 supplies invoice 4472")140 db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)141 VALUES (?, ?, ?, ?, ?, ?, ?)`,142 globexID, globexAccID, nil, 1850000, "EUR", "pending", "Freight contract milestone 2")143}
After remediation
1package main23import (4 "database/sql"5 "log"6 "os"7 "path/filepath"89 _ "modernc.org/sqlite"10)1112var db *sql.DB1314const schema = `15CREATE TABLE IF NOT EXISTS organizations (16 id INTEGER PRIMARY KEY AUTOINCREMENT,17 name TEXT NOT NULL,18 plan TEXT NOT NULL DEFAULT 'standard'19);2021CREATE TABLE IF NOT EXISTS api_keys (22 api_key TEXT PRIMARY KEY,23 org_id INTEGER NOT NULL,24 role TEXT NOT NULL DEFAULT 'service',25 label TEXT26);2728CREATE TABLE IF NOT EXISTS accounts (29 id INTEGER PRIMARY KEY AUTOINCREMENT,30 org_id INTEGER NOT NULL,31 name TEXT NOT NULL,32 currency TEXT NOT NULL DEFAULT 'USD',33 balance_cents INTEGER NOT NULL DEFAULT 0,34 status TEXT NOT NULL DEFAULT 'active'35);3637CREATE TABLE IF NOT EXISTS beneficiaries (38 id INTEGER PRIMARY KEY AUTOINCREMENT,39 org_id INTEGER NOT NULL,40 name TEXT NOT NULL,41 bank_name TEXT,42 iban TEXT,43 status TEXT NOT NULL DEFAULT 'active'44);4546CREATE TABLE IF NOT EXISTS transfers (47 id INTEGER PRIMARY KEY AUTOINCREMENT,48 org_id INTEGER NOT NULL,49 account_id INTEGER NOT NULL,50 beneficiary_id INTEGER,51 amount_cents INTEGER NOT NULL,52 currency TEXT NOT NULL DEFAULT 'USD',53 status TEXT NOT NULL DEFAULT 'pending',54 memo TEXT,55 created_at TEXT NOT NULL DEFAULT (datetime('now')),56 settled_at TEXT57);5859CREATE TABLE IF NOT EXISTS audit_log (60 id INTEGER PRIMARY KEY AUTOINCREMENT,61 org_id INTEGER,62 actor TEXT,63 action TEXT NOT NULL,64 detail TEXT,65 ip TEXT,66 created_at TEXT NOT NULL DEFAULT (datetime('now'))67);68`6970func initDB() {71 dataDir := "data"72 if err := os.MkdirAll(dataDir, 0o755); err != nil {73 log.Fatalf("creating data dir: %v", err)74 }7576 dbPath := filepath.Join(dataDir, "payments.sqlite")77 var err error78 db, err = sql.Open("sqlite", dbPath)79 if err != nil {80 log.Fatalf("opening database: %v", err)81 }8283 if _, err := db.Exec(schema); err != nil {84 log.Fatalf("applying schema: %v", err)85 }8687 seed()88}8990func seed() {91 var count int92 if err := db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count); err != nil {93 log.Fatalf("counting organizations: %v", err)94 }95 if count > 0 {96 return97 }9899 res, err := db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Acme Manufacturing", "enterprise")100 if err != nil {101 log.Fatalf("seeding organizations: %v", err)102 }103 acmeID, _ := res.LastInsertId()104105 res, err = db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Globex Trading", "standard")106 if err != nil {107 log.Fatalf("seeding organizations: %v", err)108 }109 globexID, _ := res.LastInsertId()110111 // API keys are secrets: read them from the environment (or a managed112 // secret store) at runtime instead of committing live key material. If a113 // key is not configured the seed skips it rather than falling back to a114 // hardcoded credential.115 if acmeKey := os.Getenv("ACME_API_KEY"); acmeKey != "" {116 db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",117 acmeKey, acmeID, "admin", "Acme treasury ops key")118 }119 if globexKey := os.Getenv("GLOBEX_API_KEY"); globexKey != "" {120 db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",121 globexKey, globexID, "admin", "Globex finance key")122 }123124 accRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",125 acmeID, "Acme Operating Account", "USD", 500000000, "active")126 if err != nil {127 log.Fatalf("seeding accounts: %v", err)128 }129 acmeAccID, _ := accRes.LastInsertId()130131 globexAccRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",132 globexID, "Globex Trading Account", "EUR", 250000000, "active")133 if err != nil {134 log.Fatalf("seeding accounts: %v", err)135 }136 globexAccID, _ := globexAccRes.LastInsertId()137138 benRes, err := db.Exec("INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, ?)",139 acmeID, "Northwind Supplies", "First Union Bank", "GB29NWBK60161331926819", "active")140 if err != nil {141 log.Fatalf("seeding beneficiaries: %v", err)142 }143 northwindID, _ := benRes.LastInsertId()144145 db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)146 VALUES (?, ?, ?, ?, ?, ?, ?)`,147 acmeID, acmeAccID, northwindID, 340000, "USD", "pending", "Q3 supplies invoice 4472")148 db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)149 VALUES (?, ?, ?, ?, ?, ?, ?)`,150 globexID, globexAccID, nil, 1850000, "EUR", "pending", "Freight contract milestone 2")151}
payments-api/main.go
Before remediation
1package main23import (4 "log"5 "net/http"6 "os"7)89func main() {10 initDB()11 defer db.Close()1213 go settlementSweep()1415 mux := http.NewServeMux()1617 mux.HandleFunc("GET /healthz", healthz)18 mux.HandleFunc("GET /metrics", metrics)1920 mux.HandleFunc("GET /api/accounts/search", withAuth(withAudit("accounts.search")(searchAccounts)))21 mux.HandleFunc("GET /api/accounts/{id}", withAuth(withAudit("accounts.get")(getAccount)))2223 mux.HandleFunc("GET /api/transfers/{id}", withAuth(withAudit("transfers.get")(getTransfer)))24 mux.HandleFunc("POST /api/transfers", withAuth(requireRole("admin", "service")(withAudit("transfers.create")(createTransfer))))2526 mux.HandleFunc("POST /api/beneficiaries", withAuth(requireRole("admin", "service")(withAudit("beneficiaries.create")(createBeneficiary))))2728 port := os.Getenv("PORT")29 if port == "" {30 port = "4001"31 }3233 log.Printf("payments-api listening on :%s", port)34 if err := http.ListenAndServe(":"+port, mux); err != nil {35 log.Fatal(err)36 }37}
After remediation
1package main23import (4 "crypto/tls"5 "log"6 "net/http"7 "os"8)910// newServer builds the HTTP server with a modern TLS configuration. TLS11// verification is left intact (no InsecureSkipVerify); a minimum protocol12// version of TLS 1.2 is enforced for every connection.13func newServer(addr string, handler http.Handler) *http.Server {14 return &http.Server{15 Addr: addr,16 Handler: handler,17 TLSConfig: &tls.Config{18 MinVersion: tls.VersionTLS12,19 },20 }21}2223func main() {24 initDB()25 defer db.Close()2627 go settlementSweep()2829 mux := http.NewServeMux()3031 mux.HandleFunc("GET /healthz", healthz)32 mux.HandleFunc("GET /metrics", metrics)3334 mux.HandleFunc("GET /api/accounts/search", withAuth(withAudit("accounts.search")(searchAccounts)))35 mux.HandleFunc("GET /api/accounts/{id}", withAuth(withAudit("accounts.get")(getAccount)))3637 mux.HandleFunc("GET /api/transfers/{id}", withAuth(withAudit("transfers.get")(getTransfer)))38 mux.HandleFunc("POST /api/transfers", withAuth(requireRole("admin", "service")(withAudit("transfers.create")(createTransfer))))3940 mux.HandleFunc("POST /api/beneficiaries", withAuth(requireRole("admin", "service")(withAudit("beneficiaries.create")(createBeneficiary))))4142 port := os.Getenv("PORT")43 if port == "" {44 port = "4001"45 }4647 // Certificate/key material is supplied at runtime (mount or secret store),48 // never minted in code or committed to the repo. Refuse to start over49 // plaintext so the listener always speaks TLS.50 certFile := os.Getenv("TLS_CERT_FILE")51 keyFile := os.Getenv("TLS_KEY_FILE")52 if certFile == "" || keyFile == "" {53 log.Fatal("TLS_CERT_FILE and TLS_KEY_FILE must be set; refusing to start an unencrypted listener")54 }5556 srv := newServer(":"+port, mux)57 log.Printf("payments-api listening on :%s (TLS)", port)58 if err := srv.ListenAndServeTLS(certFile, keyFile); err != nil {59 log.Fatal(err)60 }61}
payments-api/security_test.go
Before remediation
missing
After remediation
1package main23import (4 "crypto/tls"5 "database/sql"6 "net/http"7 "net/http/httptest"8 "path/filepath"9 "testing"10)1112// TestNewServerEnforcesModernTLS verifies the listener is configured for TLS13// with a modern minimum protocol version.14func TestNewServerEnforcesModernTLS(t *testing.T) {15 srv := newServer(":0", http.NewServeMux())16 if srv.TLSConfig == nil {17 t.Fatal("expected a TLS configuration on the server")18 }19 if srv.TLSConfig.MinVersion != tls.VersionTLS12 {20 t.Fatalf("expected TLS min version 1.2, got %x", srv.TLSConfig.MinVersion)21 }22}2324// TestTLSAcceptsTrustedRejectsUntrusted proves the transport enforces25// certificate verification: a peer that trusts the server cert succeeds while26// a peer using default verification is rejected.27func TestTLSAcceptsTrustedRejectsUntrusted(t *testing.T) {28 mux := http.NewServeMux()29 mux.HandleFunc("GET /healthz", healthz)3031 ts := httptest.NewUnstartedServer(mux)32 ts.TLS = newServer("", mux).TLSConfig // exercise our TLS config33 ts.StartTLS()34 defer ts.Close()3536 // Trusted peer: the test client trusts the generated server certificate.37 resp, err := ts.Client().Get(ts.URL + "/healthz")38 if err != nil {39 t.Fatalf("trusted client should succeed: %v", err)40 }41 resp.Body.Close()42 if resp.StatusCode != http.StatusOK {43 t.Fatalf("expected 200 from trusted client, got %d", resp.StatusCode)44 }4546 // Untrusted peer: default verification must reject the unknown authority.47 if _, err := http.Get(ts.URL + "/healthz"); err == nil {48 t.Fatal("untrusted client should fail TLS verification")49 }50}5152// TestSeedUsesEnvApiKeys proves the seed reads API keys from the environment53// (legitimate flow) and never inserts a hardcoded sk_live_* credential.54func TestSeedUsesEnvApiKeys(t *testing.T) {55 tmp := t.TempDir()56 var err error57 db, err = sql.Open("sqlite", filepath.Join(tmp, "test.sqlite"))58 if err != nil {59 t.Fatalf("opening test db: %v", err)60 }61 defer db.Close()62 if _, err := db.Exec(schema); err != nil {63 t.Fatalf("applying schema: %v", err)64 }6566 t.Setenv("ACME_API_KEY", "runtime-test-value-acme")67 t.Setenv("GLOBEX_API_KEY", "runtime-test-value-globex")6869 seed()7071 var seeded int72 if err := db.QueryRow("SELECT COUNT(*) FROM api_keys WHERE api_key = ?", "runtime-test-value-acme").Scan(&seeded); err != nil {73 t.Fatalf("querying seeded key: %v", err)74 }75 if seeded != 1 {76 t.Fatalf("expected acme key seeded from env, got %d", seeded)77 }7879 var live int80 if err := db.QueryRow("SELECT COUNT(*) FROM api_keys WHERE api_key LIKE 'sk_live_%'").Scan(&live); err != nil {81 t.Fatalf("querying live keys: %v", err)82 }83 if live != 0 {84 t.Fatalf("expected no hardcoded sk_live key, found %d", live)85 }86}