feat: add OWASP API Security Top 10 audit for all 4 APIs
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / deploy (push) Has been cancelled

- Real code audit of Hammer Dashboard, Network App, Todo App, and nKode APIs
- Each API assessed against all 10 OWASP API Security risks with actual findings
- Frontend: OWASP scorecard component with visual grid showing pass/warn/critical
- Scorecard displayed prominently above regular category cards in project detail view
- Each finding has description, status, recommendation, and Create Fix Task support
- Added 'OWASP API Top 10' as category option in Add Audit modal
- Dark mode support throughout
This commit is contained in:
2026-01-30 14:57:52 +00:00
parent 8b8d56370e
commit 797396497a
2 changed files with 250 additions and 1 deletions

View File

@@ -377,6 +377,86 @@ const auditData: {
],
},
// ═══════════════════════════════════════════
// OWASP API SECURITY TOP 10
// ═══════════════════════════════════════════
// --- Hammer Dashboard API ---
{
projectName: "Hammer Dashboard",
category: "OWASP API Top 10",
score: 55,
findings: [
finding("needs_improvement", "API1 - Broken Object Level Authorization (BOLA)", "Tasks API returns all tasks to any authenticated user without user-level ownership filtering. Any authenticated user or bearer token holder can access, modify, or delete any task by ID. The shared task queue design means no per-user data isolation.", "Implement ownership or team-based access controls on task CRUD operations. Filter queries by user context where appropriate."),
finding("needs_improvement", "API2 - Broken Authentication", "Uses BetterAuth with session cookies (good) but also accepts a single static bearer token (API_BEARER_TOKEN) shared across all API consumers. Default fallback is 'hammer-dev-token'. No token rotation or per-client tokens.", "Replace static bearer token with per-client API keys or OAuth2 client credentials. Remove default fallback token. Add token rotation."),
finding("strong", "API3 - Broken Object Property Level Authorization", "Elysia routes use t.Object() schema validation on request bodies, explicitly defining allowed fields. Admin role changes require requireAdmin() check. No mass assignment — fields are individually mapped.", ""),
finding("critical", "API4 - Unrestricted Resource Consumption", "No rate limiting middleware on any endpoint. No pagination on task list endpoint — returns all tasks in a single response. No request size limits configured. Vulnerable to brute-force and resource exhaustion.", "Add rate limiting middleware (per-IP, per-user). Implement pagination with configurable limits. Add request body size limits."),
finding("strong", "API5 - Broken Function Level Authorization", "Admin routes use dedicated requireAdmin() function that checks both bearer token and session role. Regular task routes require either valid session or bearer token via requireSessionOrBearer().", ""),
finding("needs_improvement", "API6 - Unrestricted Access to Sensitive Business Flows", "Open signup is enabled (emailAndPassword.enabled without disableSignUp). No CAPTCHA or bot protection on registration or auth endpoints. Webhook endpoint to Clawdbot could be abused.", "Disable open signup or add CAPTCHA. Implement bot detection on auth flows."),
finding("strong", "API7 - Server Side Request Forgery (SSRF)", "The API makes outbound fetch calls only to hardcoded URLs from environment variables (CLAWDBOT_HOOK_URL, health check URLs). No user-supplied URLs are used in server-side requests. HTTPS-only check on webhook URL.", ""),
finding("needs_improvement", "API8 - Security Misconfiguration", "CORS allows http://localhost:5173 in production. Error handler returns generic messages (good) but error logging is console-only. No security headers (X-Content-Type-Options, X-Frame-Options) configured at the application level.", "Remove localhost from production CORS. Add security response headers. Traefik may add some, but defense-in-depth is better."),
finding("needs_improvement", "API9 - Improper Inventory Management", "No API versioning scheme. No OpenAPI/Swagger documentation. Endpoints are not catalogued. Health endpoint exists but doesn't enumerate API surface.", "Add API versioning (v1 prefix). Generate OpenAPI docs from Elysia schemas. Maintain an API inventory document."),
finding("strong", "API10 - Unsafe Consumption of APIs", "Minimal external API consumption. Webhook to Clawdbot uses HTTPS with bearer auth and validates URL scheme. Health check fetches use hardcoded URLs with timeouts. No user-data-driven external calls.", ""),
],
},
// --- Network App API ---
{
projectName: "Network App",
category: "OWASP API Top 10",
score: 80,
findings: [
finding("strong", "API1 - Broken Object Level Authorization (BOLA)", "Excellent user-level data isolation. Client queries consistently use eq(clients.userId, user.id) for all CRUD operations. Individual record access combines ID check with userId ownership check: and(eq(clients.id, params.id), eq(clients.userId, user.id)). This pattern prevents horizontal privilege escalation.", ""),
finding("strong", "API2 - Broken Authentication", "BetterAuth with invite-only registration (disableSignUp: true). Bearer token plugin for mobile. POST /api/auth/sign-up/email explicitly returns 403 as defense-in-depth. Session-based auth with proper expiry (7 days, daily refresh).", ""),
finding("needs_improvement", "API3 - Broken Object Property Level Authorization", "Client creation and update routes accept a 'role' field (t.Optional(t.String())) without validation against allowed values. Users can set arbitrary role strings on client records. Other fields are properly validated with Elysia schemas.", "Restrict 'role' field to an enum of allowed values. Consider separating role updates into admin-only endpoints."),
finding("strong", "API4 - Unrestricted Resource Consumption", "Rate limiting middleware implemented with per-IP buckets: 5 req/min on auth, 10 req/min on AI endpoints, 100 req/min global. Client list supports pagination with Math.min(200, limit) cap. Expired entries cleaned up every 60s.", ""),
finding("strong", "API5 - Broken Function Level Authorization", "Admin routes use authMiddleware + onBeforeHandle guard that checks user.role === 'admin'. Returns 403 for non-admin users. All admin operations properly gated.", ""),
finding("strong", "API6 - Unrestricted Access to Sensitive Business Flows", "Signup is disabled (invite-only). Rate limiting on auth endpoints (5/min) prevents brute-force. No publicly accessible business-critical flows that could be automated.", ""),
finding("strong", "API7 - Server Side Request Forgery (SSRF)", "No outbound fetch/HTTP calls in route handlers. API does not accept URLs from users for server-side processing. External integrations (Resend email) use only env-configured endpoints.", ""),
finding("needs_improvement", "API8 - Security Misconfiguration", "Error handler includes stack traces in responses (even in production). CORS falls back to localhost:3000 if ALLOWED_ORIGINS env not set. Rate limit headers properly set.", "Only include stack traces when NODE_ENV !== 'production'. Set strict default CORS origins."),
finding("needs_improvement", "API9 - Improper Inventory Management", "No API versioning. 28+ route files with no OpenAPI documentation. No API changelog or deprecation policy. Large API surface (clients, events, interactions, emails, etc.) without formal inventory.", "Generate OpenAPI docs from Elysia route schemas. Add versioning prefix. Maintain API inventory."),
finding("needs_improvement", "API10 - Unsafe Consumption of APIs", "Uses LangChain with Anthropic/OpenAI for AI features. Resend SDK for emails. Third-party API responses (AI-generated content) are served to users without explicit sanitization or validation of the returned content.", "Validate and sanitize AI-generated content before serving to users. Add circuit breakers for external API calls."),
],
},
// --- Todo App API ---
{
projectName: "Todo App",
category: "OWASP API Top 10",
score: 60,
findings: [
finding("strong", "API1 - Broken Object Level Authorization (BOLA)", "Task queries filter by userId: eq(tasks.userId, userId) and project access checks include eq(projects.userId, userId). Individual task access verifies ownership. Hammer service route has separate service-account auth with appropriate scope.", ""),
finding("strong", "API2 - Broken Authentication", "BetterAuth with invite system using expiring tokens. Separate auth for Hammer service via dedicated API key (HAMMER_API_KEY env var). Auth middleware consistently applied via authMiddleware plugin.", ""),
finding("needs_improvement", "API3 - Broken Object Property Level Authorization", "Hammer service route has broad access to create/update tasks for any user. While authenticated with separate API key, the service can modify fields like assignee without additional validation. Regular user routes properly restrict via Elysia schemas.", "Add field-level restrictions to Hammer service routes. Validate that service operations are scoped appropriately."),
finding("critical", "API4 - Unrestricted Resource Consumption", "No rate limiting middleware found on any endpoint. Task list endpoint has filters but no pagination limits — could return unbounded results. No request body size limits configured.", "Implement rate limiting similar to Network App's approach. Add pagination with max limits. Add body size limits."),
finding("strong", "API5 - Broken Function Level Authorization", "Admin routes check user.role with dedicated guard. Hammer routes use separate API key validation. Regular routes use authMiddleware. Clear separation of privilege levels.", ""),
finding("strong", "API6 - Unrestricted Access to Sensitive Business Flows", "Registration is invite-only with expiring tokens. Invite system validates token status and expiry before acceptance. No publicly abusable business flows.", ""),
finding("needs_improvement", "API7 - Server Side Request Forgery (SSRF)", "Hammer webhook system fetches user-configured webhook URLs (hammerWebhooks table). While URLs are stored by admin users, there's no validation that URLs point to external/safe targets. Could be used to probe internal services.", "Validate webhook URLs against an allowlist or block internal IP ranges (RFC 1918). Add URL scheme validation (HTTPS only)."),
finding("needs_improvement", "API8 - Security Misconfiguration", "CORS falls back to localhost:5173 and todo.donovankelly.xyz if env not set. Error handler checks NODE_ENV for stack traces (good) but NODE_ENV must be properly set. Some debug logging in production.", "Ensure NODE_ENV=production in container. Set strict CORS defaults. Remove debug logging."),
finding("needs_improvement", "API9 - Improper Inventory Management", "No API versioning. Routes not documented. Hammer integration endpoints alongside user endpoints without clear API boundary. No OpenAPI specification.", "Add API versioning. Generate OpenAPI docs. Separate internal (Hammer) and external API surfaces."),
finding("needs_improvement", "API10 - Unsafe Consumption of APIs", "Webhook responses from external targets are not validated. The triggerHammerWebhooks function fires and forgets without checking response validity. External webhook targets could return malicious data.", "Validate webhook delivery responses. Add timeouts and circuit breakers for webhook calls."),
],
},
// --- nKode Backend ---
{
projectName: "nKode",
category: "OWASP API Top 10",
score: 70,
findings: [
finding("strong", "API1 - Broken Object Level Authorization (BOLA)", "AuthenticatedSession extractor in extractors.rs extracts user_id from cryptographic session. Login data endpoint uses user_id from path (/login-data/{user_id}) but session validation ensures the authenticated user matches. Rust type system prevents accidental data leaks.", ""),
finding("strong", "API2 - Broken Authentication", "Uses OPAQUE protocol (opaque-ke v4) — server never sees plaintext passwords. Argon2 password hashing configured. Full OIDC implementation with JWK signing. Cryptographic session management with signature verification (HEADER_SIGNATURE, HEADER_TIMESTAMP).", ""),
finding("strong", "API3 - Broken Object Property Level Authorization", "Rust's serde deserialization enforces strict type contracts. Request bodies are deserialized into specific structs with only defined fields accepted. No dynamic field assignment or mass-assignment possible due to Rust's type system.", ""),
finding("critical", "API4 - Unrestricted Resource Consumption", "No rate limiting middleware found in the Axum router setup. No tower-governor or similar crate. Login/registration OPAQUE flows are computationally expensive (Argon2) — could be abused for DoS. No request size limits visible.", "Add tower-governor rate limiting middleware. Limit OPAQUE registration/login attempts per IP. Add body size limits."),
finding("needs_improvement", "API5 - Broken Function Level Authorization", "AuthenticatedSession extractor provides user_id and session_type (Key vs Code). However, no visible role-based access control. All authenticated users appear to have equal access to all endpoints. Icon management and login-data endpoints lack admin/user distinction.", "Implement RBAC if different permission levels are needed. Add admin-only guards for management endpoints."),
finding("strong", "API6 - Unrestricted Access to Sensitive Business Flows", "OPAQUE protocol makes credential stuffing extremely difficult — interactive multi-step protocol. Registration requires prior session. No simple signup endpoint that bots could abuse.", ""),
finding("strong", "API7 - Server Side Request Forgery (SSRF)", "No outbound HTTP/fetch calls in any route handlers. API only processes direct requests and database operations. No URL processing or server-side resource fetching from user input.", ""),
finding("needs_improvement", "API8 - Security Misconfiguration", "CORS includes localhost:3000 and localhost:5173 in production code (hardcoded, not env-based). Uses tracing for structured logging (good). Error responses use proper Axum error types.", "Move development CORS origins to environment variables. Remove hardcoded localhost origins from production builds."),
finding("needs_improvement", "API9 - Improper Inventory Management", "OIDC discovery endpoint provides some API documentation (.well-known/openid-configuration). However, non-OIDC endpoints (icons, login-data) are undocumented. No API versioning despite v1 route grouping. No deprecation policy.", "Generate API documentation for all endpoints. Maintain formal API inventory."),
finding("strong", "API10 - Unsafe Consumption of APIs", "No third-party API consumption. Self-contained backend with its own authentication (OPAQUE), authorization (sessions), and data storage. Icon generation uses internal services only.", ""),
],
},
// ═══════════════════════════════════════════
// INFRASTRUCTURE
// ═══════════════════════════════════════════

View File

@@ -170,10 +170,27 @@ function categoryIcon(category: string): string {
"Dependency Security": "📦",
"Logging & Monitoring": "📊",
Compliance: "📋",
"OWASP API Top 10": "🔥",
};
return icons[category] || "🔍";
}
// OWASP API ID labels for scorecard display
const OWASP_API_IDS = [
"API1", "API2", "API3", "API4", "API5",
"API6", "API7", "API8", "API9", "API10",
];
function getOwaspId(title: string): string | null {
const match = title.match(/^(API\d+)\s*-/);
return match ? match[1] : null;
}
function getOwaspShortName(title: string): string {
const match = title.match(/^API\d+\s*-\s*(.+)/);
return match ? match[1] : title;
}
function timeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
@@ -580,6 +597,7 @@ function AddAuditModal({
"Dependency Security",
"Logging & Monitoring",
"Compliance",
"OWASP API Top 10",
];
const handleCreate = async () => {
@@ -909,6 +927,145 @@ function CategoryCard({
);
}
// ─── OWASP API Top 10 Scorecard ───
function OwaspScorecard({
audit,
onTaskCreated,
}: {
audit: SecurityAudit;
onTaskCreated: (auditId: string, findingId: string, taskId: string) => void;
}) {
const findings = audit.findings || [];
const strongCount = findings.filter((f) => f.status === "strong").length;
const warningCount = findings.filter((f) => f.status === "needs_improvement").length;
const criticalCount = findings.filter((f) => f.status === "critical").length;
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">🔥</span>
<div>
<h3 className="text-base font-bold text-gray-900 dark:text-white">
OWASP API Security Top 10
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
2023 Edition API-specific security risks
</p>
</div>
</div>
<div className="flex items-center gap-2">
<ScoreRing score={audit.score} size={56} />
</div>
</div>
{/* Visual scorecard grid */}
<div className="flex items-center gap-1.5 mt-4">
{OWASP_API_IDS.map((apiId) => {
const finding = findings.find((f) => getOwaspId(f.title) === apiId);
if (!finding) return (
<div key={apiId} className="flex-1 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<span className="text-[9px] font-medium text-gray-400">{apiId}</span>
</div>
);
const bgClass = finding.status === "strong"
? "bg-green-500/20 border border-green-500/40"
: finding.status === "critical"
? "bg-red-500/20 border border-red-500/40"
: "bg-yellow-500/20 border border-yellow-500/40";
const textClass = finding.status === "strong"
? "text-green-600 dark:text-green-400"
: finding.status === "critical"
? "text-red-600 dark:text-red-400"
: "text-yellow-600 dark:text-yellow-400";
return (
<div
key={apiId}
className={`flex-1 h-8 rounded flex items-center justify-center ${bgClass}`}
title={finding.title}
>
<span className={`text-[9px] font-bold ${textClass}`}>{apiId}</span>
</div>
);
})}
</div>
{/* Summary counts */}
<div className="flex items-center gap-4 mt-3 text-xs">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-gray-600 dark:text-gray-400">{strongCount} strong</span>
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-gray-600 dark:text-gray-400">{warningCount} needs work</span>
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-gray-600 dark:text-gray-400">{criticalCount} critical</span>
</span>
</div>
</div>
{/* Finding details */}
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{findings.map((finding) => {
const owaspId = getOwaspId(finding.title);
const shortName = getOwaspShortName(finding.title);
return (
<div key={finding.id} className="px-5 py-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{owaspId && (
<span className={`inline-block text-[10px] font-bold px-1.5 py-0.5 rounded ${statusBadgeClass(finding.status)}`}>
{owaspId}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm">{statusIcon(finding.status)}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{shortName}
</span>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${statusBadgeClass(finding.status)}`}>
{statusLabel(finding.status)}
</span>
<CreateFixTaskButton
finding={finding}
projectName={audit.projectName}
onTaskCreated={(findingId, taskId) =>
onTaskCreated(audit.id, findingId, taskId)
}
/>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{finding.description}
</p>
{finding.recommendation && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5">
💡 {finding.recommendation}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 py-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-[10px] text-gray-400">
Last audited {timeAgo(audit.lastAudited)}
</span>
</div>
</div>
);
}
// ─── Project Detail View ───
function ProjectDetail({
@@ -923,6 +1080,8 @@ function ProjectDetail({
const [editingAudit, setEditingAudit] = useState<SecurityAudit | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const projectAudits = audits.filter((a) => a.projectName === projectName);
const owaspAudit = projectAudits.find((a) => a.category === "OWASP API Top 10");
const regularAudits = projectAudits.filter((a) => a.category !== "OWASP API Top 10");
const avgScore = projectAudits.length
? Math.round(
@@ -1018,9 +1177,19 @@ function ProjectDetail({
</div>
</div>
{/* OWASP API Top 10 Scorecard (if exists for this project) */}
{owaspAudit && (
<div className="mb-6">
<OwaspScorecard
audit={owaspAudit}
onTaskCreated={onTaskCreated}
/>
</div>
)}
{/* Category cards */}
<div className="space-y-3">
{projectAudits.map((audit) => (
{regularAudits.map((audit) => (
<CategoryCard
key={audit.id}
audit={audit}