Compare commits
30 Commits
836a9f3774
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e | |||
| bf3d01becf | |||
| 9384ded04f | |||
| 0c9c3b5514 | |||
| 4a50cd100b | |||
| c22a3a70ab | |||
| 626d0cac3a | |||
| ba4d16396c | |||
| 83d944fa70 | |||
| 26abd55e0f | |||
| eae4594baf | |||
| 84803a353e | |||
| d520c4ae41 | |||
| da109a6f8b | |||
| 260ae48f77 | |||
| fbdf05392a | |||
| 1a578b23c1 | |||
| 41c8a1ef27 | |||
| 8947a2864d | |||
| 792467930d | |||
| 1a6b51dea3 | |||
| 3eb608617c |
89
.claude/agents/backend.md
Normal file
89
.claude/agents/backend.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Backend Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the backend specialist for the CVE Dashboard project. You manage the Express.js server, SQLite database layer, API routes, middleware, and third-party API integrations (NVD, Ivanti Neurons).
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Runtime:** Node.js v18+
|
||||||
|
- **Framework:** Express.js 4.x
|
||||||
|
- **Database:** SQLite3 (file: `backend/cve_database.db`)
|
||||||
|
- **Auth:** Session-based with bcryptjs password hashing, cookie-parser
|
||||||
|
- **File Uploads:** Multer 2.0.2 with security hardening
|
||||||
|
- **Environment:** dotenv for config management
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/server.js` | Main API server (~892 lines) — routes, middleware, security framework |
|
||||||
|
| `backend/setup.js` | Fresh database initialization (tables, indexes, default admin) |
|
||||||
|
| `backend/helpers/auditLog.js` | Fire-and-forget audit logging helper |
|
||||||
|
| `backend/middleware/auth.js` | `requireAuth(db)` and `requireRole()` middleware |
|
||||||
|
| `backend/routes/auth.js` | Login/logout/session endpoints |
|
||||||
|
| `backend/routes/users.js` | User CRUD (admin only) |
|
||||||
|
| `backend/routes/auditLog.js` | Audit log retrieval with filtering |
|
||||||
|
| `backend/routes/nvdLookup.js` | NVD API 2.0 proxy endpoint |
|
||||||
|
| `backend/.env.example` | Environment variable template |
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- **cves**: `UNIQUE(cve_id, vendor)` — multi-vendor support
|
||||||
|
- **documents**: linked by `cve_id + vendor`, tracks file metadata
|
||||||
|
- **users**: username, email, password_hash, role (admin/editor/viewer), is_active
|
||||||
|
- **sessions**: session_id, user_id, expires_at (24hr)
|
||||||
|
- **required_documents**: vendor-specific mandatory doc types
|
||||||
|
- **audit_logs**: user_id, username, action, entity_type, entity_id, details, ip_address
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `POST /api/auth/login|logout`, `GET /api/auth/me` — Authentication
|
||||||
|
- `GET|POST|PUT|DELETE /api/cves` — CVE CRUD with role enforcement
|
||||||
|
- `GET /api/cves/check/:cveId` — Quick check (multi-vendor)
|
||||||
|
- `GET /api/cves/:cveId/vendors` — Vendors for a CVE
|
||||||
|
- `POST /api/cves/:cveId/documents` — Upload documents
|
||||||
|
- `DELETE /api/documents/:id` — Admin-only document deletion
|
||||||
|
- `GET /api/vendors` — Vendor list
|
||||||
|
- `GET /api/stats` — Dashboard statistics
|
||||||
|
- `GET /api/nvd/lookup/:cveId` — NVD proxy (10s timeout, severity cascade v3.1>v3.0>v2.0)
|
||||||
|
- `POST /api/cves/nvd-sync` — Bulk NVD update with audit logging
|
||||||
|
- `GET|POST /api/audit-logs` — Audit log (admin only)
|
||||||
|
- `GET|POST|PUT|DELETE /api/users` — User management (admin only)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```
|
||||||
|
PORT=3001
|
||||||
|
API_HOST=<server-ip>
|
||||||
|
CORS_ORIGINS=http://<server-ip>:3000
|
||||||
|
SESSION_SECRET=<secret>
|
||||||
|
NVD_API_KEY=<optional>
|
||||||
|
IVANTI_API_KEY=<future>
|
||||||
|
IVANTI_CLIENT_ID=<future>
|
||||||
|
IVANTI_BASE_URL=https://platform4.risksense.com/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Security (MANDATORY)
|
||||||
|
1. **Input validation first** — Validate all inputs before any DB operation. Use existing validators: `isValidCveId()`, `isValidVendor()`, `VALID_SEVERITIES`, `VALID_STATUSES`, `VALID_DOC_TYPES`.
|
||||||
|
2. **Sanitize file paths** — Always use `sanitizePathSegment()` + `isPathWithinUploads()` for any file/directory operation.
|
||||||
|
3. **Never leak internals** — 500 responses use generic `"Internal server error."` only. Log full error server-side.
|
||||||
|
4. **Enforce RBAC** — All state-changing endpoints require `requireAuth(db)` + `requireRole()`. Viewers are read-only.
|
||||||
|
5. **Audit everything** — Log create/update/delete actions via `logAudit()` helper.
|
||||||
|
6. **File upload restrictions** — Extension allowlist + MIME validation. No executables.
|
||||||
|
7. **Parameterized queries only** — Never interpolate user input into SQL strings.
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- Follow existing patterns in `server.js` for new endpoints.
|
||||||
|
- New routes go in `backend/routes/` as separate files, mounted in `server.js`.
|
||||||
|
- Use async/await with try-catch. Wrap db calls in `db.get()`, `db.all()`, `db.run()`.
|
||||||
|
- Keep responses consistent: `{ success: true, data: ... }` or `{ error: "message" }`.
|
||||||
|
- Add JSDoc-style comments only for non-obvious logic.
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Never modify tables directly in route code. Create migration scripts in `backend/` (pattern: `migrate_<feature>.js`).
|
||||||
|
- Always back up the DB before migrations.
|
||||||
|
- Add appropriate indexes for new query patterns.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- After making changes, verify the server starts cleanly: `node backend/server.js`.
|
||||||
|
- Test new endpoints with curl examples.
|
||||||
|
- Check that existing endpoints still work (no regressions).
|
||||||
107
.claude/agents/frontend.md
Normal file
107
.claude/agents/frontend.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Frontend Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the frontend specialist for the CVE Dashboard project. You build and maintain the React UI, handle client-side state, manage API communication, and implement user-facing features.
|
||||||
|
|
||||||
|
**IMPORTANT:** When creating new UI components or implementing frontend features, you should use the `frontend-design` skill to ensure production-grade, distinctive design quality. Invoke this skill using the Skill tool with `skill: "frontend-design"`.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Framework:** React 18.2.4 (Create React App)
|
||||||
|
- **Styling:** Tailwind CSS (loaded via CDN in `public/index.html`)
|
||||||
|
- **Icons:** Lucide React
|
||||||
|
- **State:** React useState/useEffect + Context API (AuthContext)
|
||||||
|
- **API Communication:** Fetch API with credentials: 'include' for session cookies
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/App.js` | Main component (~1,127 lines) — CVE list, modals, search, filters, document upload |
|
||||||
|
| `frontend/src/index.js` | React entry point |
|
||||||
|
| `frontend/src/App.css` | Global styles |
|
||||||
|
| `frontend/src/components/LoginForm.js` | Login page |
|
||||||
|
| `frontend/src/components/UserMenu.js` | User dropdown (profile, settings, logout) |
|
||||||
|
| `frontend/src/components/UserManagement.js` | Admin user management interface |
|
||||||
|
| `frontend/src/components/AuditLog.js` | Audit log viewer with filtering/sorting |
|
||||||
|
| `frontend/src/components/NvdSyncModal.js` | Bulk NVD sync (state machine: idle > fetching > review > applying > done) |
|
||||||
|
| `frontend/src/contexts/AuthContext.js` | Auth state + `useAuth()` hook |
|
||||||
|
| `frontend/public/index.html` | HTML shell (includes Tailwind CDN script) |
|
||||||
|
| `frontend/.env.example` | Environment variable template |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```
|
||||||
|
REACT_APP_API_BASE=http://<server-ip>:3001/api
|
||||||
|
REACT_APP_API_HOST=http://<server-ip>:3001
|
||||||
|
```
|
||||||
|
**Critical:** React caches env vars at build time. After `.env` changes, the dev server must be fully restarted (not just refreshed).
|
||||||
|
|
||||||
|
### API Base URL
|
||||||
|
All fetch calls use `process.env.REACT_APP_API_BASE` as the base URL. Requests include `credentials: 'include'` for session cookie auth.
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
1. `LoginForm.js` posts credentials to `/api/auth/login`
|
||||||
|
2. Server returns session cookie (httpOnly, sameSite: lax)
|
||||||
|
3. `AuthContext.js` checks `/api/auth/me` on mount to restore sessions
|
||||||
|
4. `useAuth()` hook provides `user`, `login()`, `logout()`, `loading` throughout the app
|
||||||
|
5. Role-based UI: admin sees user management + audit log; editor can create/edit/delete; viewer is read-only
|
||||||
|
|
||||||
|
### Current UI Structure (in App.js)
|
||||||
|
- **Header**: App title, stats bar, Quick Check input, "Add CVE" button, "Sync with NVD" button (editor/admin), User Menu
|
||||||
|
- **Filters**: Search input, vendor dropdown, severity dropdown
|
||||||
|
- **CVE List**: Grouped by CVE ID, each group shows vendor rows with status badges, document counts, edit/delete buttons
|
||||||
|
- **Modals**: Add CVE (with NVD auto-fill), Edit CVE (with NVD update), Document Upload, NVD Sync
|
||||||
|
- **Admin Views**: User Management tab, Audit Log tab
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
- New UI features should be extracted into separate components under `frontend/src/components/`.
|
||||||
|
- Use functional components with hooks. No class components.
|
||||||
|
- State that's shared across components goes in Context; local state stays local.
|
||||||
|
- Destructure props. Use meaningful variable names.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Use Tailwind CSS utility classes exclusively. No custom CSS unless absolutely necessary.
|
||||||
|
- Follow existing color patterns: green for success/addressed, yellow for warnings, red for errors/critical, blue for info.
|
||||||
|
- Responsive design: use Tailwind responsive prefixes (sm:, md:, lg:).
|
||||||
|
- Dark mode is not currently implemented — do not add it unless requested.
|
||||||
|
|
||||||
|
### API Communication
|
||||||
|
- Always use `fetch()` with `credentials: 'include'`.
|
||||||
|
- Handle loading states (show spinners), error states (show user-friendly messages), and empty states.
|
||||||
|
- On 401 responses, redirect to login (session expired).
|
||||||
|
- Pattern:
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${process.env.REACT_APP_API_BASE}/endpoint`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) { /* handle error */ }
|
||||||
|
const result = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role-Based UI
|
||||||
|
- Check `user.role` before rendering admin/editor controls.
|
||||||
|
- Viewers see data but no create/edit/delete buttons.
|
||||||
|
- Editors see create/edit/delete for CVEs and documents.
|
||||||
|
- Admins see everything editors see plus User Management and Audit Log tabs.
|
||||||
|
|
||||||
|
### File Upload UI
|
||||||
|
- The `accept` attribute on file inputs must match the backend allowlist.
|
||||||
|
- Current allowed: `.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.svg,.zip,.tar,.gz,.7z,.rar,.eml,.msg`
|
||||||
|
- Max file size: 10MB (enforced backend, show friendly message on 413).
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- No inline styles — use Tailwind classes.
|
||||||
|
- Extract repeated logic into custom hooks or utility functions.
|
||||||
|
- Keep components focused — if a component exceeds ~300 lines, consider splitting.
|
||||||
|
- Use `key` props correctly on lists (use unique IDs, not array indexes).
|
||||||
|
- Clean up useEffect subscriptions and timers.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- After making changes, verify the frontend compiles: `cd frontend && npm start` (or check for build errors).
|
||||||
|
- Test in browser: check console for errors, verify API calls succeed.
|
||||||
|
- Test role-based visibility with different user accounts.
|
||||||
138
.claude/agents/security.md
Normal file
138
.claude/agents/security.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Security Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the security specialist for the CVE Dashboard project. You perform code reviews, dependency audits, and vulnerability assessments. You identify security issues and recommend fixes aligned with the project's existing security framework.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Application Profile
|
||||||
|
- **Type:** Internal vulnerability management tool (Charter Communications)
|
||||||
|
- **Users:** Security team members with assigned roles (admin/editor/viewer)
|
||||||
|
- **Data Sensitivity:** CVE remediation status, vendor documentation, user credentials
|
||||||
|
- **Exposure:** Internal network (home lab / corporate network), not internet-facing
|
||||||
|
|
||||||
|
### Tech Stack Security Surface
|
||||||
|
| Layer | Technology | Key Risks |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| Frontend | React 18, Tailwind CDN | XSS, CSRF, sensitive data in client state |
|
||||||
|
| Backend | Express.js 4.x | Injection, auth bypass, path traversal, DoS |
|
||||||
|
| Database | SQLite3 | SQL injection, file access, no encryption at rest |
|
||||||
|
| Auth | bcryptjs + session cookies | Session fixation, brute force, weak passwords |
|
||||||
|
| File Upload | Multer | Unrestricted upload, path traversal, malicious files |
|
||||||
|
| External API | NVD API 2.0 | SSRF, response injection, rate limit abuse |
|
||||||
|
|
||||||
|
### Existing Security Controls
|
||||||
|
These are already implemented — verify they remain intact during reviews:
|
||||||
|
|
||||||
|
**Input Validation (backend/server.js)**
|
||||||
|
- CVE ID: `/^CVE-\d{4}-\d{4,}$/` via `isValidCveId()`
|
||||||
|
- Vendor: non-empty, max 200 chars via `isValidVendor()`
|
||||||
|
- Severity: enum `VALID_SEVERITIES` (Critical, High, Medium, Low)
|
||||||
|
- Status: enum `VALID_STATUSES` (Open, Addressed, In Progress, Resolved)
|
||||||
|
- Document type: enum `VALID_DOC_TYPES` (advisory, email, screenshot, patch, other)
|
||||||
|
- Description: max 10,000 chars
|
||||||
|
- Published date: `YYYY-MM-DD` format
|
||||||
|
|
||||||
|
**File Upload Security**
|
||||||
|
- Extension allowlist: `ALLOWED_EXTENSIONS` — documents only, all executables blocked
|
||||||
|
- MIME type validation: `ALLOWED_MIME_PREFIXES` — image/*, text/*, application/pdf, Office types
|
||||||
|
- Filename sanitization: strips `/`, `\`, `..`, null bytes
|
||||||
|
- File size limit: 10MB
|
||||||
|
|
||||||
|
**Path Traversal Prevention**
|
||||||
|
- `sanitizePathSegment(segment)` — strips dangerous characters from path components
|
||||||
|
- `isPathWithinUploads(targetPath)` — verifies resolved path stays within uploads root
|
||||||
|
|
||||||
|
**Authentication & Sessions**
|
||||||
|
- bcryptjs password hashing (default rounds)
|
||||||
|
- Session cookies: `httpOnly: true`, `sameSite: 'lax'`, `secure` in production
|
||||||
|
- 24-hour session expiry
|
||||||
|
- Role-based access control on all state-changing endpoints
|
||||||
|
|
||||||
|
**Security Headers**
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY`
|
||||||
|
- `X-XSS-Protection: 1; mode=block`
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
- Generic 500 responses (no `err.message` to client)
|
||||||
|
- Full errors logged server-side
|
||||||
|
- Static file serving: `dotfiles: 'deny'`, `index: false`
|
||||||
|
- JSON body limit: 1MB
|
||||||
|
|
||||||
|
### Key Files to Review
|
||||||
|
| File | Security Relevance |
|
||||||
|
|------|-------------------|
|
||||||
|
| `backend/server.js` | Central security framework, all core routes, file handling |
|
||||||
|
| `backend/middleware/auth.js` | Authentication and authorization middleware |
|
||||||
|
| `backend/routes/auth.js` | Login/logout, session management |
|
||||||
|
| `backend/routes/users.js` | User CRUD, password handling |
|
||||||
|
| `backend/routes/nvdLookup.js` | External API proxy (SSRF risk) |
|
||||||
|
| `backend/routes/auditLog.js` | Audit log access control |
|
||||||
|
| `frontend/src/contexts/AuthContext.js` | Client-side auth state |
|
||||||
|
| `frontend/src/App.js` | Client-side input handling, API calls |
|
||||||
|
| `frontend/src/components/LoginForm.js` | Credential handling |
|
||||||
|
| `.gitignore` | Verify secrets are excluded |
|
||||||
|
|
||||||
|
## Review Checklists
|
||||||
|
|
||||||
|
### Code Review (run on all PRs/changes)
|
||||||
|
1. **Injection** — Are all database queries parameterized? No string interpolation in SQL.
|
||||||
|
2. **Authentication** — Do new state-changing endpoints use `requireAuth(db)` + `requireRole()`?
|
||||||
|
3. **Authorization** — Is role checking correct? (admin-only vs editor+ vs all authenticated)
|
||||||
|
4. **Input Validation** — Are all user inputs validated before use? New fields need validators.
|
||||||
|
5. **File Operations** — Do file/directory operations use `sanitizePathSegment()` + `isPathWithinUploads()`?
|
||||||
|
6. **Error Handling** — Do 500 responses avoid leaking `err.message`? Are errors logged server-side?
|
||||||
|
7. **Audit Logging** — Are create/update/delete actions logged via `logAudit()`?
|
||||||
|
8. **CORS** — Is `CORS_ORIGINS` still restrictive? No wildcards in production.
|
||||||
|
9. **Dependencies** — Any new packages? Check for known vulnerabilities.
|
||||||
|
10. **Secrets** — No hardcoded credentials, API keys, or secrets in code. All in `.env`.
|
||||||
|
|
||||||
|
### Dependency Audit
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && npm audit
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm audit
|
||||||
|
```
|
||||||
|
- Flag any `high` or `critical` severity findings.
|
||||||
|
- Check for outdated packages with known CVEs: `npm outdated`.
|
||||||
|
- Review new dependencies: check npm page, weekly downloads, last publish date, maintainer reputation.
|
||||||
|
|
||||||
|
### OWASP Top 10 Mapping
|
||||||
|
| OWASP Category | Status | Notes |
|
||||||
|
|---------------|--------|-------|
|
||||||
|
| A01 Broken Access Control | Mitigated | RBAC + session auth on all endpoints |
|
||||||
|
| A02 Cryptographic Failures | Partial | bcrypt for passwords; no encryption at rest for DB/files |
|
||||||
|
| A03 Injection | Mitigated | Parameterized queries, input validation |
|
||||||
|
| A04 Insecure Design | Acceptable | Internal tool with limited user base |
|
||||||
|
| A05 Security Misconfiguration | Mitigated | Security headers, CORS config, dotfiles denied |
|
||||||
|
| A06 Vulnerable Components | Monitor | Run `npm audit` regularly |
|
||||||
|
| A07 Auth Failures | Mitigated | Session-based auth, bcrypt, httpOnly cookies |
|
||||||
|
| A08 Data Integrity Failures | Partial | File type validation; no code signing |
|
||||||
|
| A09 Logging & Monitoring | Mitigated | Audit logging on all mutations |
|
||||||
|
| A10 SSRF | Partial | NVD proxy validates CVE ID format; review for Ivanti integration |
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
When reporting findings, use this structure:
|
||||||
|
```
|
||||||
|
### [SEVERITY] Finding Title
|
||||||
|
- **Location:** file:line_number
|
||||||
|
- **Issue:** Description of the vulnerability
|
||||||
|
- **Impact:** What an attacker could achieve
|
||||||
|
- **Recommendation:** Specific fix with code example
|
||||||
|
- **OWASP:** Category reference
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity levels: CRITICAL, HIGH, MEDIUM, LOW, INFO
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Never suggest disabling security controls for convenience.
|
||||||
|
2. Recommendations must be compatible with the existing security framework — extend it, don't replace it.
|
||||||
|
3. Flag any regression in existing security controls immediately.
|
||||||
|
4. For dependency issues, provide the specific CVE and affected version range.
|
||||||
|
5. Consider the threat model — this is an internal tool, not internet-facing. Prioritize accordingly.
|
||||||
|
6. When reviewing file upload changes, always verify both frontend `accept` attribute and backend allowlist stay in sync.
|
||||||
|
7. Do not recommend changes that would break existing functionality without a migration path.
|
||||||
25
.claude/instructions.md
Normal file
25
.claude/instructions.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Project Instructions
|
||||||
|
|
||||||
|
## Token Usage & Efficiency
|
||||||
|
Follow the guidelines in `.claude/optimization.md` for:
|
||||||
|
- When to use subagents vs main conversation
|
||||||
|
- Model selection (Haiku vs Sonnet)
|
||||||
|
- Token preservation strategies
|
||||||
|
- Rate limiting rules
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
This is a CVE (Common Vulnerabilities and Exposures) dashboard application for tracking security vulnerabilities, vendors, and JIRA tickets.
|
||||||
|
|
||||||
|
## Security Focus
|
||||||
|
All code changes should consider:
|
||||||
|
- Input validation
|
||||||
|
- SQL injection prevention
|
||||||
|
- XSS protection
|
||||||
|
- Authentication/authorization
|
||||||
|
|
||||||
|
## Frontend Development
|
||||||
|
When working on frontend features or UI components:
|
||||||
|
- Use the `frontend-design` skill for new component creation and UI implementation
|
||||||
|
- This skill provides production-grade design quality and avoids generic AI aesthetics
|
||||||
|
- Invoke it using: `Skill` tool with `skill: "frontend-design"`
|
||||||
|
- The skill will guide implementation with distinctive, polished code patterns
|
||||||
143
.claude/optimization.md
Normal file
143
.claude/optimization.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
OPTIMIZATION.md - Token Usage & Subagent Strategy
|
||||||
|
|
||||||
|
## SUBAGENT USAGE STRATEGY
|
||||||
|
|
||||||
|
Subagents run in separate contexts and preserve main conversation tokens.
|
||||||
|
|
||||||
|
### When to Use Subagents
|
||||||
|
|
||||||
|
**Use Subagents for:**
|
||||||
|
- Large-scale codebase exploration and analysis
|
||||||
|
- Complex multi-step investigations across many files
|
||||||
|
- Detailed code pattern searches and refactoring analysis
|
||||||
|
- Gathering comprehensive information before main conversation work
|
||||||
|
- When total tokens would exceed 30,000 in main conversation
|
||||||
|
|
||||||
|
**Keep in Main Conversation:**
|
||||||
|
- Direct file edits (1-3 files)
|
||||||
|
- Simple code changes and debugging
|
||||||
|
- Architecture decisions
|
||||||
|
- Security reviews and approvals
|
||||||
|
- User-facing responses and recommendations
|
||||||
|
- Questions requiring reasoning about codebase
|
||||||
|
- Frontend UI work (use `frontend-design` skill for new components)
|
||||||
|
|
||||||
|
### Subagent Types & When to Use
|
||||||
|
|
||||||
|
**Explore Agent** (Haiku 3.5)
|
||||||
|
- Codebase exploration and file discovery
|
||||||
|
- Pattern searching across large codebases
|
||||||
|
- Gathering information about file structure
|
||||||
|
- Finding references and relationships
|
||||||
|
|
||||||
|
**General-Purpose Agent** (Haiku 3.5)
|
||||||
|
- Multi-step code analysis tasks
|
||||||
|
- Summarizing findings from exploration
|
||||||
|
- Complex searches requiring multiple strategies
|
||||||
|
- Collecting data for main conversation decisions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MODEL SELECTION STRATEGY
|
||||||
|
|
||||||
|
### Main Conversation (Sonnet 4.5)
|
||||||
|
- **Always use Sonnet 4.5 in main conversation**
|
||||||
|
- Direct file edits and modifications
|
||||||
|
- Architecture and design decisions
|
||||||
|
- Security analysis and approvals
|
||||||
|
- Complex reasoning and recommendations
|
||||||
|
- Final user responses
|
||||||
|
|
||||||
|
### Subagent Models
|
||||||
|
|
||||||
|
**Haiku 4.5** (Default for subagents)
|
||||||
|
- Code exploration and pattern searching
|
||||||
|
- File discovery and structure analysis
|
||||||
|
- Simple codebase investigations
|
||||||
|
- Gathering information and summarizing
|
||||||
|
- Task: Use Haiku first for subagent work
|
||||||
|
|
||||||
|
**Sonnet 4.5** (For subagents - when needed)
|
||||||
|
- Security-critical analysis within subagents
|
||||||
|
- Complex architectural decisions needed in exploration
|
||||||
|
- High-risk code analysis
|
||||||
|
- When exploration requires advanced reasoning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RATE LIMITING GUIDANCE
|
||||||
|
|
||||||
|
### API Call Throttling
|
||||||
|
- 5 seconds minimum between API calls
|
||||||
|
- 10 seconds minimum between web searches
|
||||||
|
- Batch similar work whenever possible
|
||||||
|
- If you hit 429 error: STOP and wait 5 minutes
|
||||||
|
|
||||||
|
### Budget Management
|
||||||
|
- Track tokens used across all agents
|
||||||
|
- Main conversation should stay under 100,000 tokens
|
||||||
|
- Subagent work can extend to 50,000 tokens per agent
|
||||||
|
- Batch multiple subagent tasks together when possible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TOKEN PRESERVATION RULES
|
||||||
|
|
||||||
|
### Best Practices for Long-Running Conversations
|
||||||
|
|
||||||
|
**In Main Conversation:**
|
||||||
|
1. Start with subagent for exploration (saves ~20,000 tokens)
|
||||||
|
2. Request subagent summarize findings
|
||||||
|
3. Use summary to inform main conversation edits/decisions
|
||||||
|
4. Keep main conversation focused on decisions and actions
|
||||||
|
|
||||||
|
**Information Gathering:**
|
||||||
|
- Use subagents to explore before asking for analysis in main conversation
|
||||||
|
- Have subagent provide condensed summaries (250-500 words max)
|
||||||
|
- Main conversation uses summary + provides feedback/decisions
|
||||||
|
|
||||||
|
**File Editing:**
|
||||||
|
- For <3 files: Keep in main conversation
|
||||||
|
- For 3+ files: Split between subagent (finding/analysis) and main (approval/execution)
|
||||||
|
- Simple edits (1-5 lines per file): Main conversation
|
||||||
|
- Complex refactoring (10+ lines per file): Subagent analysis + main approval
|
||||||
|
|
||||||
|
**Code Review Workflow:**
|
||||||
|
1. Subagent explores and analyzes code patterns
|
||||||
|
2. Subagent flags issues and suggests improvements
|
||||||
|
3. Main conversation reviews suggestions
|
||||||
|
4. Main conversation executes approved changes
|
||||||
|
|
||||||
|
### Token Budget Allocation Example
|
||||||
|
- Main conversation: 0-100,000 tokens (soft limit)
|
||||||
|
- Per subagent task: 0-50,000 tokens
|
||||||
|
- Critical work (security): Use Sonnet in main conversation
|
||||||
|
- Exploratory work: Use Explore agent (Haiku) in subagent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DECISION TREE
|
||||||
|
|
||||||
|
```
|
||||||
|
Is this a direct file edit request?
|
||||||
|
├─ YES (1-3 files, <10 lines each) → Main conversation
|
||||||
|
├─ NO
|
||||||
|
└─ Is this exploratory analysis?
|
||||||
|
├─ YES (finding files, patterns) → Use Explore agent (Haiku)
|
||||||
|
├─ NO
|
||||||
|
└─ Is this complex multi-step work?
|
||||||
|
├─ YES (3+ steps, many files) → Use General agent (Haiku)
|
||||||
|
├─ NO
|
||||||
|
└─ Is this security-critical?
|
||||||
|
├─ YES → Main conversation (Sonnet)
|
||||||
|
└─ NO → Subagent (Haiku) or Main conversation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SUMMARY
|
||||||
|
|
||||||
|
**Main Conversation (You):** Architecture, decisions, edits, reviews
|
||||||
|
**Subagents:** Exploration, analysis, information gathering
|
||||||
|
**Sonnet 4.5:** Security, complexity, final decisions
|
||||||
|
**Haiku 4.5:** Exploration, gathering, analysis support
|
||||||
84
.gitea/issue_template/enhancement.yaml
Normal file
84
.gitea/issue_template/enhancement.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Enhancement
|
||||||
|
about: Suggest an improvement to an existing feature or functionality
|
||||||
|
title: "[Enhancement] "
|
||||||
|
labels:
|
||||||
|
- kind/enhancement
|
||||||
|
- status/triage
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to suggest an improvement! This template is for enhancements to **existing** features. If you'd like to request a brand new feature, please use the Feature Request template instead.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: Current Behavior
|
||||||
|
description: Describe how the existing feature currently works.
|
||||||
|
placeholder: "Currently, when I do X, it works like..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed-improvement
|
||||||
|
attributes:
|
||||||
|
label: Proposed Improvement
|
||||||
|
description: Describe how you'd like the existing feature to be improved.
|
||||||
|
placeholder: "I'd like it to also do Y, or behave differently by..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: Use Case
|
||||||
|
description: Why would this improvement be valuable? What problem does it solve?
|
||||||
|
placeholder: "This would help because..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area of the Application
|
||||||
|
description: Which part of the application does this enhancement relate to?
|
||||||
|
options:
|
||||||
|
- Dashboard / CVE List
|
||||||
|
- CVE Details
|
||||||
|
- Document Management
|
||||||
|
- User Management
|
||||||
|
- Authentication
|
||||||
|
- Audit Logging
|
||||||
|
- API
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: Priority
|
||||||
|
description: How important is this enhancement to your workflow?
|
||||||
|
options:
|
||||||
|
- Nice to have
|
||||||
|
- Important
|
||||||
|
- Critical
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives or Workarounds
|
||||||
|
description: Are there any current workarounds or alternative approaches you've considered?
|
||||||
|
placeholder: "Currently I work around this by..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, screenshots, or mockups about the enhancement here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
35
.gitlab/issue_templates/Bug Report.md
Normal file
35
.gitlab/issue_templates/Bug Report.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!-- Labels: kind/bug, status/triage -->
|
||||||
|
|
||||||
|
Please provide as much detail as possible so we can reproduce and fix the issue!
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- What is currently happening? -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
<!--
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
-->
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
<!-- Which browser/OS are you using? (e.g., Chrome on Windows 11) -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Relevant Log Output
|
||||||
|
|
||||||
|
<!-- Please paste any error logs or console output here. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
(paste logs here)
|
||||||
|
```
|
||||||
27
.gitlab/issue_templates/Feature Request.md
Normal file
27
.gitlab/issue_templates/Feature Request.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- Labels: kind/feature, status/triage -->
|
||||||
|
|
||||||
|
Thanks for taking the time to suggest a new feature!
|
||||||
|
|
||||||
|
## Is your feature request related to a problem?
|
||||||
|
|
||||||
|
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Describe the solution you'd like
|
||||||
|
|
||||||
|
<!-- A clear and concise description of what you want to happen. -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Describe alternatives you've considered
|
||||||
|
|
||||||
|
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Add any other context or screenshots about the feature request here. -->
|
||||||
|
|
||||||
|
|
||||||
79
COLOR_SCHEME_MODERNIZATION.md
Normal file
79
COLOR_SCHEME_MODERNIZATION.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# CVE Dashboard - Color Scheme Modernization
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
|
||||||
|
|
||||||
|
## Color Palette Changes
|
||||||
|
|
||||||
|
### Before (Neon/Retro)
|
||||||
|
- **Accent**: `#00D9FF` - Bright cyan (too neon)
|
||||||
|
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
|
||||||
|
- **Danger**: `#FF3366` - Neon pink/red
|
||||||
|
- **Success**: `#00FF88` - Bright green (too bright)
|
||||||
|
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
|
||||||
|
|
||||||
|
### After (Modern Professional)
|
||||||
|
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
|
||||||
|
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
|
||||||
|
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
|
||||||
|
- **Success**: `#10B981` - Emerald (professional green)
|
||||||
|
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
### Refinement Approach
|
||||||
|
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
|
||||||
|
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
|
||||||
|
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
|
||||||
|
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
|
||||||
|
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
|
||||||
|
#### Severity Badges
|
||||||
|
- **Critical**: Neon pink → Modern red with refined glow
|
||||||
|
- **High**: Bright yellow → Amber with warm tones
|
||||||
|
- **Medium**: Bright cyan → Sky blue professional
|
||||||
|
- **Low**: Bright green → Emerald sophisticated
|
||||||
|
|
||||||
|
#### Interactive Elements
|
||||||
|
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
|
||||||
|
- **Input Fields**: More subtle focus states, refined borders
|
||||||
|
- **Cards**: Gentler hover effects, professional elevation
|
||||||
|
- **Stat Cards**: Refined top accent lines, subtle glows
|
||||||
|
|
||||||
|
#### Layout Components
|
||||||
|
- **Wiki Panel**: Updated to emerald accent with professional borders
|
||||||
|
- **Calendar**: Sky blue accent with refined styling
|
||||||
|
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
|
||||||
|
- **CVE Cards**: Slate-based gradients with professional depth
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
1. **App.css**: Updated all CSS variables, component styles, and utility classes
|
||||||
|
2. **App.js**: Updated inline STYLES object and all JSX color references
|
||||||
|
|
||||||
|
### CSS Variables Updated
|
||||||
|
```css
|
||||||
|
--intel-darkest: #0F172A
|
||||||
|
--intel-dark: #1E293B
|
||||||
|
--intel-medium: #334155
|
||||||
|
--intel-accent: #0EA5E9
|
||||||
|
--intel-warning: #F59E0B
|
||||||
|
--intel-danger: #EF4444
|
||||||
|
--intel-success: #10B981
|
||||||
|
--intel-grid: rgba(14, 165, 233, 0.08)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maintained Features
|
||||||
|
✓ Pulsing button effects on hover/click
|
||||||
|
✓ Scanning line animation
|
||||||
|
✓ Card hover elevations
|
||||||
|
✓ Badge glow dots
|
||||||
|
✓ Grid background effect
|
||||||
|
✓ Three-column layout
|
||||||
|
✓ All interactive functionality
|
||||||
|
|
||||||
|
## Result
|
||||||
|
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.
|
||||||
294
DESIGN_SYSTEM.md
Normal file
294
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# CVE Intelligence Dashboard - Design System Reference
|
||||||
|
|
||||||
|
## 🎨 Color Palette
|
||||||
|
|
||||||
|
### Primary Colors
|
||||||
|
```css
|
||||||
|
--intel-darkest: #0F172A /* Slate 900 - Deepest background */
|
||||||
|
--intel-dark: #1E293B /* Slate 800 - Card backgrounds */
|
||||||
|
--intel-medium: #334155 /* Slate 700 - Elevated elements */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accent & Status Colors
|
||||||
|
```css
|
||||||
|
--intel-accent: #0EA5E9 /* Sky Blue - Primary accent, links, interactive elements */
|
||||||
|
--intel-warning: #F59E0B /* Amber - Warnings, high severity, open tickets */
|
||||||
|
--intel-danger: #EF4444 /* Red - Critical severity, destructive actions */
|
||||||
|
--intel-success: #10B981 /* Emerald - Success states, low severity, confirmations */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Colors
|
||||||
|
```css
|
||||||
|
--text-primary: #F8FAFC /* Slate 50 - Primary text */
|
||||||
|
--text-secondary: #E2E8F0 /* Slate 200 - Secondary text */
|
||||||
|
--text-tertiary: #CBD5E1 /* Slate 300 - Labels, metadata */
|
||||||
|
--text-muted: #94A3B8 /* Slate 400 - Placeholders, disabled */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Severity Badge Colors
|
||||||
|
| Severity | Border | Background | Text | Glow Dot |
|
||||||
|
|----------|--------|------------|------|----------|
|
||||||
|
| **Critical** | `#EF4444` | `rgba(239, 68, 68, 0.25)` | `#FCA5A5` | `#EF4444` |
|
||||||
|
| **High** | `#F59E0B` | `rgba(245, 158, 11, 0.25)` | `#FCD34D` | `#F59E0B` |
|
||||||
|
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||||
|
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||||
|
|
||||||
|
## 📐 Layout Structure
|
||||||
|
|
||||||
|
### Three-Column Grid Layout
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ HEADER & STATS BAR │
|
||||||
|
│ CVE INTEL | [Stats: Total, Entries, Tickets, Critical] │
|
||||||
|
├──────────────┬─────────────────────────┬────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
|
||||||
|
│ (3 cols) │ (6 cols) │ (3 cols) │
|
||||||
|
│ │ │ │
|
||||||
|
│ Knowledge │ Quick CVE Lookup │ Calendar │
|
||||||
|
│ Base │ Search & Filters │ Widget │
|
||||||
|
│ - Wiki │ CVE Results List │ │
|
||||||
|
│ - Docs │ - Expandable cards │ Open Tickets │
|
||||||
|
│ - Policies │ - Vendor entries │ - Compact list │
|
||||||
|
│ - Guides │ - Documents │ - Quick stats │
|
||||||
|
│ │ - JIRA tickets │ │
|
||||||
|
│ │ │ │
|
||||||
|
└──────────────┴─────────────────────────┴────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||||
|
- **Tablet/Mobile**: Stacked single column
|
||||||
|
|
||||||
|
## 🎯 Component Specifications
|
||||||
|
|
||||||
|
### Stat Cards
|
||||||
|
```css
|
||||||
|
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||||
|
Border: 2px solid [accent-color]
|
||||||
|
Border Radius: 0.5rem
|
||||||
|
Padding: 1rem
|
||||||
|
Top Accent Line: 2px gradient, 0 0 8px glow
|
||||||
|
Shadow: 0 4px 16px rgba(0, 0, 0, 0.5)
|
||||||
|
Hover: translateY(-2px), enhanced shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intel Cards (Main Content)
|
||||||
|
```css
|
||||||
|
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||||
|
Border: 2px solid rgba(14, 165, 233, 0.4)
|
||||||
|
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6), subtle glow
|
||||||
|
Hover: Enhanced border (0.5 opacity), lift effect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
```css
|
||||||
|
/* Primary */
|
||||||
|
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))
|
||||||
|
Border: 1px solid #0EA5E9
|
||||||
|
Color: #38BDF8
|
||||||
|
Text Shadow: 0 0 6px rgba(14, 165, 233, 0.2)
|
||||||
|
|
||||||
|
/* Hover State */
|
||||||
|
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(14, 165, 233, 0.2))
|
||||||
|
Shadow: 0 0 20px rgba(14, 165, 233, 0.25)
|
||||||
|
Transform: translateY(-1px)
|
||||||
|
Ripple Effect: 300px radial on click
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Fields
|
||||||
|
```css
|
||||||
|
Background: rgba(30, 41, 59, 0.6)
|
||||||
|
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||||
|
Font: 'JetBrains Mono', monospace
|
||||||
|
Focus: border #0EA5E9, ring 2px rgba(14, 165, 233, 0.15)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges (Status/Severity)
|
||||||
|
```css
|
||||||
|
Display: inline-flex
|
||||||
|
Align Items: center
|
||||||
|
Gap: 0.5rem
|
||||||
|
Border: 2px solid [severity-color]
|
||||||
|
Border Radius: 0.375rem
|
||||||
|
Padding: 0.375rem 0.875rem
|
||||||
|
Font: 'JetBrains Mono', 0.75rem, 700, uppercase
|
||||||
|
Letter Spacing: 0.5px
|
||||||
|
Glow Dot: 8px circle with pulse animation
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ Interactions & Animations
|
||||||
|
|
||||||
|
### Hover Effects
|
||||||
|
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||||
|
- **Buttons**: Radial ripple expand (300px), slight lift
|
||||||
|
- **List Items**: Border color shift, background lighten
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
```css
|
||||||
|
/* Pulse Glow (for dots) */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.7; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scan Line */
|
||||||
|
@keyframes scan {
|
||||||
|
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||||
|
50% { transform: translateY(2000%); opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin (loading) */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transitions
|
||||||
|
```css
|
||||||
|
Standard: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||||
|
Fast: all 0.2s ease
|
||||||
|
Ripple: width/height 0.5s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔤 Typography
|
||||||
|
|
||||||
|
### Font Families
|
||||||
|
```css
|
||||||
|
Primary (UI): 'Outfit', system-ui, sans-serif
|
||||||
|
Monospace (Data/Code): 'JetBrains Mono', monospace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font Sizes & Weights
|
||||||
|
```css
|
||||||
|
/* Headings */
|
||||||
|
h1: 2.5rem (40px), 700, monospace
|
||||||
|
h2: 1.125rem (18px), 600, uppercase, tracking-wider
|
||||||
|
h3: 1.125rem (18px), 600
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
Body: 0.875rem (14px), 400
|
||||||
|
Small: 0.75rem (12px), 400
|
||||||
|
Labels: 0.75rem (12px), 500, uppercase, tracking-wider
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Shadows (Headings)
|
||||||
|
```css
|
||||||
|
Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0.15)
|
||||||
|
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Visual Effects
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
```css
|
||||||
|
/* Card Elevations */
|
||||||
|
Level 1: 0 2px 6px rgba(0, 0, 0, 0.3)
|
||||||
|
Level 2: 0 4px 12px rgba(0, 0, 0, 0.4)
|
||||||
|
Level 3: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||||
|
|
||||||
|
/* Glows */
|
||||||
|
Subtle: 0 0 12px rgba([color], 0.12)
|
||||||
|
Medium: 0 0 20px rgba([color], 0.15)
|
||||||
|
Strong: 0 0 28px rgba([color], 0.25)
|
||||||
|
|
||||||
|
/* Inset Highlights */
|
||||||
|
Top: inset 0 1px 0 rgba(14, 165, 233, 0.15)
|
||||||
|
Recessed: inset 0 2px 4px rgba(0, 0, 0, 0.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Styles
|
||||||
|
```css
|
||||||
|
/* Standard Cards */
|
||||||
|
Border: 1.5-2px solid rgba(14, 165, 233, 0.3-0.4)
|
||||||
|
|
||||||
|
/* Accent Panels */
|
||||||
|
Left Border: 3px solid [accent-color]
|
||||||
|
|
||||||
|
/* Vendor/Nested Cards */
|
||||||
|
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradients
|
||||||
|
```css
|
||||||
|
/* Backgrounds */
|
||||||
|
Card: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||||
|
Nested: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9))
|
||||||
|
|
||||||
|
/* Accent Lines */
|
||||||
|
Top Bar: linear-gradient(90deg, transparent, [color], transparent)
|
||||||
|
|
||||||
|
/* Grid Background */
|
||||||
|
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
||||||
|
Size: 20px × 20px
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 Specific Component Patterns
|
||||||
|
|
||||||
|
### Wiki/Knowledge Base Entry
|
||||||
|
```css
|
||||||
|
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||||
|
Border: 1px solid rgba(16, 185, 129, 0.25)
|
||||||
|
Padding: 0.75rem
|
||||||
|
Cursor: pointer
|
||||||
|
Hover: border-color shift to rgba(16, 185, 129, 0.4)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calendar Widget
|
||||||
|
```css
|
||||||
|
Day Cells:
|
||||||
|
- Text: white, font-mono, 0.75rem
|
||||||
|
- Hover: bg rgba(14, 165, 233, 0.2)
|
||||||
|
- Current Day: bg rgba(14, 165, 233, 0.3), border 1px #0EA5E9
|
||||||
|
- Other Month: text rgba(148, 163, 184, 0.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ticket Cards (Compact)
|
||||||
|
```css
|
||||||
|
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||||
|
Border: 1px solid rgba(245, 158, 11, 0.25)
|
||||||
|
Padding: 0.5rem
|
||||||
|
Status Badge: Reduced size (0.65rem, 0.25rem padding)
|
||||||
|
Glow Dot: 6px diameter
|
||||||
|
```
|
||||||
|
|
||||||
|
### CVE Expandable Cards
|
||||||
|
```css
|
||||||
|
Header: Clickable, cursor pointer
|
||||||
|
Collapsed: Show summary (severity, vendor count, doc count)
|
||||||
|
Expanded: Full description, metadata, vendor entries
|
||||||
|
Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
||||||
|
Vendor Cards: Nested with reduced opacity borders
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Accessibility
|
||||||
|
|
||||||
|
### Contrast Ratios
|
||||||
|
- Primary text on dark: 18.5:1 (AAA)
|
||||||
|
- Secondary text on dark: 12.3:1 (AAA)
|
||||||
|
- Accent colors: All meet WCAG AA minimum
|
||||||
|
|
||||||
|
### Interactive States
|
||||||
|
- Focus rings: 2px solid accent color
|
||||||
|
- Hover: Visible border/background changes
|
||||||
|
- Active: Transform feedback
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- Minimum size: 12px (0.75rem)
|
||||||
|
- Line height: 1.5 for body text
|
||||||
|
- Letter spacing: Generous for uppercase labels
|
||||||
|
|
||||||
|
## 🎯 Design Principles
|
||||||
|
|
||||||
|
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||||
|
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||||
|
3. **Refined Depth**: Layers and elevation without harsh neon
|
||||||
|
4. **Purposeful Color**: Accent colors convey meaning (status, severity)
|
||||||
|
5. **Smooth Interactions**: Polished micro-interactions and transitions
|
||||||
|
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||||
|
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: February 10, 2026
|
||||||
|
**Version**: 2.0 (Modern Professional Redesign)
|
||||||
7
Ivanti_config_template.ini
Normal file
7
Ivanti_config_template.ini
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[platform]
|
||||||
|
url = https://platform4.risksense.com
|
||||||
|
api_ver = /api/v1
|
||||||
|
# PROD 1550 | UAT 1551
|
||||||
|
client_id = <pick 1550 or 1551>
|
||||||
|
[secrets]
|
||||||
|
api_key = <your API key here>
|
||||||
522
README.md
522
README.md
@@ -3,7 +3,7 @@
|
|||||||
A comprehensive vulnerability management system designed for tracking CVE (Common Vulnerabilities and Exposures) remediation status and maintaining vendor documentation compliance.
|
A comprehensive vulnerability management system designed for tracking CVE (Common Vulnerabilities and Exposures) remediation status and maintaining vendor documentation compliance.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,16 +51,32 @@ This dashboard provides:
|
|||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
|
### 🔐 User Authentication & Roles
|
||||||
|
- **Secure login**: Session-based authentication with encrypted passwords
|
||||||
|
- **Role-based access control**: Three user roles with different permissions
|
||||||
|
- **Admin**: Full access including user management and document deletion
|
||||||
|
- **Editor**: Can add/edit CVEs and upload documents
|
||||||
|
- **Viewer**: Read-only access to CVEs and documents
|
||||||
|
- **User management**: Admins can create, edit, and deactivate users
|
||||||
|
- **Session persistence**: Stay logged in across browser sessions (24-hour expiry)
|
||||||
|
|
||||||
### 🔍 Quick CVE Status Check
|
### 🔍 Quick CVE Status Check
|
||||||
- **Instant verification**: Enter any CVE ID and immediately see if it's been addressed
|
- **Instant verification**: Enter any CVE ID and immediately see if it's been addressed
|
||||||
- **Document compliance**: Shows which documents are present (Advisory ✓, Email ○, Screenshot ○)
|
- **Multi-vendor display**: Shows all vendors associated with a CVE
|
||||||
|
- **Document compliance**: Shows which documents are present per vendor (Advisory ✓, Email ○, Screenshot ○)
|
||||||
- **Visual indicators**: Color-coded results (green = addressed, yellow = not found, red = missing required docs)
|
- **Visual indicators**: Color-coded results (green = addressed, yellow = not found, red = missing required docs)
|
||||||
|
|
||||||
### 📂 Document Management
|
### 📂 Document Management
|
||||||
- **Upload documents**: PDF, images, Word docs, text files (up to 10MB)
|
- **Upload documents**: PDF, images, Word docs, text files (up to 10MB)
|
||||||
- **Automatic organization**: Files stored as `uploads/CVE-2024-1234/Microsoft/advisory.pdf`
|
- **Automatic organization**: Files stored as `uploads/CVE-2024-1234/Microsoft/advisory.pdf`
|
||||||
|
- **Per-vendor storage**: Each vendor's documents are organized separately
|
||||||
- **Document types**: Advisory, Email, Screenshot, Patch, Other
|
- **Document types**: Advisory, Email, Screenshot, Patch, Other
|
||||||
- **View & Delete**: Direct links to view documents, delete with confirmation
|
- **View & Delete**: Direct links to view documents, admin-only deletion
|
||||||
|
|
||||||
|
### 🏢 Multi-Vendor Support
|
||||||
|
- **Same CVE, multiple vendors**: Track a single CVE across different vendors (e.g., CVE-2024-1234 for both Microsoft and Cisco)
|
||||||
|
- **Vendor-specific tracking**: Each vendor entry has its own status, documents, and compliance
|
||||||
|
- **Flexible organization**: Documents organized by CVE ID and vendor
|
||||||
|
|
||||||
### 🔎 Search & Filter
|
### 🔎 Search & Filter
|
||||||
- **Search by CVE ID or description**: Find vulnerabilities quickly
|
- **Search by CVE ID or description**: Find vulnerabilities quickly
|
||||||
@@ -72,6 +88,7 @@ This dashboard provides:
|
|||||||
- **Document status badges**: "✓ Docs Complete" or "⚠ Incomplete"
|
- **Document status badges**: "✓ Docs Complete" or "⚠ Incomplete"
|
||||||
- **Required documents**: Advisory (mandatory), Email (optional), Screenshot (optional)
|
- **Required documents**: Advisory (mandatory), Email (optional), Screenshot (optional)
|
||||||
- **Vendor-specific requirements**: Customizable per vendor
|
- **Vendor-specific requirements**: Customizable per vendor
|
||||||
|
- **Per-vendor compliance**: Track documentation status for each vendor separately
|
||||||
|
|
||||||
### 🎨 Charter/Spectrum Branding
|
### 🎨 Charter/Spectrum Branding
|
||||||
- **Corporate colors**: Charter Blue (#0476D9) throughout
|
- **Corporate colors**: Charter Blue (#0476D9) throughout
|
||||||
@@ -90,12 +107,15 @@ This dashboard provides:
|
|||||||
│ │ Frontend │ │ Backend API │ │
|
│ │ Frontend │ │ Backend API │ │
|
||||||
│ │ │ HTTP │ │ │
|
│ │ │ HTTP │ │ │
|
||||||
│ │ React + │◄───────►│ Express.js │ │
|
│ │ React + │◄───────►│ Express.js │ │
|
||||||
│ │ Tailwind │ :3001 │ │ │
|
│ │ Tailwind │ :3001 │ + Auth Middleware │ │
|
||||||
│ │ │ │ ┌─────────────────┐ │ │
|
│ │ │ │ │ │
|
||||||
│ │ Port: 3000 │ │ │ SQLite DB │ │ │
|
│ │ Port: 3000 │ │ ┌─────────────────┐ │ │
|
||||||
│ └──────────────┘ │ │ - cves │ │ │
|
│ └──────────────┘ │ │ SQLite DB │ │ │
|
||||||
|
│ │ │ - cves │ │ │
|
||||||
│ │ │ - documents │ │ │
|
│ │ │ - documents │ │ │
|
||||||
│ │ │ - required_docs│ │ │
|
│ │ │ - required_docs│ │ │
|
||||||
|
│ │ │ - users │ │ │
|
||||||
|
│ │ │ - sessions │ │ │
|
||||||
│ │ └─────────────────┘ │ │
|
│ │ └─────────────────┘ │ │
|
||||||
│ └──────────────────────┘ │
|
│ └──────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -105,9 +125,10 @@ This dashboard provides:
|
|||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ uploads/ │ │
|
│ │ uploads/ │ │
|
||||||
│ │ └─ CVE-2024-1234/ │ │
|
│ │ └─ CVE-2024-1234/ │ │
|
||||||
│ │ └─ Microsoft/ │ │
|
│ │ ├─ Microsoft/ │ │
|
||||||
│ │ ├─ advisory.pdf│ │
|
│ │ │ └─ advisory.pdf│ │
|
||||||
│ │ └─ email.pdf │ │
|
│ │ └─ Cisco/ │ │
|
||||||
|
│ │ └─ advisory.pdf│ │
|
||||||
│ └──────────────────────┘ │
|
│ └──────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -119,6 +140,7 @@ This dashboard provides:
|
|||||||
- Tailwind CSS (via CDN)
|
- Tailwind CSS (via CDN)
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- Fetch API
|
- Fetch API
|
||||||
|
- Context API (AuthContext)
|
||||||
|
|
||||||
**Backend:**
|
**Backend:**
|
||||||
- Node.js v18+
|
- Node.js v18+
|
||||||
@@ -126,6 +148,9 @@ This dashboard provides:
|
|||||||
- SQLite3
|
- SQLite3
|
||||||
- Multer (file uploads)
|
- Multer (file uploads)
|
||||||
- CORS
|
- CORS
|
||||||
|
- bcryptjs (password hashing)
|
||||||
|
- cookie-parser (session management)
|
||||||
|
- dotenv (environment configuration)
|
||||||
|
|
||||||
**Database:**
|
**Database:**
|
||||||
- SQLite (development/production)
|
- SQLite (development/production)
|
||||||
@@ -168,6 +193,9 @@ Expected packages:
|
|||||||
- sqlite3
|
- sqlite3
|
||||||
- multer
|
- multer
|
||||||
- cors
|
- cors
|
||||||
|
- bcryptjs
|
||||||
|
- cookie-parser
|
||||||
|
- dotenv
|
||||||
|
|
||||||
### 3. Install Frontend Dependencies
|
### 3. Install Frontend Dependencies
|
||||||
```bash
|
```bash
|
||||||
@@ -189,35 +217,64 @@ node setup.js
|
|||||||
|
|
||||||
This will:
|
This will:
|
||||||
- ✅ Create `cve_database.db`
|
- ✅ Create `cve_database.db`
|
||||||
- ✅ Create tables: `cves`, `documents`, `required_documents`
|
- ✅ Create tables: `cves`, `documents`, `required_documents`, `users`, `sessions`
|
||||||
|
- ✅ Set up multi-vendor support with UNIQUE(cve_id, vendor) constraint
|
||||||
- ✅ Create indexes for fast queries
|
- ✅ Create indexes for fast queries
|
||||||
- ✅ Create `cve_document_status` view
|
- ✅ Create `cve_document_status` view
|
||||||
- ✅ Create `uploads/` and `uploads/temp/` directories
|
- ✅ Create `uploads/` directory
|
||||||
- ✅ Insert default required documents for major vendors
|
- ✅ Insert default required documents for major vendors
|
||||||
|
- ✅ Create default admin user (admin/admin123)
|
||||||
|
|
||||||
Expected output:
|
Expected output:
|
||||||
```
|
```
|
||||||
🚀 CVE Database Setup
|
🚀 CVE Database Setup (Multi-Vendor Support)
|
||||||
|
|
||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
✓ Created uploads directory
|
|
||||||
|
✓ Uploads directory already exists
|
||||||
✓ Database initialized successfully
|
✓ Database initialized successfully
|
||||||
✓ Database connection closed
|
✓ Created default admin user (admin/admin123)
|
||||||
|
|
||||||
|
📝 Adding sample CVE data for testing...
|
||||||
|
✓ Added sample: CVE-2024-SAMPLE-1 / Microsoft
|
||||||
|
✓ Added sample: CVE-2024-SAMPLE-1 / Cisco
|
||||||
|
ℹ️ Sample data added - demonstrates multi-vendor support
|
||||||
|
|
||||||
╔════════════════════════════════════════════════════════╗
|
╔════════════════════════════════════════════════════════╗
|
||||||
║ CVE DATABASE SETUP COMPLETE! ║
|
║ CVE DATABASE SETUP COMPLETE! ║
|
||||||
╚════════════════════════════════════════════════════════╝
|
╚════════════════════════════════════════════════════════╝
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Configure Server IP
|
### 5. Configure Environment Variables
|
||||||
|
|
||||||
Edit `frontend/src/App.js` and update the API URL (line 5):
|
Run the environment setup script to configure server IP addresses:
|
||||||
```javascript
|
```bash
|
||||||
const API_BASE = 'http://YOUR_SERVER_IP:3001/api';
|
cd backend
|
||||||
|
chmod +x setup-env.sh
|
||||||
|
./setup-env.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
The script will:
|
||||||
```javascript
|
- Auto-detect your server's IP address
|
||||||
const API_BASE = 'http://192.168.2.117:3001/api';
|
- Create `backend/.env` with CORS and API settings
|
||||||
|
- Create `frontend/.env` with API base URL
|
||||||
|
|
||||||
|
**Manual Configuration (Alternative):**
|
||||||
|
|
||||||
|
Create `backend/.env`:
|
||||||
|
```bash
|
||||||
|
# Backend Configuration
|
||||||
|
PORT=3001
|
||||||
|
API_HOST=YOUR_SERVER_IP
|
||||||
|
CORS_ORIGINS=http://YOUR_SERVER_IP:3000
|
||||||
|
SESSION_SECRET=your-secure-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `frontend/.env`:
|
||||||
|
```bash
|
||||||
|
# Frontend Configuration
|
||||||
|
REACT_APP_API_BASE=http://YOUR_SERVER_IP:3001/api
|
||||||
|
REACT_APP_API_HOST=http://YOUR_SERVER_IP:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Add Tailwind CSS to Frontend
|
### 6. Add Tailwind CSS to Frontend
|
||||||
@@ -292,12 +349,12 @@ chmod +x stop-servers.sh
|
|||||||
|
|
||||||
### Backend Configuration
|
### Backend Configuration
|
||||||
|
|
||||||
**CORS Settings** (`backend/server.js`):
|
**Environment Variables** (`backend/.env`):
|
||||||
```javascript
|
```bash
|
||||||
app.use(cors({
|
PORT=3001 # API server port
|
||||||
origin: ['http://localhost:3000', 'http://192.168.2.117:3000'],
|
API_HOST=192.168.2.117 # Server IP address
|
||||||
credentials: true
|
CORS_ORIGINS=http://192.168.2.117:3000 # Allowed frontend origins (comma-separated)
|
||||||
}));
|
SESSION_SECRET=your-secure-secret # Session encryption key
|
||||||
```
|
```
|
||||||
|
|
||||||
**File Upload Limits** (`backend/server.js`):
|
**File Upload Limits** (`backend/server.js`):
|
||||||
@@ -308,16 +365,12 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Port Configuration** (`backend/server.js`):
|
|
||||||
```javascript
|
|
||||||
const PORT = 3001;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Configuration
|
### Frontend Configuration
|
||||||
|
|
||||||
**API Base URL** (`frontend/src/App.js`):
|
**Environment Variables** (`frontend/.env`):
|
||||||
```javascript
|
```bash
|
||||||
const API_BASE = 'http://192.168.2.117:3001/api';
|
REACT_APP_API_BASE=http://192.168.2.117:3001/api # API endpoint with /api path
|
||||||
|
REACT_APP_API_HOST=http://192.168.2.117:3001 # Base URL for file downloads
|
||||||
```
|
```
|
||||||
|
|
||||||
**Severity Levels** (`frontend/src/App.js`):
|
**Severity Levels** (`frontend/src/App.js`):
|
||||||
@@ -372,8 +425,36 @@ cd /home/cve-dashboard
|
|||||||
- Frontend: `http://YOUR_SERVER_IP:3000`
|
- Frontend: `http://YOUR_SERVER_IP:3000`
|
||||||
- Backend API: `http://YOUR_SERVER_IP:3001`
|
- Backend API: `http://YOUR_SERVER_IP:3001`
|
||||||
|
|
||||||
|
### Logging In
|
||||||
|
|
||||||
|
1. Navigate to `http://YOUR_SERVER_IP:3000`
|
||||||
|
2. You'll see the login page
|
||||||
|
3. Enter credentials:
|
||||||
|
- **Default admin**: username `admin`, password `admin123`
|
||||||
|
4. Click **"Sign In"**
|
||||||
|
5. You'll be redirected to the dashboard
|
||||||
|
|
||||||
|
**First-Time Setup:**
|
||||||
|
- After initial setup, change the default admin password
|
||||||
|
- Create additional users based on their roles:
|
||||||
|
- **Viewers**: Read-only access (security auditors, stakeholders)
|
||||||
|
- **Editors**: Can add/edit CVEs and upload documents (analysts)
|
||||||
|
- **Admins**: Full access including user management (team leads)
|
||||||
|
|
||||||
|
### User Management (Admin Only)
|
||||||
|
|
||||||
|
1. Click on your username in the top right
|
||||||
|
2. Select **"User Management"**
|
||||||
|
3. From here you can:
|
||||||
|
- View all users and their roles
|
||||||
|
- Create new users
|
||||||
|
- Edit user roles and status
|
||||||
|
- Deactivate users (soft delete)
|
||||||
|
|
||||||
### Adding a New CVE
|
### Adding a New CVE
|
||||||
|
|
||||||
|
**Required Role:** Editor or Admin
|
||||||
|
|
||||||
1. Click the **"+ Add New CVE"** button (top right)
|
1. Click the **"+ Add New CVE"** button (top right)
|
||||||
2. Fill in the form:
|
2. Fill in the form:
|
||||||
- **CVE ID**: e.g., `CVE-2024-1234`
|
- **CVE ID**: e.g., `CVE-2024-1234`
|
||||||
@@ -384,25 +465,31 @@ cd /home/cve-dashboard
|
|||||||
3. Click **"Add CVE"**
|
3. Click **"Add CVE"**
|
||||||
4. CVE appears in the dashboard immediately
|
4. CVE appears in the dashboard immediately
|
||||||
|
|
||||||
|
**Multi-Vendor Note:** You can add the same CVE ID multiple times with different vendors. For example, CVE-2024-1234 can exist for both Microsoft and Cisco with separate tracking.
|
||||||
|
|
||||||
### Uploading Documents
|
### Uploading Documents
|
||||||
|
|
||||||
|
**Required Role:** Editor or Admin
|
||||||
|
|
||||||
1. Find the CVE in the list
|
1. Find the CVE in the list
|
||||||
2. Click **"View Documents"** to expand
|
2. Click **"View Documents"** to expand
|
||||||
3. Click **"Upload New Document"**
|
3. Click **"Upload New Document"**
|
||||||
4. Select your file (PDF, PNG, JPG, TXT, DOC, DOCX)
|
4. Select your file (PDF, PNG, JPG, TXT, DOC, DOCX)
|
||||||
5. When prompted, specify:
|
5. When prompted, specify:
|
||||||
|
- **Vendor**: Select the vendor this document applies to
|
||||||
- **Document type**: advisory, email, screenshot, patch, other
|
- **Document type**: advisory, email, screenshot, patch, other
|
||||||
- **Notes** (optional): Description or context
|
- **Notes** (optional): Description or context
|
||||||
6. File uploads and organizes automatically
|
6. File uploads and organizes automatically
|
||||||
|
|
||||||
**File Organization Example:**
|
**File Organization Example (Multi-Vendor):**
|
||||||
```
|
```
|
||||||
uploads/
|
uploads/
|
||||||
└── CVE-2024-1234/
|
└── CVE-2024-1234/
|
||||||
└── Microsoft/
|
├── Microsoft/
|
||||||
├── 1706140800000-MS-Security-Advisory.pdf
|
│ ├── 1706140800000-MS-Security-Advisory.pdf
|
||||||
├── 1706140850000-Vendor-Email.pdf
|
│ └── 1706140850000-Vendor-Email.pdf
|
||||||
└── 1706140900000-Patch-Screenshot.png
|
└── Cisco/
|
||||||
|
└── 1706140900000-Cisco-Advisory.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Quick Check
|
### Using Quick Check
|
||||||
@@ -412,16 +499,21 @@ uploads/
|
|||||||
1. Enter `CVE-2024-5678` in the **Quick Check** box
|
1. Enter `CVE-2024-5678` in the **Quick Check** box
|
||||||
2. Click **"Check Status"**
|
2. Click **"Check Status"**
|
||||||
|
|
||||||
**Result A - Already Addressed:**
|
**Result A - Already Addressed (Multi-Vendor):**
|
||||||
```
|
```
|
||||||
✓ CVE Addressed
|
✓ CVE Addressed
|
||||||
Vendor: Cisco
|
|
||||||
Severity: High
|
|
||||||
Status: Addressed
|
|
||||||
Documents: 2 attached
|
|
||||||
✓ Advisory ✓ Email ○ Screenshot
|
|
||||||
|
|
||||||
Ready for false positive request
|
Vendor: Microsoft
|
||||||
|
Severity: Critical | Status: Addressed
|
||||||
|
Documents: 3 attached
|
||||||
|
✓ Advisory ✓ Email ✓ Screenshot
|
||||||
|
|
||||||
|
Vendor: Cisco
|
||||||
|
Severity: High | Status: Open
|
||||||
|
Documents: 1 attached
|
||||||
|
✓ Advisory ○ Email ○ Screenshot
|
||||||
|
|
||||||
|
Ready for false positive request (Microsoft)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result B - Not Found:**
|
**Result B - Not Found:**
|
||||||
@@ -436,6 +528,7 @@ Action Required: Create entry and gather vendor documentation
|
|||||||
**Result C - Incomplete:**
|
**Result C - Incomplete:**
|
||||||
```
|
```
|
||||||
✓ CVE Addressed
|
✓ CVE Addressed
|
||||||
|
Vendor: Oracle
|
||||||
Documents: 1 attached
|
Documents: 1 attached
|
||||||
✗ Advisory ○ Email ○ Screenshot
|
✗ Advisory ○ Email ○ Screenshot
|
||||||
|
|
||||||
@@ -468,10 +561,10 @@ Missing required advisory - obtain before requesting false positive
|
|||||||
3. Click **"View"** to open document in new tab
|
3. Click **"View"** to open document in new tab
|
||||||
4. Select checkboxes to export multiple documents
|
4. Select checkboxes to export multiple documents
|
||||||
|
|
||||||
### Deleting Documents
|
### Deleting Documents (Admin Only)
|
||||||
|
|
||||||
1. Expand documents for a CVE
|
1. Expand documents for a CVE
|
||||||
2. Click red **"Delete"** button next to document
|
2. Click red **"Delete"** button next to document (only visible to admins)
|
||||||
3. Confirm deletion in popup
|
3. Confirm deletion in popup
|
||||||
4. Document removed from database and filesystem
|
4. Document removed from database and filesystem
|
||||||
|
|
||||||
@@ -488,12 +581,107 @@ Missing required advisory - obtain before requesting false positive
|
|||||||
|
|
||||||
Base URL: `http://YOUR_SERVER_IP:3001/api`
|
Base URL: `http://YOUR_SERVER_IP:3001/api`
|
||||||
|
|
||||||
|
**Authentication Required:** All endpoints except `/api/auth/login` require authentication via session cookie.
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
```http
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Login successful",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"email": "admin@localhost",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets a session cookie (`session_id`) for subsequent requests.
|
||||||
|
|
||||||
|
#### Logout
|
||||||
|
```http
|
||||||
|
POST /api/auth/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Logged out successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Current User
|
||||||
|
```http
|
||||||
|
GET /api/auth/me
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"email": "admin@localhost",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Management Endpoints (Admin Only)
|
||||||
|
|
||||||
|
#### Get All Users
|
||||||
|
```http
|
||||||
|
GET /api/users
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create User
|
||||||
|
```http
|
||||||
|
POST /api/users
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"role": "editor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update User
|
||||||
|
```http
|
||||||
|
PUT /api/users/:id
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete User
|
||||||
|
```http
|
||||||
|
DELETE /api/users/:id
|
||||||
|
```
|
||||||
|
|
||||||
### CVE Endpoints
|
### CVE Endpoints
|
||||||
|
|
||||||
#### Get All CVEs
|
#### Get All CVEs
|
||||||
```http
|
```http
|
||||||
GET /api/cves
|
GET /api/cves
|
||||||
```
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
- `search` (optional): Search term for CVE ID or description
|
- `search` (optional): Search term for CVE ID or description
|
||||||
@@ -502,7 +690,7 @@ GET /api/cves
|
|||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
|
curl -b cookies.txt "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -528,33 +716,43 @@ curl "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
|
|||||||
```http
|
```http
|
||||||
GET /api/cves/check/:cveId
|
GET /api/cves/check/:cveId
|
||||||
```
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
|
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (Found):**
|
**Response (Found - Multi-Vendor):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"exists": true,
|
"exists": true,
|
||||||
"cve": {
|
"vendors": [
|
||||||
"cve_id": "CVE-2024-1234",
|
{
|
||||||
"vendor": "Microsoft",
|
"vendor": "Microsoft",
|
||||||
"severity": "Critical",
|
"severity": "Critical",
|
||||||
"status": "Addressed",
|
"status": "Addressed",
|
||||||
"total_documents": 3,
|
"total_documents": 3,
|
||||||
"has_advisory": 1,
|
|
||||||
"has_email": 1,
|
|
||||||
"has_screenshot": 1
|
|
||||||
},
|
|
||||||
"addressed": true,
|
|
||||||
"has_required_docs": true,
|
|
||||||
"compliance": {
|
"compliance": {
|
||||||
"advisory": true,
|
"advisory": true,
|
||||||
"email": true,
|
"email": true,
|
||||||
"screenshot": true
|
"screenshot": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Cisco",
|
||||||
|
"severity": "High",
|
||||||
|
"status": "Open",
|
||||||
|
"total_documents": 1,
|
||||||
|
"compliance": {
|
||||||
|
"advisory": true,
|
||||||
|
"email": false,
|
||||||
|
"screenshot": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"addressed": true,
|
||||||
|
"has_required_docs": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -566,11 +764,43 @@ curl "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Get Vendors for CVE
|
||||||
|
```http
|
||||||
|
GET /api/cves/:cveId/vendors
|
||||||
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/CVE-2024-1234/vendors"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"vendor": "Microsoft",
|
||||||
|
"severity": "Critical",
|
||||||
|
"status": "Addressed",
|
||||||
|
"description": "Remote code execution vulnerability",
|
||||||
|
"published_date": "2024-01-15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vendor": "Cisco",
|
||||||
|
"severity": "High",
|
||||||
|
"status": "Open",
|
||||||
|
"description": "Remote code execution vulnerability",
|
||||||
|
"published_date": "2024-01-15"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
#### Create CVE
|
#### Create CVE
|
||||||
```http
|
```http
|
||||||
POST /api/cves
|
POST /api/cves
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
**Required Role:** Editor or Admin
|
||||||
|
|
||||||
**Body:**
|
**Body:**
|
||||||
```json
|
```json
|
||||||
@@ -583,9 +813,11 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** The same CVE ID can be added multiple times with different vendors. The combination of (cve_id, vendor) must be unique.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://192.168.2.117:3001/api/cves \
|
curl -b cookies.txt -X POST http://192.168.2.117:3001/api/cves \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"cve_id": "CVE-2024-1234",
|
"cve_id": "CVE-2024-1234",
|
||||||
@@ -601,7 +833,14 @@ curl -X POST http://192.168.2.117:3001/api/cves \
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"cve_id": "CVE-2024-1234",
|
"cve_id": "CVE-2024-1234",
|
||||||
"message": "CVE created successfully"
|
"message": "CVE created successfully for vendor: Microsoft"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error (Duplicate):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "This CVE already exists for this vendor. Choose a different vendor or update the existing entry."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -610,6 +849,7 @@ curl -X POST http://192.168.2.117:3001/api/cves \
|
|||||||
PATCH /api/cves/:cveId/status
|
PATCH /api/cves/:cveId/status
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
**Required Role:** Editor or Admin
|
||||||
|
|
||||||
**Body:**
|
**Body:**
|
||||||
```json
|
```json
|
||||||
@@ -620,7 +860,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
|
curl -b cookies.txt -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "False Positive Requested"}'
|
-d '{"status": "False Positive Requested"}'
|
||||||
```
|
```
|
||||||
@@ -631,10 +871,14 @@ curl -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
|
|||||||
```http
|
```http
|
||||||
GET /api/cves/:cveId/documents
|
GET /api/cves/:cveId/documents
|
||||||
```
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `vendor` (optional): Filter documents by vendor
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
|
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents?vendor=Microsoft"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -643,6 +887,7 @@ curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"cve_id": "CVE-2024-1234",
|
"cve_id": "CVE-2024-1234",
|
||||||
|
"vendor": "Microsoft",
|
||||||
"name": "MS-Security-Advisory.pdf",
|
"name": "MS-Security-Advisory.pdf",
|
||||||
"type": "advisory",
|
"type": "advisory",
|
||||||
"file_path": "uploads/CVE-2024-1234/Microsoft/1706140800000-MS-Security-Advisory.pdf",
|
"file_path": "uploads/CVE-2024-1234/Microsoft/1706140800000-MS-Security-Advisory.pdf",
|
||||||
@@ -659,19 +904,18 @@ curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
|
|||||||
POST /api/cves/:cveId/documents
|
POST /api/cves/:cveId/documents
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
```
|
```
|
||||||
|
**Required Role:** Editor or Admin
|
||||||
|
|
||||||
**Form Fields:**
|
**Form Fields:**
|
||||||
- `file`: The file to upload
|
- `file`: The file to upload
|
||||||
- `cveId`: CVE ID (e.g., CVE-2024-1234)
|
- `vendor`: Vendor name (required - determines storage folder)
|
||||||
- `vendor`: Vendor name (e.g., Microsoft)
|
|
||||||
- `type`: Document type (advisory, email, screenshot, patch, other)
|
- `type`: Document type (advisory, email, screenshot, patch, other)
|
||||||
- `notes` (optional): Description
|
- `notes` (optional): Description
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
|
curl -b cookies.txt -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
|
||||||
-F "file=@/path/to/advisory.pdf" \
|
-F "file=@/path/to/advisory.pdf" \
|
||||||
-F "cveId=CVE-2024-1234" \
|
|
||||||
-F "vendor=Microsoft" \
|
-F "vendor=Microsoft" \
|
||||||
-F "type=advisory" \
|
-F "type=advisory" \
|
||||||
-F "notes=Official security advisory"
|
-F "notes=Official security advisory"
|
||||||
@@ -694,10 +938,11 @@ curl -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
|
|||||||
```http
|
```http
|
||||||
DELETE /api/documents/:id
|
DELETE /api/documents/:id
|
||||||
```
|
```
|
||||||
|
**Required Role:** Admin only
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE http://192.168.2.117:3001/api/documents/1
|
curl -b cookies.txt -X DELETE http://192.168.2.117:3001/api/documents/1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -713,10 +958,11 @@ curl -X DELETE http://192.168.2.117:3001/api/documents/1
|
|||||||
```http
|
```http
|
||||||
GET /api/vendors
|
GET /api/vendors
|
||||||
```
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://192.168.2.117:3001/api/vendors"
|
curl -b cookies.txt "http://192.168.2.117:3001/api/vendors"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -728,10 +974,11 @@ curl "http://192.168.2.117:3001/api/vendors"
|
|||||||
```http
|
```http
|
||||||
GET /api/stats
|
GET /api/stats
|
||||||
```
|
```
|
||||||
|
**Required Role:** Any authenticated user
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
curl "http://192.168.2.117:3001/api/stats"
|
curl -b cookies.txt "http://192.168.2.117:3001/api/stats"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -757,7 +1004,7 @@ Stores CVE metadata and remediation status.
|
|||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
||||||
| cve_id | VARCHAR(20) UNIQUE | CVE identifier (e.g., CVE-2024-1234) |
|
| cve_id | VARCHAR(20) | CVE identifier (e.g., CVE-2024-1234) |
|
||||||
| vendor | VARCHAR(100) | Vendor name |
|
| vendor | VARCHAR(100) | Vendor name |
|
||||||
| severity | VARCHAR(20) | Critical, High, Medium, Low |
|
| severity | VARCHAR(20) | Critical, High, Medium, Low |
|
||||||
| description | TEXT | Vulnerability description |
|
| description | TEXT | Vulnerability description |
|
||||||
@@ -766,6 +1013,8 @@ Stores CVE metadata and remediation status.
|
|||||||
| created_at | TIMESTAMP | Record creation timestamp |
|
| created_at | TIMESTAMP | Record creation timestamp |
|
||||||
| updated_at | TIMESTAMP | Last update timestamp |
|
| updated_at | TIMESTAMP | Last update timestamp |
|
||||||
|
|
||||||
|
**Unique Constraint:** `UNIQUE(cve_id, vendor)` - Allows same CVE with different vendors
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
- `idx_cve_id` on `cve_id`
|
- `idx_cve_id` on `cve_id`
|
||||||
- `idx_vendor` on `vendor`
|
- `idx_vendor` on `vendor`
|
||||||
@@ -778,7 +1027,8 @@ Stores document metadata and file locations.
|
|||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
||||||
| cve_id | VARCHAR(20) | Foreign key to cves.cve_id |
|
| cve_id | VARCHAR(20) | CVE identifier |
|
||||||
|
| vendor | VARCHAR(100) | Vendor name (for per-vendor organization) |
|
||||||
| name | VARCHAR(255) | Original filename |
|
| name | VARCHAR(255) | Original filename |
|
||||||
| type | VARCHAR(50) | advisory, email, screenshot, patch, other |
|
| type | VARCHAR(50) | advisory, email, screenshot, patch, other |
|
||||||
| file_path | VARCHAR(500) | Path to file on filesystem |
|
| file_path | VARCHAR(500) | Path to file on filesystem |
|
||||||
@@ -791,8 +1041,49 @@ Stores document metadata and file locations.
|
|||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
- `idx_doc_cve_id` on `cve_id`
|
- `idx_doc_cve_id` on `cve_id`
|
||||||
|
- `idx_doc_vendor` on `vendor`
|
||||||
- `idx_doc_type` on `type`
|
- `idx_doc_type` on `type`
|
||||||
|
|
||||||
|
#### `users`
|
||||||
|
Stores user accounts for authentication.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
||||||
|
| username | VARCHAR(50) UNIQUE | Login username |
|
||||||
|
| email | VARCHAR(255) UNIQUE | User email address |
|
||||||
|
| password_hash | VARCHAR(255) | bcrypt hashed password |
|
||||||
|
| role | VARCHAR(20) | admin, editor, or viewer |
|
||||||
|
| is_active | BOOLEAN | Account active status (1=active, 0=disabled) |
|
||||||
|
| created_at | TIMESTAMP | Account creation timestamp |
|
||||||
|
| last_login | TIMESTAMP | Last successful login |
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
- `admin` - Full access: manage users, delete documents, all CVE operations
|
||||||
|
- `editor` - Can add/edit CVEs, upload documents
|
||||||
|
- `viewer` - Read-only access to CVEs and documents
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_users_username` on `username`
|
||||||
|
|
||||||
|
#### `sessions`
|
||||||
|
Stores active user sessions.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
|
||||||
|
| session_id | VARCHAR(255) UNIQUE | Session token (stored in cookie) |
|
||||||
|
| user_id | INTEGER | Foreign key to users.id |
|
||||||
|
| expires_at | TIMESTAMP | Session expiration time |
|
||||||
|
| created_at | TIMESTAMP | Session creation timestamp |
|
||||||
|
|
||||||
|
**Foreign Key:** `user_id` → `users(id)` ON DELETE CASCADE
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_sessions_session_id` on `session_id`
|
||||||
|
- `idx_sessions_user_id` on `user_id`
|
||||||
|
- `idx_sessions_expires` on `expires_at`
|
||||||
|
|
||||||
#### `required_documents`
|
#### `required_documents`
|
||||||
Defines which document types are mandatory per vendor.
|
Defines which document types are mandatory per vendor.
|
||||||
|
|
||||||
@@ -872,8 +1163,15 @@ cve-dashboard/
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express API server
|
│ ├── server.js # Express API server
|
||||||
│ ├── setup.js # Database initialization script
|
│ ├── setup.js # Database initialization script
|
||||||
|
│ ├── setup-env.sh # Environment configuration script
|
||||||
|
│ ├── .env # Environment variables (create with setup-env.sh)
|
||||||
│ ├── cve_database.db # SQLite database file
|
│ ├── cve_database.db # SQLite database file
|
||||||
│ ├── package.json # Backend dependencies
|
│ ├── package.json # Backend dependencies
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── auth.js # Authentication middleware
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # Login/logout endpoints
|
||||||
|
│ │ └── users.js # User management endpoints
|
||||||
│ └── backend.log # Backend log file (if using startup script)
|
│ └── backend.log # Backend log file (if using startup script)
|
||||||
│
|
│
|
||||||
├── frontend/
|
├── frontend/
|
||||||
@@ -882,22 +1180,32 @@ cve-dashboard/
|
|||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── App.js # Main React component
|
│ │ ├── App.js # Main React component
|
||||||
│ │ ├── index.js # React entry point
|
│ │ ├── index.js # React entry point
|
||||||
│ │ └── index.css # Global styles
|
│ │ ├── index.css # Global styles
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── LoginForm.js # Login page component
|
||||||
|
│ │ │ ├── UserMenu.js # User dropdown menu
|
||||||
|
│ │ │ └── UserManagement.js # Admin user management
|
||||||
|
│ │ └── contexts/
|
||||||
|
│ │ └── AuthContext.js # Authentication state management
|
||||||
|
│ ├── .env # Environment variables (create with setup-env.sh)
|
||||||
│ ├── package.json # Frontend dependencies
|
│ ├── package.json # Frontend dependencies
|
||||||
│ └── frontend.log # Frontend log file (if using startup script)
|
│ └── frontend.log # Frontend log file (if using startup script)
|
||||||
│
|
│
|
||||||
├── uploads/ # File storage (auto-created)
|
├── uploads/ # File storage (auto-created)
|
||||||
│ ├── temp/ # Temporary upload directory
|
│ ├── temp/ # Temporary upload directory
|
||||||
│ ├── CVE-2024-1234/
|
│ ├── CVE-2024-1234/
|
||||||
│ │ └── Microsoft/
|
│ │ ├── Microsoft/ # Vendor-specific folder
|
||||||
│ │ ├── 1706140800000-advisory.pdf
|
│ │ │ ├── 1706140800000-advisory.pdf
|
||||||
│ │ └── 1706140850000-email.pdf
|
│ │ │ └── 1706140850000-email.pdf
|
||||||
|
│ │ └── Cisco/ # Same CVE, different vendor
|
||||||
|
│ │ └── 1706140900000-advisory.pdf
|
||||||
│ └── CVE-2024-5678/
|
│ └── CVE-2024-5678/
|
||||||
│ └── Cisco/
|
│ └── Oracle/
|
||||||
│ └── 1706140900000-advisory.pdf
|
│ └── 1706140900000-advisory.pdf
|
||||||
│
|
│
|
||||||
├── .gitignore # Git ignore rules
|
├── .gitignore # Git ignore rules
|
||||||
├── README.md # This file
|
├── README.md # This file
|
||||||
|
├── test_cases_auth.md # Authentication test cases
|
||||||
├── start-servers.sh # Startup script
|
├── start-servers.sh # Startup script
|
||||||
├── stop-servers.sh # Shutdown script
|
├── stop-servers.sh # Shutdown script
|
||||||
├── backend.pid # Backend process ID (when running)
|
├── backend.pid # Backend process ID (when running)
|
||||||
@@ -1104,8 +1412,10 @@ chmod -R 777 /home/cve-dashboard/uploads
|
|||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
### Version 1.1 (Next Release)
|
### Version 1.1 (Current Release) ✅
|
||||||
- [ ] **User Authentication**: Login system with user roles
|
- [x] **User Authentication**: Login system with user roles (admin, editor, viewer)
|
||||||
|
- [x] **Multi-Vendor Support**: Same CVE can be tracked across multiple vendors
|
||||||
|
- [x] **Environment Configuration**: .env files replace hardcoded IPs
|
||||||
- [ ] **Audit Logging**: Track who added/modified CVEs
|
- [ ] **Audit Logging**: Track who added/modified CVEs
|
||||||
- [ ] **Email Notifications**: Alert when new CVEs are added
|
- [ ] **Email Notifications**: Alert when new CVEs are added
|
||||||
- [ ] **Export to Excel**: Download CVE list as spreadsheet
|
- [ ] **Export to Excel**: Download CVE list as spreadsheet
|
||||||
@@ -1232,16 +1542,16 @@ Vulnerability Management Team
|
|||||||
|
|
||||||
This software is proprietary and confidential. Unauthorized copying, distribution, or use of this software, via any medium, is strictly prohibited.
|
This software is proprietary and confidential. Unauthorized copying, distribution, or use of this software, via any medium, is strictly prohibited.
|
||||||
|
|
||||||
Copyright © 2024 Charter Communications. All rights reserved.
|
Copyright © 2024-2026 Charter Communications. All rights reserved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Project Statistics
|
## 📊 Project Statistics
|
||||||
|
|
||||||
- **Version**: 1.0.0
|
- **Version**: 1.1.0
|
||||||
- **Released**: January 2024
|
- **Released**: January 2026
|
||||||
- **Lines of Code**: ~1,500
|
- **Lines of Code**: ~2,500
|
||||||
- **Dependencies**: 12
|
- **Dependencies**: 15
|
||||||
- **Supported Browsers**: Chrome, Edge, Firefox, Safari
|
- **Supported Browsers**: Chrome, Edge, Firefox, Safari
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1273,6 +1583,40 @@ Copyright © 2024 Charter Communications. All rights reserved.
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
|
### [1.1.0] - 2026-01-29
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **User Authentication**: Complete login system with session-based auth
|
||||||
|
- Three user roles: admin, editor, viewer
|
||||||
|
- Default admin account (admin/admin123)
|
||||||
|
- Session persistence with secure cookies
|
||||||
|
- Password hashing with bcryptjs
|
||||||
|
- **User Management**: Admin interface for managing users
|
||||||
|
- Create, edit, deactivate users
|
||||||
|
- Role assignment
|
||||||
|
- Password reset capability
|
||||||
|
- **Multi-Vendor Support**: Track same CVE across multiple vendors
|
||||||
|
- UNIQUE constraint on (cve_id, vendor) instead of just cve_id
|
||||||
|
- Per-vendor document storage
|
||||||
|
- Quick Check shows all vendors for a CVE
|
||||||
|
- New API endpoint: GET /api/cves/:cveId/vendors
|
||||||
|
- **Environment Configuration**: Replaced hardcoded IPs
|
||||||
|
- setup-env.sh script for easy configuration
|
||||||
|
- .env files for both frontend and backend
|
||||||
|
- Auto-detection of server IP address
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- All API endpoints now require authentication
|
||||||
|
- Document deletion restricted to admin role
|
||||||
|
- CVE creation/editing restricted to editor and admin roles
|
||||||
|
- stop-servers.sh improved with better process killing
|
||||||
|
- Browser tab title changed from "ReactApp" to "Dashboard"
|
||||||
|
- Document storage now organized by CVE ID AND vendor
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- Dynamic hostname detection now works via environment variables
|
||||||
|
- Multiple vendors can now have entries for the same CVE
|
||||||
|
|
||||||
### [1.0.0] - 2024-01-26
|
### [1.0.0] - 2024-01-26
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -1288,9 +1632,9 @@ Copyright © 2024 Charter Communications. All rights reserved.
|
|||||||
- Document compliance tracking
|
- Document compliance tracking
|
||||||
- Required document configuration per vendor
|
- Required document configuration per vendor
|
||||||
|
|
||||||
#### Known Issues
|
#### Known Issues (Resolved in 1.1.0)
|
||||||
- Dynamic hostname detection not working (hardcoded IP as workaround)
|
- ~~Dynamic hostname detection not working (hardcoded IP as workaround)~~ Fixed
|
||||||
- No user authentication (single-user system)
|
- ~~No user authentication (single-user system)~~ Fixed
|
||||||
- Export functionality shows alert only (not implemented)
|
- Export functionality shows alert only (not implemented)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
222
TEST_PLAN_AUDIT_LOG.md
Normal file
222
TEST_PLAN_AUDIT_LOG.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Audit Logging Feature - User Acceptance Test Plan
|
||||||
|
|
||||||
|
## Test Environment Setup
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Fresh database via `node backend/setup.js`, OR existing database migrated via `node backend/migrate-audit-log.js`
|
||||||
|
- Backend running on port 3001
|
||||||
|
- Frontend running on port 3000
|
||||||
|
- Three test accounts created:
|
||||||
|
- `admin` / `admin123` (role: admin)
|
||||||
|
- `editor1` (role: editor)
|
||||||
|
- `viewer1` (role: viewer)
|
||||||
|
|
||||||
|
**Verify setup:** Run `sqlite3 backend/cve_database.db ".tables"` and confirm `audit_logs` is listed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Database & Schema
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 1.1 | Fresh install creates table | Run `node setup.js` on a new DB. Query `SELECT sql FROM sqlite_master WHERE name='audit_logs'` | Table exists with columns: id, user_id, username, action, entity_type, entity_id, details, ip_address, created_at | |
|
||||||
|
| 1.2 | Indexes created | Query `SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_audit%'` | Four indexes: idx_audit_user_id, idx_audit_action, idx_audit_entity_type, idx_audit_created_at | |
|
||||||
|
| 1.3 | Migration is idempotent | Run `node migrate-audit-log.js` twice on the same DB | Second run prints "already exists, nothing to do". No errors. Backup file created each run. | |
|
||||||
|
| 1.4 | Migration backs up DB | Run `node migrate-audit-log.js` | Backup file `cve_database_backup_<timestamp>.db` created in backend directory | |
|
||||||
|
| 1.5 | Setup summary updated | Run `node setup.js` | Console output lists `audit_logs` in the tables line | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication Audit Logging
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 2.1 | Successful login logged | Log in as `admin`. Query `SELECT * FROM audit_logs WHERE action='login' ORDER BY id DESC LIMIT 1` | Row with user_id=admin's ID, username='admin', action='login', entity_type='auth', details contains `{"role":"admin"}`, ip_address populated | |
|
||||||
|
| 2.2 | Failed login - wrong password | Attempt login with `admin` / `wrongpass`. Query audit_logs. | Row with action='login_failed', username='admin', details contains `{"reason":"invalid_password"}` | |
|
||||||
|
| 2.3 | Failed login - unknown user | Attempt login with `nonexistent` / `anypass`. Query audit_logs. | Row with action='login_failed', user_id=NULL, username='nonexistent', details contains `{"reason":"user_not_found"}` | |
|
||||||
|
| 2.4 | Failed login - disabled account | Disable a user account via admin, then attempt login as that user. Query audit_logs. | Row with action='login_failed', details contains `{"reason":"account_disabled"}` | |
|
||||||
|
| 2.5 | Logout logged | Log in as admin, then log out. Query audit_logs. | Row with action='logout', entity_type='auth', username='admin' | |
|
||||||
|
| 2.6 | Login does not block on audit error | Verify login succeeds even if audit_logs table had issues (non-critical path) | Login response returns normally regardless of audit insert result | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CVE Operation Audit Logging
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 3.1 | CVE create logged | Log in as editor or admin. Add a new CVE (e.g., CVE-2025-TEST-1 / Microsoft / Critical). Query audit_logs. | Row with action='cve_create', entity_type='cve', entity_id='CVE-2025-TEST-1', details contains `{"vendor":"Microsoft","severity":"Critical"}` | |
|
||||||
|
| 3.2 | CVE status update logged | Update a CVE's status to "Addressed" via the API (`PATCH /api/cves/CVE-2025-TEST-1/status`). Query audit_logs. | Row with action='cve_update_status', entity_id='CVE-2025-TEST-1', details contains `{"status":"Addressed"}` | |
|
||||||
|
| 3.3 | CVE status update bug fix | Update a CVE's status. Verify the CVE record in the `cves` table. | Status is correctly updated. No SQL error (the old `vendor` reference bug is fixed). | |
|
||||||
|
| 3.4 | Audit captures acting user | Log in as `editor1`, create a CVE. Query audit_logs. | username='editor1' on the cve_create row | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Document Operation Audit Logging
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 4.1 | Document upload logged | Upload a document to a CVE via the UI. Query audit_logs. | Row with action='document_upload', entity_type='document', entity_id=CVE ID, details contains vendor, type, and filename | |
|
||||||
|
| 4.2 | Document delete logged | Delete a document (admin only) via the UI. Query audit_logs. | Row with action='document_delete', entity_type='document', entity_id=document DB ID, details contains file_path | |
|
||||||
|
| 4.3 | Upload captures file metadata | Upload a file named `advisory.pdf` of type `advisory` for vendor `Cisco`. Query audit_logs. | details = `{"vendor":"Cisco","type":"advisory","filename":"advisory.pdf"}` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. User Management Audit Logging
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 5.1 | User create logged | As admin, create a new user `testuser` with role `viewer`. Query audit_logs. | Row with action='user_create', entity_type='user', entity_id=new user's ID, details contains `{"created_username":"testuser","role":"viewer"}` | |
|
||||||
|
| 5.2 | User update logged | As admin, change `testuser`'s role to `editor`. Query audit_logs. | Row with action='user_update', entity_id=testuser's ID, details contains `{"role":"editor"}` | |
|
||||||
|
| 5.3 | User update - password change | As admin, change `testuser`'s password. Query audit_logs. | details contains `{"password_changed":true}` (password itself is NOT logged) | |
|
||||||
|
| 5.4 | User update - multiple fields | Change username and role at the same time. Query audit_logs. | details contains both changed fields | |
|
||||||
|
| 5.5 | User delete logged | As admin, delete `testuser`. Query audit_logs. | Row with action='user_delete', details contains `{"deleted_username":"testuser"}` | |
|
||||||
|
| 5.6 | User deactivation logged | As admin, set a user's is_active to false. Query audit_logs. | Row with action='user_update', details contains `{"is_active":false}` | |
|
||||||
|
| 5.7 | Self-delete prevented, no log | As admin, attempt to delete your own account. Query audit_logs. | 400 error returned. NO audit_log entry created for the attempt. | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API Access Control
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 6.1 | Admin can query audit logs | Log in as admin. `GET /api/audit-logs`. | 200 response with logs array and pagination object | |
|
||||||
|
| 6.2 | Editor denied audit logs | Log in as editor. `GET /api/audit-logs`. | 403 response with `{"error":"Insufficient permissions"}` | |
|
||||||
|
| 6.3 | Viewer denied audit logs | Log in as viewer. `GET /api/audit-logs`. | 403 response | |
|
||||||
|
| 6.4 | Unauthenticated denied | Without a session cookie, `GET /api/audit-logs`. | 401 response | |
|
||||||
|
| 6.5 | Admin can get actions list | `GET /api/audit-logs/actions` as admin. | 200 response with array of distinct action strings | |
|
||||||
|
| 6.6 | Non-admin denied actions list | `GET /api/audit-logs/actions` as editor. | 403 response | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Filtering & Pagination
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 7.1 | Default pagination | `GET /api/audit-logs` (no params). | Returns up to 25 entries, page=1, correct total count and totalPages | |
|
||||||
|
| 7.2 | Custom page size | `GET /api/audit-logs?limit=5`. | Returns exactly 5 entries (if >= 5 exist). Pagination reflects limit=5. | |
|
||||||
|
| 7.3 | Page size capped at 100 | `GET /api/audit-logs?limit=999`. | Returns at most 100 entries per page | |
|
||||||
|
| 7.4 | Navigate to page 2 | `GET /api/audit-logs?page=2&limit=5`. | Returns entries 6-10 (offset=5). Entries differ from page 1. | |
|
||||||
|
| 7.5 | Filter by username | `GET /api/audit-logs?user=admin`. | Only entries where username contains "admin" | |
|
||||||
|
| 7.6 | Partial username match | `GET /api/audit-logs?user=adm`. | Matches "admin" (LIKE search) | |
|
||||||
|
| 7.7 | Filter by action | `GET /api/audit-logs?action=login`. | Only entries with action='login' (exact match) | |
|
||||||
|
| 7.8 | Filter by entity type | `GET /api/audit-logs?entityType=auth`. | Only auth-related entries | |
|
||||||
|
| 7.9 | Filter by date range | `GET /api/audit-logs?startDate=2025-01-01&endDate=2025-12-31`. | Only entries within the date range (inclusive) | |
|
||||||
|
| 7.10 | Combined filters | `GET /api/audit-logs?user=admin&action=login&entityType=auth`. | Only entries matching ALL filters simultaneously | |
|
||||||
|
| 7.11 | Empty result set | `GET /api/audit-logs?user=nonexistentuser`. | `{"logs":[],"pagination":{"page":1,"limit":25,"total":0,"totalPages":0}}` | |
|
||||||
|
| 7.12 | Ordering | Query audit logs without filters. | Entries ordered by created_at DESC (newest first) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend - Audit Log Menu Access
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 8.1 | Admin sees Audit Log menu item | Log in as admin. Click user avatar to open dropdown menu. | "Audit Log" option visible with clock icon, positioned between "Manage Users" and "Sign Out" | |
|
||||||
|
| 8.2 | Editor does NOT see Audit Log | Log in as editor. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
|
||||||
|
| 8.3 | Viewer does NOT see Audit Log | Log in as viewer. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
|
||||||
|
| 8.4 | Clicking Audit Log opens modal | As admin, click "Audit Log" in the menu. | Modal overlay appears with audit log table. Menu dropdown closes. | |
|
||||||
|
| 8.5 | Menu closes on outside click | Open the user menu, then click outside the dropdown. | Dropdown closes | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Frontend - Audit Log Modal
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 9.1 | Modal displays header | Open the Audit Log modal. | Title "Audit Log", subtitle "Track all user actions across the system", X close button visible | |
|
||||||
|
| 9.2 | Close button works | Click the X button on the modal. | Modal closes, returns to dashboard | |
|
||||||
|
| 9.3 | Loading state shown | Open the modal (observe briefly). | Spinner with "Loading audit logs..." appears before data loads | |
|
||||||
|
| 9.4 | Table columns correct | Open modal with data present. | Six columns visible: Time, User, Action, Entity, Details, IP Address | |
|
||||||
|
| 9.5 | Time formatting | Check the Time column. | Dates display in local format (e.g., "1/29/2026, 3:45:00 PM"), not raw ISO strings | |
|
||||||
|
| 9.6 | Action badges color-coded | View entries with different action types. | login=green, logout=gray, login_failed=red, cve_create=blue, cve_update_status=yellow, document_upload=purple, document_delete=red, user_create=blue, user_update=yellow, user_delete=red | |
|
||||||
|
| 9.7 | Entity column format | View entries with entity_type and entity_id. | Shows "cve CVE-2025-TEST-1" or "auth" (no ID for auth entries) | |
|
||||||
|
| 9.8 | Details column formatting | View an entry with JSON details. | Displays "key: value, key: value" format, not raw JSON | |
|
||||||
|
| 9.9 | Details truncation | View entry with long details. | Text truncated with ellipsis. Full text visible on hover (title attribute). | |
|
||||||
|
| 9.10 | IP address display | View entries. | IP addresses shown in monospace font. Null IPs show "-" | |
|
||||||
|
| 9.11 | Empty state | Apply filters that return no results. | "No audit log entries found." message displayed | |
|
||||||
|
| 9.12 | Error state | (Simulate: stop backend while modal is open, then apply filters.) | Error icon with error message displayed | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend - Filters
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 10.1 | Username filter | Type "admin" in username field, click Apply Filters. | Only entries with "admin" in username shown | |
|
||||||
|
| 10.2 | Action dropdown populated | Click the Action dropdown. | Lists all distinct actions present in the database (from `/api/audit-logs/actions`) | |
|
||||||
|
| 10.3 | Action filter | Select "login" from Action dropdown, click Apply. | Only login entries shown | |
|
||||||
|
| 10.4 | Entity type dropdown | Click the Entity Type dropdown. | Lists: auth, cve, document, user | |
|
||||||
|
| 10.5 | Entity type filter | Select "cve", click Apply. | Only CVE-related entries shown | |
|
||||||
|
| 10.6 | Date range filter | Set start date to today, set end date to today, click Apply. | Only entries from today shown | |
|
||||||
|
| 10.7 | Combined filters | Set username="admin", action="login", click Apply. | Only admin login entries shown | |
|
||||||
|
| 10.8 | Reset button | Set multiple filters, click Reset. | All filter fields cleared. (Note: table does not auto-refresh until Apply is clicked again.) | |
|
||||||
|
| 10.9 | Apply after reset | Click Reset, then click Apply Filters. | Full unfiltered results shown | |
|
||||||
|
| 10.10 | Filter resets to page 1 | Navigate to page 2, then apply a filter. | Results start from page 1 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Frontend - Pagination
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 11.1 | Pagination info displayed | Open modal with >25 entries. | Shows "Showing 1 - 25 of N entries" and "Page 1 of X" | |
|
||||||
|
| 11.2 | Next page button | Click the right chevron. | Page advances. Entry range updates. "Page 2 of X" shown. | |
|
||||||
|
| 11.3 | Previous page button | Navigate to page 2, then click left chevron. | Returns to page 1 | |
|
||||||
|
| 11.4 | First page - prev disabled | On page 1, check left chevron. | Button is disabled (grayed out, not clickable) | |
|
||||||
|
| 11.5 | Last page - next disabled | Navigate to the last page. | Right chevron is disabled | |
|
||||||
|
| 11.6 | Pagination hidden for few entries | Open modal with <= 25 total entries. | No pagination controls shown (totalPages <= 1) | |
|
||||||
|
| 11.7 | Entry count accuracy | Compare "Showing X - Y of Z" with actual table rows. | Row count matches Y - X + 1. Total Z matches database count. | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Fire-and-Forget Behavior
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 12.1 | Audit failure does not break login | (Requires code-level test or corrupting audit_logs table temporarily.) Rename audit_logs table, attempt login. | Login succeeds. Console shows "Audit log error:" message. | |
|
||||||
|
| 12.2 | Audit failure does not break CVE create | With corrupted audit table, create a CVE. | CVE created successfully. Error logged to console only. | |
|
||||||
|
| 12.3 | Response not delayed by audit | Create a CVE and observe response time. | Response returns immediately; audit insert is non-blocking. | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Data Integrity
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 13.1 | Audit survives user deletion | Create user, perform actions, delete user. Query audit_logs for that username. | Audit entries remain with the username preserved (denormalized). No foreign key cascade. | |
|
||||||
|
| 13.2 | Details stored as valid JSON | Query `SELECT details FROM audit_logs WHERE details IS NOT NULL LIMIT 5`. Parse each. | All non-null details values are valid JSON strings | |
|
||||||
|
| 13.3 | IP address captured | Query entries created via browser. | ip_address field contains the client IP (e.g., `::1` for localhost or `127.0.0.1`) | |
|
||||||
|
| 13.4 | Timestamps auto-populated | Query entries without explicitly setting created_at. | All rows have a created_at value, not NULL | |
|
||||||
|
| 13.5 | Null entity_id for auth actions | Query `SELECT * FROM audit_logs WHERE entity_type='auth'`. | entity_id is NULL for login/logout/login_failed entries | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. End-to-End Workflow
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Pass/Fail |
|
||||||
|
|---|-----------|-------|-----------------|-----------|
|
||||||
|
| 14.1 | Full user lifecycle | 1. Admin logs in 2. Creates user "testuser2" 3. testuser2 logs in 4. testuser2 creates a CVE 5. Admin updates testuser2's role 6. Admin deletes testuser2 7. Open Audit Log and review | All 6 actions visible in the audit log in reverse chronological order. Each entry has correct user, action, entity, and details. | |
|
||||||
|
| 14.2 | Filter down to one user's actions | Perform test 14.1, then filter by username="testuser2". | Only testuser2's own actions shown (login, cve_create). Admin actions on testuser2 show admin as the actor. | |
|
||||||
|
| 14.3 | Security audit trail | Attempt 3 failed logins with wrong password, then succeed. Open Audit Log, filter action="login_failed". | All 3 failed attempts visible with timestamps and IP addresses. Useful for detecting brute force. | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Summary
|
||||||
|
|
||||||
|
| Section | Tests | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| 1. Database & Schema | 5 | Table creation, indexes, migration idempotency |
|
||||||
|
| 2. Auth Logging | 6 | Login success/failure variants, logout |
|
||||||
|
| 3. CVE Logging | 4 | Create, status update, bug fix verification |
|
||||||
|
| 4. Document Logging | 3 | Upload, delete, metadata capture |
|
||||||
|
| 5. User Mgmt Logging | 7 | Create, update, delete, edge cases |
|
||||||
|
| 6. API Access Control | 6 | Admin-only enforcement on all endpoints |
|
||||||
|
| 7. API Filtering | 12 | Pagination, filters, combined queries |
|
||||||
|
| 8. Menu Access | 5 | Role-based UI visibility |
|
||||||
|
| 9. Modal Display | 12 | Table rendering, formatting, states |
|
||||||
|
| 10. Frontend Filters | 10 | Filter UI interaction and behavior |
|
||||||
|
| 11. Pagination UI | 7 | Navigation, boundary conditions |
|
||||||
|
| 12. Fire-and-Forget | 3 | Non-blocking audit behavior |
|
||||||
|
| 13. Data Integrity | 5 | Denormalization, JSON, timestamps |
|
||||||
|
| 14. End-to-End | 3 | Full workflow validation |
|
||||||
|
| **Total** | **88** | |
|
||||||
211
WEEKLY_REPORT_FEATURE.md
Normal file
211
WEEKLY_REPORT_FEATURE.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Weekly Vulnerability Report Upload Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
|
||||||
|
- Created `weekly_reports` table to store report metadata
|
||||||
|
- Tracks upload date, file paths, row counts, and which report is current
|
||||||
|
- Indexed for fast queries
|
||||||
|
|
||||||
|
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
|
||||||
|
- Executes Python script via Node.js child_process
|
||||||
|
- Parses row counts from Python output
|
||||||
|
- Handles errors, timeouts (30 seconds), and validation
|
||||||
|
|
||||||
|
3. **API Routes** (`backend/routes/weeklyReports.js`)
|
||||||
|
- `POST /api/weekly-reports/upload` - Upload and process Excel file
|
||||||
|
- `GET /api/weekly-reports` - List all reports
|
||||||
|
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
|
||||||
|
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
|
||||||
|
|
||||||
|
4. **Python Script** (`backend/scripts/split_cve_report.py`)
|
||||||
|
- Moved from ~/Documents to backend/scripts
|
||||||
|
- Splits comma-separated CVE IDs into separate rows
|
||||||
|
- Duplicates device/IP data for each CVE
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
|
||||||
|
- Phase-based UI: idle → uploading → processing → success
|
||||||
|
- File upload with .xlsx validation
|
||||||
|
- Display existing reports with current report indicator (★)
|
||||||
|
- Download buttons for both original and processed files
|
||||||
|
|
||||||
|
2. **App.js Integration**
|
||||||
|
- Added "Weekly Report" button next to NVD Sync button
|
||||||
|
- State management for modal visibility
|
||||||
|
- Modal rendering
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Starting the Application
|
||||||
|
|
||||||
|
1. **Backend:**
|
||||||
|
```bash
|
||||||
|
cd /home/admin/cve-dashboard/backend
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Frontend:**
|
||||||
|
```bash
|
||||||
|
cd /home/admin/cve-dashboard/frontend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the Feature
|
||||||
|
|
||||||
|
1. **Access the Feature**
|
||||||
|
- Login as an editor or admin user
|
||||||
|
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
|
||||||
|
|
||||||
|
2. **Upload a Report**
|
||||||
|
- Click the "Weekly Report" button
|
||||||
|
- Click "Choose File" and select your .xlsx file
|
||||||
|
- Click "Upload & Process"
|
||||||
|
- Wait for processing to complete (usually 5-10 seconds)
|
||||||
|
|
||||||
|
3. **Download Processed Report**
|
||||||
|
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
|
||||||
|
- Click "Download Processed" to get the split version
|
||||||
|
- The current week's report is marked with a ★ star icon
|
||||||
|
|
||||||
|
4. **Access Previous Reports**
|
||||||
|
- All previous reports are listed below the upload section
|
||||||
|
- Click the download icons to get original or processed versions
|
||||||
|
- Reports are labeled as "This week's report", "Last week's report", or by date
|
||||||
|
|
||||||
|
### What the Processing Does
|
||||||
|
|
||||||
|
**Before Processing:**
|
||||||
|
| HOSTNAME | IP | CVE ID |
|
||||||
|
|----------|------------|---------------------------|
|
||||||
|
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
|
||||||
|
|
||||||
|
**After Processing:**
|
||||||
|
| HOSTNAME | IP | CVE ID |
|
||||||
|
|----------|------------|---------------------------|
|
||||||
|
| server01 | 10.0.0.1 | CVE-2024-1234 |
|
||||||
|
| server01 | 10.0.0.1 | CVE-2024-5678 |
|
||||||
|
|
||||||
|
Each CVE now has its own row, making it easy to:
|
||||||
|
- Sort by CVE ID
|
||||||
|
- Filter for specific CVEs
|
||||||
|
- Research CVEs one by one per device
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
### New Files Created
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
scripts/
|
||||||
|
split_cve_report.py # Python script for CVE splitting
|
||||||
|
requirements.txt # Python dependencies
|
||||||
|
routes/
|
||||||
|
weeklyReports.js # API endpoints
|
||||||
|
helpers/
|
||||||
|
excelProcessor.js # Python integration
|
||||||
|
migrations/
|
||||||
|
add_weekly_reports_table.js # Database migration
|
||||||
|
uploads/
|
||||||
|
weekly_reports/ # Uploaded and processed files
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
WeeklyReportModal.js # Upload modal UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
server.js # Added route mounting
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
App.js # Added button and modal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security & Permissions
|
||||||
|
|
||||||
|
- **Upload**: Requires editor or admin role
|
||||||
|
- **Download**: Any authenticated user
|
||||||
|
- **Delete**: Admin only
|
||||||
|
- **File Validation**: Only .xlsx files accepted, 10MB limit
|
||||||
|
- **Audit Logging**: All uploads, downloads, and deletions are logged
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend Issues
|
||||||
|
|
||||||
|
**Python not found:**
|
||||||
|
```bash
|
||||||
|
# Install Python 3
|
||||||
|
sudo apt-get install python3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Missing dependencies:**
|
||||||
|
```bash
|
||||||
|
# Install pandas and openpyxl
|
||||||
|
pip3 install pandas openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
**Port already in use:**
|
||||||
|
```bash
|
||||||
|
# Find and kill process using port 3001
|
||||||
|
lsof -i :3001
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Issues
|
||||||
|
|
||||||
|
**Button not visible:**
|
||||||
|
- Make sure you're logged in as editor or admin
|
||||||
|
- Viewer role cannot upload reports
|
||||||
|
|
||||||
|
**Upload fails:**
|
||||||
|
- Check file is .xlsx format (not .xls or .csv)
|
||||||
|
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
|
||||||
|
- Check file size is under 10MB
|
||||||
|
|
||||||
|
**Processing timeout:**
|
||||||
|
- Large files (10,000+ rows) may timeout
|
||||||
|
- Try reducing file size or increase timeout in `excelProcessor.js`
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Backend starts without errors
|
||||||
|
- [x] Frontend compiles successfully
|
||||||
|
- [x] Database migration completed
|
||||||
|
- [x] Python dependencies installed
|
||||||
|
- [ ] Upload .xlsx file (manual test in browser)
|
||||||
|
- [ ] Verify processed file has split CVEs (manual test)
|
||||||
|
- [ ] Download original and processed files (manual test)
|
||||||
|
- [ ] Verify current report marked with star (manual test)
|
||||||
|
- [ ] Test as viewer - button should be hidden (manual test)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Possible improvements:
|
||||||
|
- Progress bar during Python processing
|
||||||
|
- Email notifications when processing completes
|
||||||
|
- Scheduled automatic uploads
|
||||||
|
- Report comparison (diff between weeks)
|
||||||
|
- Export to other formats (CSV, JSON)
|
||||||
|
- Bulk delete old reports
|
||||||
|
- Report validation before upload
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check the troubleshooting section above
|
||||||
|
2. Review audit logs for error details
|
||||||
|
3. Check browser console for frontend errors
|
||||||
|
4. Review backend server logs for API errors
|
||||||
838
architecture.excalidraw
Normal file
838
architecture.excalidraw
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "title-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 400,
|
||||||
|
"y": 30,
|
||||||
|
"width": 400,
|
||||||
|
"height": 45,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 1,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "CVE Dashboard Architecture",
|
||||||
|
"fontSize": 36,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 32,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "CVE Dashboard Architecture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-box",
|
||||||
|
"type": "ellipse",
|
||||||
|
"x": 500,
|
||||||
|
"y": 120,
|
||||||
|
"width": 200,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#e7f5ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 2,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "users-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 505,
|
||||||
|
"y": 145,
|
||||||
|
"width": 190,
|
||||||
|
"height": 30,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 3,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Users\n(Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 16,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 23,
|
||||||
|
"containerId": "users-box",
|
||||||
|
"originalText": "Users\n(Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 450,
|
||||||
|
"y": 250,
|
||||||
|
"width": 300,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#a5d8ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 4,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "frontend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 455,
|
||||||
|
"y": 255,
|
||||||
|
"width": 290,
|
||||||
|
"height": 110,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 5,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 103,
|
||||||
|
"containerId": "frontend-box",
|
||||||
|
"originalText": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 400,
|
||||||
|
"y": 420,
|
||||||
|
"width": 400,
|
||||||
|
"height": 180,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "#d0bfff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 6,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "backend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 405,
|
||||||
|
"y": 425,
|
||||||
|
"width": 390,
|
||||||
|
"height": 170,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 7,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 163,
|
||||||
|
"containerId": "backend-box",
|
||||||
|
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 200,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "#b2f2bb",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 8,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "db-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 205,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 9,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "db-box",
|
||||||
|
"originalText": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 550,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "#ffec99",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 10,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "storage-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 555,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 11,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "storage-box",
|
||||||
|
"originalText": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 900,
|
||||||
|
"y": 420,
|
||||||
|
"width": 220,
|
||||||
|
"height": 100,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "#ffc9c9",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 12,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "nvd-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 905,
|
||||||
|
"y": 425,
|
||||||
|
"width": 210,
|
||||||
|
"height": 90,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 13,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 83,
|
||||||
|
"containerId": "nvd-box",
|
||||||
|
"originalText": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 200,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 14,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "users-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 370,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 15,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 500,
|
||||||
|
"y": 600,
|
||||||
|
"width": -140,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 16,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[-140, 0],
|
||||||
|
[-140, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "db-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": true,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 700,
|
||||||
|
"y": 600,
|
||||||
|
"width": 0,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 17,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "storage-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 800,
|
||||||
|
"y": 480,
|
||||||
|
"width": 100,
|
||||||
|
"height": 0,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 18,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[100, 0]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "nvd-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-http",
|
||||||
|
"type": "text",
|
||||||
|
"x": 610,
|
||||||
|
"y": 390,
|
||||||
|
"width": 100,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 19,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTP/REST API",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTP/REST API"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-https",
|
||||||
|
"type": "text",
|
||||||
|
"x": 820,
|
||||||
|
"y": 460,
|
||||||
|
"width": 60,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 20,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTPS",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTPS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auth-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 100,
|
||||||
|
"y": 250,
|
||||||
|
"width": 280,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 21,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 73,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "features-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 900,
|
||||||
|
"y": 580,
|
||||||
|
"width": 280,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 22,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 113,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appState": {
|
||||||
|
"gridSize": null,
|
||||||
|
"viewBackgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||||
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
|
NVD_API_KEY=
|
||||||
|
|||||||
21
backend/helpers/auditLog.js
Normal file
21
backend/helpers/auditLog.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Audit Log Helper
|
||||||
|
// Fire-and-forget insert - never blocks the response
|
||||||
|
|
||||||
|
function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) {
|
||||||
|
const detailsStr = details && typeof details === 'object'
|
||||||
|
? JSON.stringify(details)
|
||||||
|
: details || null;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Audit log error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logAudit;
|
||||||
93
backend/helpers/excelProcessor.js
Normal file
93
backend/helpers/excelProcessor.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
|
||||||
|
* @param {string} inputPath - Path to original Excel file
|
||||||
|
* @param {string} outputPath - Path for processed Excel file
|
||||||
|
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
|
||||||
|
*/
|
||||||
|
function processVulnerabilityReport(inputPath, outputPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
|
||||||
|
|
||||||
|
// Verify script exists
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
return reject(new Error(`Python script not found: ${scriptPath}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify input file exists
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
return reject(new Error(`Input file not found: ${inputPath}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
// 30 second timeout
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
python.kill();
|
||||||
|
reject(new Error('Processing timed out. File may be too large or corrupted.'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
python.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
python.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
python.on('close', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (timedOut) return;
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
// Parse Python error messages
|
||||||
|
if (stderr.includes('Sheet') && stderr.includes('not found')) {
|
||||||
|
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
|
||||||
|
}
|
||||||
|
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
|
||||||
|
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
|
||||||
|
}
|
||||||
|
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output for row counts
|
||||||
|
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
|
||||||
|
const newMatch = stdout.match(/New rows:\s*(\d+)/);
|
||||||
|
|
||||||
|
if (!originalMatch || !newMatch) {
|
||||||
|
return reject(new Error('Failed to parse row counts from Python output'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify output file was created
|
||||||
|
if (!fs.existsSync(outputPath)) {
|
||||||
|
return reject(new Error('Processed file was not created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
original_rows: parseInt(originalMatch[1]),
|
||||||
|
processed_rows: parseInt(newMatch[1]),
|
||||||
|
output_path: outputPath
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
python.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
reject(new Error('Python 3 is required but not found. Please install Python.'));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { processVulnerabilityReport };
|
||||||
96
backend/migrate-audit-log.js
Normal file
96
backend/migrate-audit-log.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Migration script: Add audit_logs table
|
||||||
|
// Run: node migrate-audit-log.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const DB_FILE = './cve_database.db';
|
||||||
|
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
||||||
|
|
||||||
|
function run(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ CVE Database Migration: Add Audit Logs ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||||
|
|
||||||
|
if (!fs.existsSync(DB_FILE)) {
|
||||||
|
console.log('❌ Database not found. Run setup.js for fresh install.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup database
|
||||||
|
console.log('📦 Creating backup...');
|
||||||
|
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
||||||
|
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_FILE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if table already exists
|
||||||
|
const exists = await get(db,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log('⏭️ audit_logs table already exists, nothing to do.');
|
||||||
|
} else {
|
||||||
|
console.log('1️⃣ Creating audit_logs table...');
|
||||||
|
await run(db, `
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id VARCHAR(100),
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log(' ✓ Table created');
|
||||||
|
|
||||||
|
console.log('2️⃣ Creating indexes...');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)');
|
||||||
|
console.log(' ✓ Indexes created');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ MIGRATION COMPLETE! ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
|
console.log('\n📋 Summary:');
|
||||||
|
console.log(' ✓ audit_logs table ready');
|
||||||
|
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
||||||
|
console.log('\n🚀 Restart your server to apply changes.\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration failed:', error.message);
|
||||||
|
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
289
backend/migrate-to-1.1.js
Executable file
289
backend/migrate-to-1.1.js
Executable file
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Migration script: v1.0.0 -> v1.1.0
|
||||||
|
// Adds: users, sessions tables, multi-vendor support, vendor column in documents
|
||||||
|
// Run: node migrate-to-1.1.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DB_FILE = './cve_database.db';
|
||||||
|
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ CVE Database Migration: v1.0.0 → v1.1.0 ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
if (!fs.existsSync(DB_FILE)) {
|
||||||
|
console.log('❌ Database not found. Run setup.js for fresh install.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup database
|
||||||
|
console.log('📦 Creating backup...');
|
||||||
|
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
||||||
|
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_FILE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run migrations in sequence
|
||||||
|
await addUsersTable(db);
|
||||||
|
await addSessionsTable(db);
|
||||||
|
await addVendorToDocuments(db);
|
||||||
|
await updateCvesConstraint(db);
|
||||||
|
await createDefaultAdmin(db);
|
||||||
|
await updateView(db);
|
||||||
|
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ MIGRATION COMPLETE! ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
|
console.log('\n📋 Summary:');
|
||||||
|
console.log(' ✓ Users table added');
|
||||||
|
console.log(' ✓ Sessions table added');
|
||||||
|
console.log(' ✓ Vendor column added to documents');
|
||||||
|
console.log(' ✓ Multi-vendor constraint applied to cves');
|
||||||
|
console.log(' ✓ Default admin user created (admin/admin123)');
|
||||||
|
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
||||||
|
console.log('\n🚀 Restart your server to apply changes.\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration failed:', error.message);
|
||||||
|
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUsersTable(db) {
|
||||||
|
console.log('1️⃣ Adding users table...');
|
||||||
|
|
||||||
|
const exists = await get(db,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(' ⏭️ Users table already exists, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(db, `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
CHECK (role IN ('admin', 'editor', 'viewer'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
|
||||||
|
console.log(' ✓ Users table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSessionsTable(db) {
|
||||||
|
console.log('2️⃣ Adding sessions table...');
|
||||||
|
|
||||||
|
const exists = await get(db,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(' ⏭️ Sessions table already exists, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(db, `
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
|
||||||
|
console.log(' ✓ Sessions table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVendorToDocuments(db) {
|
||||||
|
console.log('3️⃣ Adding vendor column to documents...');
|
||||||
|
|
||||||
|
// Check if vendor column exists
|
||||||
|
const columns = await all(db, "PRAGMA table_info(documents)");
|
||||||
|
const hasVendor = columns.some(col => col.name === 'vendor');
|
||||||
|
|
||||||
|
if (hasVendor) {
|
||||||
|
console.log(' ⏭️ Vendor column already exists, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add vendor column
|
||||||
|
await run(db, "ALTER TABLE documents ADD COLUMN vendor VARCHAR(100)");
|
||||||
|
|
||||||
|
// Populate vendor from the cves table based on cve_id
|
||||||
|
await run(db, `
|
||||||
|
UPDATE documents
|
||||||
|
SET vendor = (
|
||||||
|
SELECT c.vendor
|
||||||
|
FROM cves c
|
||||||
|
WHERE c.cve_id = documents.cve_id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE vendor IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Set default for any remaining nulls
|
||||||
|
await run(db, "UPDATE documents SET vendor = 'Unknown' WHERE vendor IS NULL");
|
||||||
|
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor)');
|
||||||
|
console.log(' ✓ Vendor column added and populated');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCvesConstraint(db) {
|
||||||
|
console.log('4️⃣ Updating CVEs table for multi-vendor support...');
|
||||||
|
|
||||||
|
// Check current schema
|
||||||
|
const tableInfo = await get(db,
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='cves'"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableInfo.sql.includes('UNIQUE(cve_id, vendor)')) {
|
||||||
|
console.log(' ⏭️ Multi-vendor constraint already exists, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite doesn't support ALTER CONSTRAINT, so we need to rebuild the table
|
||||||
|
console.log(' 📋 Rebuilding table with new constraint...');
|
||||||
|
|
||||||
|
// Create new table with correct schema
|
||||||
|
await run(db, `
|
||||||
|
CREATE TABLE cves_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cve_id VARCHAR(20) NOT NULL,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
published_date DATE,
|
||||||
|
status VARCHAR(50) DEFAULT 'Open',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(cve_id, vendor)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Copy data
|
||||||
|
await run(db, `
|
||||||
|
INSERT INTO cves_new (id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at)
|
||||||
|
SELECT id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at
|
||||||
|
FROM cves
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop old table
|
||||||
|
await run(db, 'DROP TABLE cves');
|
||||||
|
|
||||||
|
// Rename new table
|
||||||
|
await run(db, 'ALTER TABLE cves_new RENAME TO cves');
|
||||||
|
|
||||||
|
// Recreate indexes
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity)');
|
||||||
|
await run(db, 'CREATE INDEX IF NOT EXISTS idx_status ON cves(status)');
|
||||||
|
|
||||||
|
console.log(' ✓ Multi-vendor constraint applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDefaultAdmin(db) {
|
||||||
|
console.log('5️⃣ Creating default admin user...');
|
||||||
|
|
||||||
|
const exists = await get(db, "SELECT id FROM users WHERE username = 'admin'");
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(' ⏭️ Admin user already exists, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||||
|
|
||||||
|
await run(db, `
|
||||||
|
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, ['admin', 'admin@localhost', passwordHash, 'admin', 1]);
|
||||||
|
|
||||||
|
console.log(' ✓ Admin user created (admin/admin123)');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateView(db) {
|
||||||
|
console.log('6️⃣ Updating document status view...');
|
||||||
|
|
||||||
|
// Drop old view if exists
|
||||||
|
await run(db, 'DROP VIEW IF EXISTS cve_document_status');
|
||||||
|
|
||||||
|
// Create updated view with multi-vendor support
|
||||||
|
await run(db, `
|
||||||
|
CREATE VIEW cve_document_status AS
|
||||||
|
SELECT
|
||||||
|
c.id as record_id,
|
||||||
|
c.cve_id,
|
||||||
|
c.vendor,
|
||||||
|
c.severity,
|
||||||
|
c.status,
|
||||||
|
COUNT(DISTINCT d.id) as total_documents,
|
||||||
|
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
||||||
|
THEN 'Complete'
|
||||||
|
ELSE 'Missing Required Docs'
|
||||||
|
END as compliance_status
|
||||||
|
FROM cves c
|
||||||
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||||
|
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' ✓ View updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate();
|
||||||
39
backend/migrate_jira_tickets.js
Normal file
39
backend/migrate_jira_tickets.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Migration: Add jira_tickets table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting JIRA tickets migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Create jira_tickets table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
ticket_key TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ jira_tickets table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
||||||
|
|
||||||
|
console.log('✓ Indexes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
70
backend/migrations/add_knowledge_base_table.js
Normal file
70
backend/migrations/add_knowledge_base_table.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Migration: Add knowledge_base table for storing documentation and policies
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Running migration: add_knowledge_base_table');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
file_name VARCHAR(255),
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
file_size INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating knowledge_base table:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created knowledge_base table');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug
|
||||||
|
ON knowledge_base(slug)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating slug index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category
|
||||||
|
ON knowledge_base(category)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating category index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on category');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at
|
||||||
|
ON knowledge_base(created_at DESC)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating created_at index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on created_at');
|
||||||
|
console.log('\nMigration completed successfully!');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
backend/migrations/add_weekly_reports_table.js
Normal file
59
backend/migrations/add_weekly_reports_table.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Migration: Add weekly_reports table for vulnerability report uploads
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Running migration: add_weekly_reports_table');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
upload_date DATE NOT NULL,
|
||||||
|
week_label VARCHAR(50),
|
||||||
|
original_filename VARCHAR(255),
|
||||||
|
processed_filename VARCHAR(255),
|
||||||
|
original_file_path VARCHAR(500),
|
||||||
|
processed_file_path VARCHAR(500),
|
||||||
|
row_count_original INTEGER,
|
||||||
|
row_count_processed INTEGER,
|
||||||
|
uploaded_by INTEGER,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_current BOOLEAN DEFAULT 0,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating weekly_reports table:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created weekly_reports table');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
|
||||||
|
ON weekly_reports(upload_date DESC)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating date index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on upload_date');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
|
||||||
|
ON weekly_reports(is_current)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating current index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on is_current');
|
||||||
|
console.log('\nMigration completed successfully!');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
114
backend/routes/auditLog.js
Normal file
114
backend/routes/auditLog.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Audit Log Routes (Admin only)
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function createAuditLogRouter(db, requireAuth, requireRole) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require admin role
|
||||||
|
router.use(requireAuth(db), requireRole('admin'));
|
||||||
|
|
||||||
|
// Get paginated audit logs with filters
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
user,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const offset = (Math.max(1, parseInt(page)) - 1) * parseInt(limit);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(limit)));
|
||||||
|
|
||||||
|
let where = [];
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
where.push('username LIKE ?');
|
||||||
|
params.push(`%${user}%`);
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
where.push('action = ?');
|
||||||
|
params.push(action);
|
||||||
|
}
|
||||||
|
if (entityType) {
|
||||||
|
where.push('entity_type = ?');
|
||||||
|
params.push(entityType);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
where.push('created_at >= ?');
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
where.push('created_at <= ?');
|
||||||
|
params.push(endDate + ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get total count
|
||||||
|
const countRow = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
|
||||||
|
params,
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, pageSize, offset],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logs: rows,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: pageSize,
|
||||||
|
total: countRow.total,
|
||||||
|
totalPages: Math.ceil(countRow.total / pageSize)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit log query error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch audit logs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get distinct action types for filter dropdown
|
||||||
|
router.get('/actions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT DISTINCT action FROM audit_logs ORDER BY action',
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(rows.map(r => r.action));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit log actions error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch actions' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createAuditLogRouter;
|
||||||
@@ -3,7 +3,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
function createAuthRouter(db) {
|
function createAuthRouter(db, logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
@@ -28,16 +28,43 @@ function createAuthRouter(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: null,
|
||||||
|
username: username,
|
||||||
|
action: 'login_failed',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: { reason: 'user_not_found' },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: user.id,
|
||||||
|
username: username,
|
||||||
|
action: 'login_failed',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: { reason: 'account_disabled' },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
return res.status(401).json({ error: 'Account is disabled' });
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: user.id,
|
||||||
|
username: username,
|
||||||
|
action: 'login_failed',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: { reason: 'invalid_password' },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +104,16 @@ function createAuthRouter(db) {
|
|||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
action: 'login',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: { role: user.role },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
user: {
|
user: {
|
||||||
@@ -97,6 +134,17 @@ function createAuthRouter(db) {
|
|||||||
const sessionId = req.cookies?.session_id;
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
|
// Look up user before deleting session
|
||||||
|
const session = await new Promise((resolve) => {
|
||||||
|
db.get(
|
||||||
|
`SELECT u.id as user_id, u.username FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.session_id = ?`,
|
||||||
|
[sessionId],
|
||||||
|
(err, row) => resolve(row || null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Delete session from database
|
// Delete session from database
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
db.run(
|
db.run(
|
||||||
@@ -105,6 +153,18 @@ function createAuthRouter(db) {
|
|||||||
() => resolve()
|
() => resolve()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: session.user_id,
|
||||||
|
username: session.username,
|
||||||
|
action: 'logout',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: null,
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cookie
|
// Clear cookie
|
||||||
@@ -159,8 +219,13 @@ function createAuthRouter(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up expired sessions (can be called periodically)
|
// Clean up expired sessions (admin only)
|
||||||
router.post('/cleanup-sessions', async (req, res) => {
|
router.post('/cleanup-sessions', async (req, res) => {
|
||||||
|
// Basic auth check - require a valid session to call this
|
||||||
|
const sessionId = req.cookies?.session_id;
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
352
backend/routes/knowledgeBase.js
Normal file
352
backend/routes/knowledgeBase.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
function createKnowledgeBaseRouter(db, upload) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper to sanitize filename
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate slug from title
|
||||||
|
function generateSlug(title) {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to validate file type
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.md', '.txt', '.doc', '.docx',
|
||||||
|
'.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.html', '.htm', '.json', '.yaml', '.yml',
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidFileType(filename) {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/knowledge-base/upload - Upload new document
|
||||||
|
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[KB Upload] Multer error:', err);
|
||||||
|
return res.status(400).json({ error: err.message || 'File upload failed' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
console.log('[KB Upload] Request received:', {
|
||||||
|
hasFile: !!req.file,
|
||||||
|
body: req.body,
|
||||||
|
contentType: req.headers['content-type']
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFile = req.file;
|
||||||
|
const { title, description, category } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
console.error('[KB Upload] Error: Title is missing');
|
||||||
|
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!isValidFileType(uploadedFile.originalname)) {
|
||||||
|
fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'File type not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||||
|
const slug = generateSlug(title);
|
||||||
|
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(kbDir)) {
|
||||||
|
fs.mkdirSync(kbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${timestamp}_${sanitizedName}`;
|
||||||
|
const filePath = path.join(kbDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move uploaded file to permanent location
|
||||||
|
fs.renameSync(uploadedFile.path, filePath);
|
||||||
|
|
||||||
|
// Check if slug already exists
|
||||||
|
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.error('Error checking slug:', err);
|
||||||
|
return res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If slug exists, append timestamp to make it unique
|
||||||
|
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||||
|
|
||||||
|
// Insert new knowledge base entry
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO knowledge_base (
|
||||||
|
title, slug, description, category, file_path, file_name,
|
||||||
|
file_type, file_size, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
insertSql,
|
||||||
|
[
|
||||||
|
title.trim(),
|
||||||
|
finalSlug,
|
||||||
|
description || null,
|
||||||
|
category || 'General',
|
||||||
|
filePath,
|
||||||
|
sanitizedName,
|
||||||
|
uploadedFile.mimetype,
|
||||||
|
uploadedFile.size,
|
||||||
|
req.user.id
|
||||||
|
],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.error('Error inserting knowledge base entry:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'CREATE_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
this.lastID,
|
||||||
|
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: this.lastID,
|
||||||
|
title: title.trim(),
|
||||||
|
slug: finalSlug,
|
||||||
|
category: category || 'General'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up file on error
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
console.error('Error uploading knowledge base document:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base - List all articles
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
ORDER BY kb.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching knowledge base articles:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id - Get single article details
|
||||||
|
router.get('/:id', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
WHERE kb.id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id/content - Get document content for display
|
||||||
|
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'VIEW_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ filename: row.file_name }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine content type for inline display
|
||||||
|
let contentType = row.file_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// For markdown files, send as plain text so frontend can parse it
|
||||||
|
if (row.file_name.endsWith('.md')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
} else if (row.file_name.endsWith('.txt')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
// Use inline instead of attachment to allow browser to display
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||||
|
// Allow iframe embedding from frontend origin
|
||||||
|
res.removeHeader('X-Frame-Options');
|
||||||
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id/download - Download document
|
||||||
|
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DOWNLOAD_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ filename: row.file_name }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/knowledge-base/:id - Delete article
|
||||||
|
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article for deletion:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
if (fs.existsSync(row.file_path)) {
|
||||||
|
fs.unlinkSync(row.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DELETE_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ title: row.title }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createKnowledgeBaseRouter;
|
||||||
94
backend/routes/nvdLookup.js
Normal file
94
backend/routes/nvdLookup.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// NVD CVE Lookup Routes
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
|
||||||
|
function createNvdLookupRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// Lookup CVE details from NVD API 2.0
|
||||||
|
router.get('/lookup/:cveId', async (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(cveId)}`;
|
||||||
|
const headers = {};
|
||||||
|
if (process.env.NVD_API_KEY) {
|
||||||
|
headers['apiKey'] = process.env.NVD_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return res.status(429).json({ error: 'NVD API rate limit exceeded. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(502).json({ error: `NVD API returned status ${response.status}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vuln = data.vulnerabilities[0].cve;
|
||||||
|
|
||||||
|
// Extract English description
|
||||||
|
const descriptionEntry = vuln.descriptions?.find(d => d.lang === 'en');
|
||||||
|
const description = descriptionEntry ? descriptionEntry.value : '';
|
||||||
|
|
||||||
|
// Extract severity with cascade: CVSS v3.1 → v3.0 → v2.0
|
||||||
|
let severity = null;
|
||||||
|
const metrics = vuln.metrics || {};
|
||||||
|
|
||||||
|
if (metrics.cvssMetricV31 && metrics.cvssMetricV31.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV31[0].cvssData?.baseSeverity;
|
||||||
|
} else if (metrics.cvssMetricV30 && metrics.cvssMetricV30.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV30[0].cvssData?.baseSeverity;
|
||||||
|
} else if (metrics.cvssMetricV2 && metrics.cvssMetricV2.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV2[0].baseSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map NVD severity strings to app levels
|
||||||
|
const severityMap = {
|
||||||
|
'CRITICAL': 'Critical',
|
||||||
|
'HIGH': 'High',
|
||||||
|
'MEDIUM': 'Medium',
|
||||||
|
'LOW': 'Low'
|
||||||
|
};
|
||||||
|
severity = severity ? (severityMap[severity.toUpperCase()] || 'Medium') : 'Medium';
|
||||||
|
|
||||||
|
// Extract published date (YYYY-MM-DD)
|
||||||
|
const publishedRaw = vuln.published;
|
||||||
|
const published_date = publishedRaw ? publishedRaw.split('T')[0] : '';
|
||||||
|
|
||||||
|
res.json({ description, severity, published_date });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||||
|
return res.status(504).json({ error: 'NVD API request timed out.' });
|
||||||
|
}
|
||||||
|
console.error('NVD lookup error:', err);
|
||||||
|
res.status(502).json({ error: 'Failed to reach NVD API.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createNvdLookupRouter;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
function createUsersRouter(db, requireAuth, requireRole) {
|
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// All routes require admin role
|
// All routes require admin role
|
||||||
@@ -81,6 +81,16 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'user_create',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: String(result.id),
|
||||||
|
details: { created_username: username, role: role || 'viewer' },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'User created successfully',
|
message: 'User created successfully',
|
||||||
user: {
|
user: {
|
||||||
@@ -160,6 +170,23 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updatedFields = {};
|
||||||
|
if (username) updatedFields.username = username;
|
||||||
|
if (email) updatedFields.email = email;
|
||||||
|
if (role) updatedFields.role = role;
|
||||||
|
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||||
|
if (password) updatedFields.password_changed = true;
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'user_update',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: String(userId),
|
||||||
|
details: updatedFields,
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
// If user was deactivated, delete their sessions
|
// If user was deactivated, delete their sessions
|
||||||
if (is_active === false) {
|
if (is_active === false) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -187,6 +214,14 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Look up the user before deleting
|
||||||
|
const targetUser = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Delete sessions first (foreign key)
|
// Delete sessions first (foreign key)
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
||||||
@@ -204,6 +239,16 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'user_delete',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: String(userId),
|
||||||
|
details: { deleted_username: targetUser ? targetUser.username : 'unknown' },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ message: 'User deleted successfully' });
|
res.json({ message: 'User deleted successfully' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete user error:', err);
|
console.error('Delete user error:', err);
|
||||||
|
|||||||
261
backend/routes/weeklyReports.js
Normal file
261
backend/routes/weeklyReports.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
const { processVulnerabilityReport } = require('../helpers/excelProcessor');
|
||||||
|
|
||||||
|
function createWeeklyReportsRouter(db, upload) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper to sanitize filename
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate week label
|
||||||
|
function getWeekLabel(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const uploadDate = new Date(date);
|
||||||
|
const daysDiff = Math.floor((now - uploadDate) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff < 7) {
|
||||||
|
return "This week's report";
|
||||||
|
} else if (daysDiff < 14) {
|
||||||
|
return "Last week's report";
|
||||||
|
} else {
|
||||||
|
const month = uploadDate.getMonth() + 1;
|
||||||
|
const day = uploadDate.getDate();
|
||||||
|
const year = uploadDate.getFullYear();
|
||||||
|
return `Week of ${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/weekly-reports/upload - Upload and process vulnerability report
|
||||||
|
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => {
|
||||||
|
const uploadedFile = req.file;
|
||||||
|
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
const ext = path.extname(uploadedFile.originalname).toLowerCase();
|
||||||
|
if (ext !== '.xlsx') {
|
||||||
|
fs.unlinkSync(uploadedFile.path); // Clean up temp file
|
||||||
|
return res.status(400).json({ error: 'Only .xlsx files are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||||
|
const reportsDir = path.join(__dirname, '..', 'uploads', 'weekly_reports');
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(reportsDir)) {
|
||||||
|
fs.mkdirSync(reportsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFilename = `${timestamp}_original_${sanitizedName}`;
|
||||||
|
const processedFilename = `${timestamp}_processed_${sanitizedName}`;
|
||||||
|
const originalPath = path.join(reportsDir, originalFilename);
|
||||||
|
const processedPath = path.join(reportsDir, processedFilename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move uploaded file to permanent location
|
||||||
|
fs.renameSync(uploadedFile.path, originalPath);
|
||||||
|
|
||||||
|
// Process the file with Python script
|
||||||
|
const result = await processVulnerabilityReport(originalPath, processedPath);
|
||||||
|
|
||||||
|
const uploadDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Update previous current reports to not current
|
||||||
|
db.run('UPDATE weekly_reports SET is_current = 0 WHERE is_current = 1', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error updating previous current reports:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert new report record
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO weekly_reports (
|
||||||
|
upload_date, week_label, original_filename, processed_filename,
|
||||||
|
original_file_path, processed_file_path, row_count_original,
|
||||||
|
row_count_processed, uploaded_by, is_current
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const weekLabel = getWeekLabel(uploadDate);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
insertSql,
|
||||||
|
[
|
||||||
|
uploadDate,
|
||||||
|
weekLabel,
|
||||||
|
sanitizedName,
|
||||||
|
processedFilename,
|
||||||
|
originalPath,
|
||||||
|
processedPath,
|
||||||
|
result.original_rows,
|
||||||
|
result.processed_rows,
|
||||||
|
req.user.id
|
||||||
|
],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error inserting weekly report:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to save report metadata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'UPLOAD_WEEKLY_REPORT',
|
||||||
|
'weekly_reports',
|
||||||
|
this.lastID,
|
||||||
|
JSON.stringify({ filename: sanitizedName, rows: result.processed_rows }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: this.lastID,
|
||||||
|
original_rows: result.original_rows,
|
||||||
|
processed_rows: result.processed_rows,
|
||||||
|
week_label: weekLabel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up files on error
|
||||||
|
if (fs.existsSync(originalPath)) fs.unlinkSync(originalPath);
|
||||||
|
if (fs.existsSync(processedPath)) fs.unlinkSync(processedPath);
|
||||||
|
|
||||||
|
console.error('Error processing vulnerability report:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to process report' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/weekly-reports - List all reports
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const sql = `
|
||||||
|
SELECT id, upload_date, week_label, original_filename, processed_filename,
|
||||||
|
row_count_original, row_count_processed, is_current, uploaded_at
|
||||||
|
FROM weekly_reports
|
||||||
|
ORDER BY upload_date DESC, uploaded_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching weekly reports:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch reports' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/weekly-reports/:id/download/:type - Download report file
|
||||||
|
router.get('/:id/download/:type', requireAuth(db), (req, res) => {
|
||||||
|
const { id, type } = req.params;
|
||||||
|
|
||||||
|
if (type !== 'original' && type !== 'processed') {
|
||||||
|
return res.status(400).json({ error: 'Invalid download type. Use "original" or "processed"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `SELECT original_file_path, processed_file_path, original_filename FROM weekly_reports WHERE id = ?`;
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching report:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch report' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Report not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = type === 'original' ? row.original_file_path : row.processed_file_path;
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DOWNLOAD_WEEKLY_REPORT',
|
||||||
|
'weekly_reports',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ type }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadName = type === 'original' ? row.original_filename : row.original_filename.replace('.xlsx', '_processed.xlsx');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/weekly-reports/:id - Delete report (admin only)
|
||||||
|
router.delete('/:id', requireAuth(db), requireRole(db, 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT original_file_path, processed_file_path FROM weekly_reports WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching report for deletion:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch report' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Report not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
db.run('DELETE FROM weekly_reports WHERE id = ?', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error deleting report:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete report' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete files
|
||||||
|
if (fs.existsSync(row.original_file_path)) {
|
||||||
|
fs.unlinkSync(row.original_file_path);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(row.processed_file_path)) {
|
||||||
|
fs.unlinkSync(row.processed_file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DELETE_WEEKLY_REPORT',
|
||||||
|
'weekly_reports',
|
||||||
|
id,
|
||||||
|
null,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createWeeklyReportsRouter;
|
||||||
2
backend/scripts/requirements.txt
Normal file
2
backend/scripts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pandas>=2.0.0
|
||||||
|
openpyxl>=3.0.0
|
||||||
83
backend/scripts/split_cve_report.py
Executable file
83
backend/scripts/split_cve_report.py
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
CVE Report Splitter
|
||||||
|
Splits multiple CVE IDs in a single row into separate rows for easier filtering and analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def split_cve_report(input_file, output_file=None, sheet_name='Vulnerabilities', cve_column='CVE ID'):
|
||||||
|
"""
|
||||||
|
Split CVE IDs into separate rows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Path to input Excel file
|
||||||
|
output_file: Path to output file (default: adds '_Split' to input filename)
|
||||||
|
sheet_name: Name of sheet with vulnerability data (default: 'Vulnerabilities')
|
||||||
|
cve_column: Name of column containing CVE IDs (default: 'CVE ID')
|
||||||
|
"""
|
||||||
|
input_path = Path(input_file)
|
||||||
|
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"Error: File not found: {input_file}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if output_file is None:
|
||||||
|
output_file = input_path.parent / f"{input_path.stem}_Split{input_path.suffix}"
|
||||||
|
|
||||||
|
print(f"Reading: {input_file}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(input_file, sheet_name=sheet_name)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error: Sheet '{sheet_name}' not found in workbook")
|
||||||
|
print(f"Available sheets: {pd.ExcelFile(input_file).sheet_names}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if cve_column not in df.columns:
|
||||||
|
print(f"Error: Column '{cve_column}' not found")
|
||||||
|
print(f"Available columns: {list(df.columns)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
original_rows = len(df)
|
||||||
|
print(f"Original rows: {original_rows}")
|
||||||
|
|
||||||
|
# Split CVE IDs by comma
|
||||||
|
df[cve_column] = df[cve_column].astype(str).str.split(',')
|
||||||
|
|
||||||
|
# Explode to create separate rows
|
||||||
|
df_exploded = df.explode(cve_column)
|
||||||
|
|
||||||
|
# Clean up CVE IDs
|
||||||
|
df_exploded[cve_column] = df_exploded[cve_column].str.strip()
|
||||||
|
df_exploded = df_exploded[df_exploded[cve_column].notna()]
|
||||||
|
df_exploded = df_exploded[df_exploded[cve_column] != 'nan']
|
||||||
|
df_exploded = df_exploded[df_exploded[cve_column] != '']
|
||||||
|
|
||||||
|
# Reset index
|
||||||
|
df_exploded = df_exploded.reset_index(drop=True)
|
||||||
|
|
||||||
|
new_rows = len(df_exploded)
|
||||||
|
print(f"New rows: {new_rows}")
|
||||||
|
print(f"Added {new_rows - original_rows} rows from splitting CVEs")
|
||||||
|
|
||||||
|
# Save output
|
||||||
|
df_exploded.to_excel(output_file, index=False, sheet_name=sheet_name)
|
||||||
|
print(f"\n✓ Success! Saved to: {output_file}")
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 split_cve_report.py <input_file.xlsx> [output_file.xlsx]")
|
||||||
|
print("\nExample:")
|
||||||
|
print(" python3 split_cve_report.py 'Vulnerability Workbook.xlsx'")
|
||||||
|
print(" python3 split_cve_report.py 'input.xlsx' 'output.xlsx'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_file = sys.argv[1]
|
||||||
|
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
split_cve_report(input_file, output_file)
|
||||||
@@ -15,6 +15,11 @@ const fs = require('fs');
|
|||||||
const { requireAuth, requireRole } = require('./middleware/auth');
|
const { requireAuth, requireRole } = require('./middleware/auth');
|
||||||
const createAuthRouter = require('./routes/auth');
|
const createAuthRouter = require('./routes/auth');
|
||||||
const createUsersRouter = require('./routes/users');
|
const createUsersRouter = require('./routes/users');
|
||||||
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
|
const logAudit = require('./helpers/auditLog');
|
||||||
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
|
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
||||||
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -24,20 +29,95 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|||||||
? process.env.CORS_ORIGINS.split(',')
|
? process.env.CORS_ORIGINS.split(',')
|
||||||
: ['http://localhost:3000'];
|
: ['http://localhost:3000'];
|
||||||
|
|
||||||
|
// ========== SECURITY HELPERS ==========
|
||||||
|
|
||||||
|
// Allowed file extensions for document uploads (documents only, no executables)
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||||
|
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
||||||
|
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.odt', '.ods', '.odp',
|
||||||
|
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||||
|
'.zip', '.gz', '.tar', '.7z'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Allowed MIME type prefixes
|
||||||
|
const ALLOWED_MIME_PREFIXES = [
|
||||||
|
'image/', 'text/', 'application/pdf',
|
||||||
|
'application/msword', 'application/vnd.openxmlformats',
|
||||||
|
'application/vnd.ms-', 'application/vnd.oasis.opendocument',
|
||||||
|
'application/rtf', 'application/json', 'application/xml',
|
||||||
|
'application/vnd.ms-outlook', 'message/rfc822',
|
||||||
|
'application/zip', 'application/gzip', 'application/x-7z',
|
||||||
|
'application/x-tar', 'application/octet-stream'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sanitize a single path segment (cveId, vendor, filename) to prevent traversal
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
// Remove path separators, null bytes, and .. sequences
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that a resolved path is within the uploads directory
|
||||||
|
function isPathWithinUploads(targetPath) {
|
||||||
|
const uploadsRoot = path.resolve('uploads');
|
||||||
|
const resolved = path.resolve(targetPath);
|
||||||
|
return resolved.startsWith(uploadsRoot + path.sep) || resolved === uploadsRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CVE ID format
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
function isValidCveId(cveId) {
|
||||||
|
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed enum values
|
||||||
|
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
||||||
|
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
||||||
|
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
||||||
|
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
|
// Validate vendor name - printable chars, reasonable length
|
||||||
|
function isValidVendor(vendor) {
|
||||||
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
// Log all incoming requests
|
// Log all incoming requests
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
||||||
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: CORS_ORIGINS,
|
origin: CORS_ORIGINS,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
// Only parse JSON for requests with application/json content type
|
||||||
|
app.use(express.json({
|
||||||
|
limit: '1mb',
|
||||||
|
type: 'application/json'
|
||||||
|
}));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', express.static('uploads'));
|
app.use('/uploads', express.static('uploads', {
|
||||||
|
dotfiles: 'deny',
|
||||||
|
index: false
|
||||||
|
}));
|
||||||
|
|
||||||
// Database connection
|
// Database connection
|
||||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||||
@@ -46,10 +126,16 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Auth routes (public)
|
// Auth routes (public)
|
||||||
app.use('/api/auth', createAuthRouter(db));
|
app.use('/api/auth', createAuthRouter(db, logAudit));
|
||||||
|
|
||||||
// User management routes (admin only)
|
// User management routes (admin only)
|
||||||
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole));
|
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit));
|
||||||
|
|
||||||
|
// Audit log routes (admin only)
|
||||||
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||||
|
|
||||||
|
// NVD lookup routes (authenticated users)
|
||||||
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||||
|
|
||||||
// Simple storage - upload to temp directory first
|
// Simple storage - upload to temp directory first
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -62,15 +148,37 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
cb(null, `${timestamp}-${file.originalname}`);
|
// Sanitize original filename - strip path components and dangerous chars
|
||||||
|
const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
cb(null, `${timestamp}-${safeName}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// File filter - reject executables and non-allowed types
|
||||||
|
function fileFilter(req, file, cb) {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||||
|
return cb(new Error(`File type '${ext}' is not allowed. Allowed types: ${[...ALLOWED_EXTENSIONS].join(', ')}`));
|
||||||
|
}
|
||||||
|
const mimeAllowed = ALLOWED_MIME_PREFIXES.some(prefix => file.mimetype.startsWith(prefix));
|
||||||
|
if (!mimeAllowed) {
|
||||||
|
return cb(new Error(`MIME type '${file.mimetype}' is not allowed.`));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
|
fileFilter: fileFilter,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
||||||
|
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
||||||
|
|
||||||
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
@@ -78,15 +186,9 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
|||||||
const { search, vendor, severity, status } = req.query;
|
const { search, vendor, severity, status } = req.query;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT c.*,
|
SELECT c.*, COUNT(d.id) as document_count
|
||||||
COUNT(d.id) as document_count,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) > 0
|
|
||||||
THEN 'Complete'
|
|
||||||
ELSE 'Incomplete'
|
|
||||||
END as doc_status
|
|
||||||
FROM cves c
|
FROM cves c
|
||||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -114,12 +216,20 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
|||||||
db.all(query, params, (err, rows) => {
|
db.all(query, params, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error fetching CVEs:', err);
|
console.error('Error fetching CVEs:', err);
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get distinct CVE IDs for NVD sync (authenticated users)
|
||||||
|
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||||
|
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
res.json(rows.map(r => r.cve_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
||||||
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
@@ -127,7 +237,6 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT c.*,
|
SELECT c.*,
|
||||||
COUNT(d.id) as total_documents,
|
COUNT(d.id) as total_documents,
|
||||||
COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) as has_advisory,
|
|
||||||
COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email,
|
COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email,
|
||||||
COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot
|
COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot
|
||||||
FROM cves c
|
FROM cves c
|
||||||
@@ -138,7 +247,7 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [cveId], (err, rows) => {
|
db.all(query, [cveId], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -155,14 +264,12 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
severity: row.severity,
|
severity: row.severity,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
total_documents: row.total_documents,
|
total_documents: row.total_documents,
|
||||||
compliance: {
|
doc_types: {
|
||||||
advisory: row.has_advisory > 0,
|
|
||||||
email: row.has_email > 0,
|
email: row.has_email > 0,
|
||||||
screenshot: row.has_screenshot > 0
|
screenshot: row.has_screenshot > 0
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
addressed: true,
|
addressed: true
|
||||||
has_required_docs: rows.some(row => row.has_advisory > 0)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -180,7 +287,7 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [cveId], (err, rows) => {
|
db.all(query, [cveId], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -189,32 +296,49 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
console.log('=== ADD CVE REQUEST ===');
|
|
||||||
console.log('Body:', req.body);
|
|
||||||
console.log('=======================');
|
|
||||||
|
|
||||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' });
|
||||||
|
}
|
||||||
|
if (!severity || !VALID_SEVERITIES.includes(severity)) {
|
||||||
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (!description || typeof description !== 'string' || description.length > 10000) {
|
||||||
|
return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' });
|
||||||
|
}
|
||||||
|
if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||||
|
return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' });
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log('Query:', query);
|
|
||||||
console.log('Values:', [cve_id, vendor, severity, description, published_date]);
|
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('DATABASE ERROR:', err); // Make sure this is here
|
console.error('DATABASE ERROR:', err);
|
||||||
// ... rest of error handling
|
|
||||||
// Check if it's a duplicate CVE_ID + Vendor combination
|
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Failed to create CVE entry.' });
|
||||||
}
|
}
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_create',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cve_id,
|
||||||
|
details: { vendor, severity },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
res.json({
|
res.json({
|
||||||
id: this.lastID,
|
id: this.lastID,
|
||||||
cve_id,
|
cve_id,
|
||||||
@@ -229,17 +353,340 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
|||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!status || !VALID_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
||||||
|
|
||||||
db.run(query, [
|
db.run(query, [status, cveId], function(err) {
|
||||||
vendor,status, cveId], function(err) {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_update_status',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cveId,
|
||||||
|
details: { status },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
res.json({ message: 'Status updated successfully', changes: this.changes });
|
res.json({ message: 'Status updated successfully', changes: this.changes });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk sync CVE data from NVD (editor or admin)
|
||||||
|
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { updates } = req.body;
|
||||||
|
if (!Array.isArray(updates) || updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No updates provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
const errors = [];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
updates.forEach((entry) => {
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
if (entry.description !== null && entry.description !== undefined) {
|
||||||
|
fields.push('description = ?');
|
||||||
|
values.push(entry.description);
|
||||||
|
}
|
||||||
|
if (entry.severity !== null && entry.severity !== undefined) {
|
||||||
|
fields.push('severity = ?');
|
||||||
|
values.push(entry.severity);
|
||||||
|
}
|
||||||
|
if (entry.published_date !== null && entry.published_date !== undefined) {
|
||||||
|
fields.push('published_date = ?');
|
||||||
|
values.push(entry.published_date);
|
||||||
|
}
|
||||||
|
if (fields.length === 0) {
|
||||||
|
completed++;
|
||||||
|
if (completed === updates.length) sendResponse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(entry.cve_id);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
||||||
|
values,
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('NVD sync update error:', err);
|
||||||
|
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
||||||
|
} else {
|
||||||
|
updated += this.changes;
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
if (completed === updates.length) sendResponse();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResponse() {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_nvd_sync',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: null,
|
||||||
|
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
const result = { message: 'NVD sync completed', updated };
|
||||||
|
if (errors.length > 0) result.errors = errors;
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Edit single CVE entry (editor or admin)
|
||||||
|
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||||
|
|
||||||
|
// Input validation for provided fields
|
||||||
|
if (cve_id !== undefined && !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Vendor must be under 200 characters.' });
|
||||||
|
}
|
||||||
|
if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) {
|
||||||
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) {
|
||||||
|
return res.status(400).json({ error: 'Description must be under 10000 characters.' });
|
||||||
|
}
|
||||||
|
if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||||
|
return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing row first
|
||||||
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!existing) return res.status(404).json({ error: 'CVE entry not found' });
|
||||||
|
|
||||||
|
const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status };
|
||||||
|
|
||||||
|
const newCveId = cve_id !== undefined ? cve_id : existing.cve_id;
|
||||||
|
const newVendor = vendor !== undefined ? vendor : existing.vendor;
|
||||||
|
const cveIdChanged = newCveId !== existing.cve_id;
|
||||||
|
const vendorChanged = newVendor !== existing.vendor;
|
||||||
|
|
||||||
|
const doUpdate = () => {
|
||||||
|
// Build dynamic SET clause
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
if (cve_id !== undefined) { fields.push('cve_id = ?'); values.push(cve_id); }
|
||||||
|
if (vendor !== undefined) { fields.push('vendor = ?'); values.push(vendor); }
|
||||||
|
if (severity !== undefined) { fields.push('severity = ?'); values.push(severity); }
|
||||||
|
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
||||||
|
if (published_date !== undefined) { fields.push('published_date = ?'); values.push(published_date); }
|
||||||
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||||
|
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const after = {
|
||||||
|
cve_id: newCveId, vendor: newVendor,
|
||||||
|
severity: severity !== undefined ? severity : existing.severity,
|
||||||
|
description: description !== undefined ? description : existing.description,
|
||||||
|
published_date: published_date !== undefined ? published_date : existing.published_date,
|
||||||
|
status: status !== undefined ? status : existing.status
|
||||||
|
};
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_edit',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: newCveId,
|
||||||
|
details: { before, after },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'CVE updated successfully', changes: this.changes });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cveIdChanged || vendorChanged) {
|
||||||
|
// Check UNIQUE constraint
|
||||||
|
db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => {
|
||||||
|
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
||||||
|
|
||||||
|
// Rename document directory (with path traversal prevention)
|
||||||
|
const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor));
|
||||||
|
const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor));
|
||||||
|
|
||||||
|
if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(oldDir)) {
|
||||||
|
const newParent = path.join('uploads', newCveId);
|
||||||
|
if (!fs.existsSync(newParent)) {
|
||||||
|
fs.mkdirSync(newParent, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(oldDir, newDir);
|
||||||
|
|
||||||
|
// Clean up old cve_id directory if empty
|
||||||
|
const oldParent = path.join('uploads', existing.cve_id);
|
||||||
|
if (fs.existsSync(oldParent)) {
|
||||||
|
const remaining = fs.readdirSync(oldParent);
|
||||||
|
if (remaining.length === 0) fs.rmdirSync(oldParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update documents table - file paths
|
||||||
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => {
|
||||||
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
|
||||||
|
const newPrefix = path.join('uploads', newCveId, newVendor);
|
||||||
|
|
||||||
|
let docUpdated = 0;
|
||||||
|
const totalDocs = docs.length;
|
||||||
|
|
||||||
|
const finishDocUpdate = () => {
|
||||||
|
if (docUpdated >= totalDocs) doUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalDocs === 0) {
|
||||||
|
doUpdate();
|
||||||
|
} else {
|
||||||
|
docs.forEach((doc) => {
|
||||||
|
const newFilePath = doc.file_path.replace(oldPrefix, newPrefix);
|
||||||
|
db.run('UPDATE documents SET cve_id = ?, vendor = ?, file_path = ? WHERE id = ?',
|
||||||
|
[newCveId, newVendor, newFilePath, doc.id],
|
||||||
|
(docUpdateErr) => {
|
||||||
|
if (docUpdateErr) console.error('Error updating document:', docUpdateErr);
|
||||||
|
docUpdated++;
|
||||||
|
finishDocUpdate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
doUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
||||||
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
// Get all rows for this CVE ID to know what we're deleting
|
||||||
|
db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
||||||
|
|
||||||
|
// Delete all documents from DB
|
||||||
|
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||||
|
if (docErr) console.error('Error deleting documents:', docErr);
|
||||||
|
|
||||||
|
// Delete all CVE rows
|
||||||
|
db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) {
|
||||||
|
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
// Remove upload directory (with path traversal prevention)
|
||||||
|
const safeCveId = sanitizePathSegment(cveId);
|
||||||
|
const cveDir = path.join('uploads', safeCveId);
|
||||||
|
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
||||||
|
fs.rmSync(cveDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_delete',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cveId,
|
||||||
|
details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete single CVE vendor entry (editor or admin)
|
||||||
|
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
||||||
|
|
||||||
|
// Delete associated documents from DB
|
||||||
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||||
|
if (docErr) console.error('Error fetching documents:', docErr);
|
||||||
|
|
||||||
|
// Delete document files from disk (with path traversal prevention)
|
||||||
|
if (docs && docs.length > 0) {
|
||||||
|
docs.forEach(doc => {
|
||||||
|
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
||||||
|
fs.unlinkSync(doc.file_path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete documents from DB
|
||||||
|
db.run('DELETE FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (delDocErr) => {
|
||||||
|
if (delDocErr) console.error('Error deleting documents from DB:', delDocErr);
|
||||||
|
|
||||||
|
// Delete CVE row
|
||||||
|
db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) {
|
||||||
|
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
// Clean up directories (with path traversal prevention)
|
||||||
|
const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor));
|
||||||
|
if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) {
|
||||||
|
fs.rmSync(safeVendorDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
|
||||||
|
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
|
||||||
|
const remaining = fs.readdirSync(safeCveDir);
|
||||||
|
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_delete',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cve.cve_id,
|
||||||
|
details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== DOCUMENT ENDPOINTS ==========
|
// ========== DOCUMENT ENDPOINTS ==========
|
||||||
|
|
||||||
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
||||||
@@ -259,7 +706,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, params, (err, rows) => {
|
db.all(query, params, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -269,15 +716,16 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|||||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
||||||
upload.single('file')(req, res, (err) => {
|
upload.single('file')(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('MULTER ERROR:', err);
|
console.error('Upload error:', err.message);
|
||||||
return res.status(500).json({ error: 'File upload failed: ' + err.message });
|
// Show file validation errors to the user; hide other internal errors
|
||||||
|
if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File exceeds the 10MB size limit.' });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'File upload failed.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== UPLOAD REQUEST RECEIVED ===');
|
|
||||||
console.log('CVE ID:', req.params.cveId);
|
|
||||||
console.log('Body:', req.body);
|
|
||||||
console.log('File:', req.file);
|
|
||||||
console.log('================================');
|
|
||||||
|
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
const { type, notes, vendor } = req.body;
|
const { type, notes, vendor } = req.body;
|
||||||
@@ -289,18 +737,41 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!vendor) {
|
if (!vendor) {
|
||||||
console.error('ERROR: Vendor is required');
|
// Clean up temp file
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
return res.status(400).json({ error: 'Vendor is required' });
|
return res.status(400).json({ error: 'Vendor is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate document type
|
||||||
|
if (type && !VALID_DOC_TYPES.includes(type)) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize path segments to prevent directory traversal
|
||||||
|
const safeCveId = sanitizePathSegment(cveId);
|
||||||
|
const safeVendor = sanitizePathSegment(vendor);
|
||||||
|
const safeFilename = sanitizePathSegment(file.filename);
|
||||||
|
|
||||||
|
if (!safeCveId || !safeVendor || !safeFilename) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' });
|
||||||
|
}
|
||||||
|
|
||||||
// Move file from temp to proper location
|
// Move file from temp to proper location
|
||||||
const finalDir = path.join('uploads', cveId, vendor);
|
const finalDir = path.join('uploads', safeCveId, safeVendor);
|
||||||
|
const finalPath = path.join(finalDir, safeFilename);
|
||||||
|
|
||||||
|
// Verify paths stay within uploads directory
|
||||||
|
if (!isPathWithinUploads(finalDir) || !isPathWithinUploads(finalPath)) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: 'Invalid file path.' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(finalDir)) {
|
if (!fs.existsSync(finalDir)) {
|
||||||
fs.mkdirSync(finalDir, { recursive: true });
|
fs.mkdirSync(finalDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalPath = path.join(finalDir, file.filename);
|
|
||||||
|
|
||||||
// Move file from temp to final location
|
// Move file from temp to final location
|
||||||
fs.renameSync(file.path, finalPath);
|
fs.renameSync(file.path, finalPath);
|
||||||
|
|
||||||
@@ -322,19 +793,27 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
notes
|
notes
|
||||||
], function(err) {
|
], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('DATABASE ERROR:', err);
|
console.error('Document insert error:', err);
|
||||||
// If database insert fails, delete the file
|
// If database insert fails, delete the file
|
||||||
if (fs.existsSync(finalPath)) {
|
if (fs.existsSync(finalPath)) {
|
||||||
fs.unlinkSync(finalPath);
|
fs.unlinkSync(finalPath);
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'document_upload',
|
||||||
|
entityType: 'document',
|
||||||
|
entityId: cveId,
|
||||||
|
details: { vendor, type, filename: file.originalname },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
res.json({
|
res.json({
|
||||||
id: this.lastID,
|
id: this.lastID,
|
||||||
message: 'Document uploaded successfully',
|
message: 'Document uploaded successfully',
|
||||||
file: {
|
file: {
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
path: finalPath,
|
|
||||||
size: fileSizeKB
|
size: fileSizeKB
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -348,17 +827,27 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re
|
|||||||
// First get the file path to delete the actual file
|
// First get the file path to delete the actual file
|
||||||
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row && fs.existsSync(row.file_path)) {
|
// Only delete file if path is within uploads directory
|
||||||
|
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
|
||||||
fs.unlinkSync(row.file_path);
|
fs.unlinkSync(row.file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'document_delete',
|
||||||
|
entityType: 'document',
|
||||||
|
entityId: id,
|
||||||
|
details: { file_path: row ? row.file_path : null },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
res.json({ message: 'Document deleted successfully' });
|
res.json({ message: 'Document deleted successfully' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -372,7 +861,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [], (err, rows) => {
|
db.all(query, [], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows.map(r => r.vendor));
|
res.json(rows.map(r => r.vendor));
|
||||||
});
|
});
|
||||||
@@ -394,12 +883,198 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.get(query, [], (err, row) => {
|
db.get(query, [], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(row);
|
res.json(row);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== JIRA TICKET ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Get all JIRA tickets (with optional filters)
|
||||||
|
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
||||||
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (cve_id) {
|
||||||
|
query += ' AND cve_id = ?';
|
||||||
|
params.push(cve_id);
|
||||||
|
}
|
||||||
|
if (vendor) {
|
||||||
|
query += ' AND vendor = ?';
|
||||||
|
params.push(vendor);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching JIRA tickets:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create JIRA ticket
|
||||||
|
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
|
}
|
||||||
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||||
|
}
|
||||||
|
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketStatus = status || 'Open';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating JIRA ticket:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_create',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: this.lastID.toString(),
|
||||||
|
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
message: 'JIRA ticket created successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update JIRA ticket
|
||||||
|
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||||
|
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||||
|
}
|
||||||
|
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||||
|
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||||
|
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||||
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('Error updating JIRA ticket:', updateErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_update',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: id,
|
||||||
|
details: { before: existing, changes: req.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete JIRA ticket
|
||||||
|
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||||
|
if (deleteErr) {
|
||||||
|
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_delete',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: id,
|
||||||
|
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ function initializeDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
|
-- Audit log table for tracking user actions
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id VARCHAR(100),
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
||||||
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
||||||
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
||||||
@@ -244,7 +262,7 @@ function displaySummary() {
|
|||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
console.log('\n📊 What was created:');
|
console.log('\n📊 What was created:');
|
||||||
console.log(' ✓ SQLite database (cve_database.db)');
|
console.log(' ✓ SQLite database (cve_database.db)');
|
||||||
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions');
|
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions, audit_logs');
|
||||||
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
||||||
console.log(' ✓ Vendor column in documents table');
|
console.log(' ✓ Vendor column in documents table');
|
||||||
console.log(' ✓ User authentication with session-based auth');
|
console.log(' ✓ User authentication with session-based auth');
|
||||||
|
|||||||
0
frontend/cve_database.db
Normal file
0
frontend/cve_database.db
Normal file
@@ -10,6 +10,7 @@
|
|||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,9 +25,67 @@
|
|||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
<title>CVE Dashboard</title>
|
<title>CVE Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
'mono': ['JetBrains Mono', 'monospace'],
|
||||||
|
'sans': ['Outfit', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
'intel': {
|
||||||
|
'darkest': '#0A0E27',
|
||||||
|
'dark': '#131937',
|
||||||
|
'medium': '#1E2749',
|
||||||
|
'accent': '#00D9FF',
|
||||||
|
'accent-dim': '#0099BB',
|
||||||
|
'danger': '#FF3366',
|
||||||
|
'warning': '#FFB800',
|
||||||
|
'success': '#00FF88',
|
||||||
|
'grid': '#1E2749',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'grid-pattern': 'linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px)',
|
||||||
|
},
|
||||||
|
backgroundSize: {
|
||||||
|
'grid': '20px 20px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'scan': 'scan 3s ease-in-out infinite',
|
||||||
|
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
||||||
|
'fade-in': 'fade-in 0.5s ease-out',
|
||||||
|
'slide-up': 'slide-up 0.4s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'scan': {
|
||||||
|
'0%, 100%': { transform: 'translateY(-100%)', opacity: '0' },
|
||||||
|
'50%': { transform: 'translateY(100%)', opacity: '0.3' },
|
||||||
|
},
|
||||||
|
'pulse-glow': {
|
||||||
|
'0%, 100%': { boxShadow: '0 0 5px rgba(0, 217, 255, 0.3)' },
|
||||||
|
'50%': { boxShadow: '0 0 20px rgba(0, 217, 255, 0.6)' },
|
||||||
|
},
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
'slide-up': {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-intel-darkest">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -1,38 +1,825 @@
|
|||||||
.App {
|
/* Tactical Intelligence Dashboard Styles */
|
||||||
text-align: center;
|
/* IMPORTANT: This file MUST be imported in App.js */
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: 'Outfit', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-logo {
|
/* Pulse animation for glowing dots - used by inline styles */
|
||||||
height: 40vmin;
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Base Colors - Modern Slate Foundation */
|
||||||
|
--intel-darkest: #0F172A;
|
||||||
|
--intel-dark: #1E293B;
|
||||||
|
--intel-medium: #334155;
|
||||||
|
--intel-accent: #0EA5E9; /* Sky Blue - professional cyan */
|
||||||
|
--intel-warning: #F59E0B; /* Amber - sophisticated warning */
|
||||||
|
--intel-danger: #EF4444; /* Modern Red - urgent but refined */
|
||||||
|
--intel-success: #10B981; /* Emerald - professional green */
|
||||||
|
--intel-grid: rgba(14, 165, 233, 0.08);
|
||||||
|
|
||||||
|
/* Text Colors with proper contrast */
|
||||||
|
--text-primary: #F8FAFC;
|
||||||
|
--text-secondary: #E2E8F0;
|
||||||
|
--text-tertiary: #CBD5E1;
|
||||||
|
--text-muted: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #0F172A;
|
||||||
|
color: #E2E8F0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes for Tailwind-style usage */
|
||||||
|
.bg-intel-darkest { background-color: var(--intel-darkest); }
|
||||||
|
.bg-intel-dark { background-color: var(--intel-dark); }
|
||||||
|
.bg-intel-medium { background-color: var(--intel-medium); }
|
||||||
|
.text-intel-accent { color: var(--intel-accent); }
|
||||||
|
.text-intel-warning { color: var(--intel-warning); }
|
||||||
|
.text-intel-danger { color: var(--intel-danger); }
|
||||||
|
.text-intel-success { color: var(--intel-success); }
|
||||||
|
.border-intel-accent { border-color: var(--intel-accent); }
|
||||||
|
.border-intel-warning { border-color: var(--intel-warning); }
|
||||||
|
.border-intel-danger { border-color: var(--intel-danger); }
|
||||||
|
.border-intel-grid { border-color: var(--intel-grid); }
|
||||||
|
|
||||||
|
/* Grid background effect */
|
||||||
|
.grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(14, 165, 233, 0.025) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace font for technical data */
|
||||||
|
.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing border effect */
|
||||||
|
.glow-border {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
background: linear-gradient(45deg, transparent, rgba(14, 165, 233, 0.08), transparent);
|
||||||
|
border-radius: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanning line animation */
|
||||||
|
.scan-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(14, 165, 233, 0.6),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: scan 3s ease-in-out infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@keyframes scan {
|
||||||
.App-logo {
|
0%, 100% {
|
||||||
animation: App-logo-spin infinite 20s linear;
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(2000%);
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-header {
|
/* Card hover effects with refined depth */
|
||||||
background-color: #282c34;
|
.intel-card {
|
||||||
min-height: 100vh;
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
|
||||||
display: flex;
|
border: 1.5px solid rgba(14, 165, 233, 0.3);
|
||||||
flex-direction: column;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.1),
|
||||||
|
inset 0 -1px 0 rgba(14, 165, 233, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(14, 165, 233, 0.08),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-card:hover {
|
||||||
|
border-color: rgba(14, 165, 233, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px rgba(14, 165, 233, 0.15),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||||
|
0 0 30px rgba(14, 165, 233, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-card:hover::after {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges with STRONG glow and contrast */
|
||||||
|
.status-badge {
|
||||||
|
position: relative;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 2px solid;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
font-size: calc(10px + 2vmin);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-link {
|
.status-badge::before {
|
||||||
color: #61dafb;
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes App-logo-spin {
|
.status-critical {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.15) 100%);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
color: #FCA5A5;
|
||||||
|
text-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-critical::before {
|
||||||
|
background: #EF4444;
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.6), 0 0 6px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-high {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(245, 158, 11, 0.15) 100%);
|
||||||
|
border-color: rgba(245, 158, 11, 0.6);
|
||||||
|
color: #FCD34D;
|
||||||
|
text-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-high::before {
|
||||||
|
background: #F59E0B;
|
||||||
|
box-shadow: 0 0 12px rgba(245, 158, 11, 0.6), 0 0 6px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-medium {
|
||||||
|
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2) 0%, rgba(14, 165, 233, 0.15) 100%);
|
||||||
|
border-color: rgba(14, 165, 233, 0.6);
|
||||||
|
color: #7DD3FC;
|
||||||
|
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-medium::before {
|
||||||
|
background: #0EA5E9;
|
||||||
|
box-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 6px rgba(14, 165, 233, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-low {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.15) 100%);
|
||||||
|
border-color: rgba(16, 185, 129, 0.6);
|
||||||
|
color: #6EE7B7;
|
||||||
|
text-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-low::before {
|
||||||
|
background: #10B981;
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.6), 0 0 6px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles with depth and glow */
|
||||||
|
.intel-button {
|
||||||
|
position: relative;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 1px solid;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.5s, height 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button:hover::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-primary {
|
||||||
|
background: linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%);
|
||||||
|
border-color: #0EA5E9;
|
||||||
|
color: #38BDF8;
|
||||||
|
text-shadow: 0 0 6px rgba(14, 165, 233, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-primary:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(14, 165, 233, 0.25),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-danger {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.1) 100%);
|
||||||
|
border-color: #EF4444;
|
||||||
|
color: #F87171;
|
||||||
|
text-shadow: 0 0 6px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-danger:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(239, 68, 68, 0.25),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-success {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||||
|
border-color: #10B981;
|
||||||
|
color: #34D399;
|
||||||
|
text-shadow: 0 0 6px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-button-success:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(16, 185, 129, 0.25),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields with better contrast */
|
||||||
|
.intel-input {
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||||
|
color: #F8FAFC;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.2),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0EA5E9;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(14, 165, 233, 0.15),
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.15),
|
||||||
|
0 4px 12px rgba(14, 165, 233, 0.1);
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-input::placeholder {
|
||||||
|
color: rgba(226, 232, 240, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat cards with refined depth */
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%);
|
||||||
|
border: 1.5px solid rgba(14, 165, 233, 0.35);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(14, 165, 233, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(14, 165, 233, 0.15),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||||
|
0 0 24px rgba(14, 165, 233, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #0EA5E9, transparent);
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay with proper backdrop */
|
||||||
|
.modal-overlay {
|
||||||
|
background: rgba(10, 14, 39, 0.97);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal card enhancements */
|
||||||
|
.intel-card.modal-card {
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||||
|
0 10px 30px rgba(0, 217, 255, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(14, 165, 233, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(14, 165, 233, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
.fade-in {
|
||||||
|
animation: fade-in 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pulse glow animation */
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 5px currentColor;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table styling */
|
||||||
|
.data-row {
|
||||||
|
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-row:hover {
|
||||||
|
background: rgba(0, 217, 255, 0.06);
|
||||||
|
border-bottom-color: rgba(0, 217, 255, 0.3);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 217, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vendor entry cards - high contrast and depth */
|
||||||
|
.vendor-card {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.9) 0%, rgba(30, 39, 73, 0.8) 100%);
|
||||||
|
border: 1px solid rgba(0, 217, 255, 0.25);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vendor-card:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||||
|
border-color: rgba(0, 217, 255, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 16px rgba(0, 217, 255, 0.15),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document list items with depth */
|
||||||
|
.document-item {
|
||||||
|
background: linear-gradient(135deg, rgba(10, 14, 39, 0.9) 0%, rgba(19, 25, 55, 0.8) 100%);
|
||||||
|
border: 1px solid rgba(0, 217, 255, 0.15);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-item:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(10, 14, 39, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||||
|
border-color: rgba(0, 217, 255, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 217, 255, 0.12),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JIRA ticket items with proper contrast */
|
||||||
|
.jira-ticket-item {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.85) 0%, rgba(30, 39, 73, 0.75) 100%);
|
||||||
|
border: 1px solid rgba(255, 184, 0, 0.2);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.25),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jira-ticket-item:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.85) 100%);
|
||||||
|
border-color: rgba(255, 184, 0, 0.35);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(255, 184, 0, 0.15),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.35),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CVE Header card with depth */
|
||||||
|
.cve-header {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cve-header:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 39, 73, 0.95) 0%, rgba(42, 52, 88, 0.9) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
border: 2px solid rgba(14, 165, 233, 0.1);
|
||||||
|
border-top-color: #0EA5E9;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip with enhanced styling */
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #334155 0%, #475569 100%);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: #F8FAFC;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 16px rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced heading glow */
|
||||||
|
h1.text-intel-accent,
|
||||||
|
h2.text-intel-accent,
|
||||||
|
h3.text-intel-accent {
|
||||||
|
text-shadow:
|
||||||
|
0 0 16px rgba(14, 165, 233, 0.3),
|
||||||
|
0 0 32px rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced border glow for featured cards */
|
||||||
|
.border-intel-accent {
|
||||||
|
box-shadow: 0 0 12px rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-intel-warning {
|
||||||
|
box-shadow: 0 0 12px rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-intel-danger {
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick lookup section enhancement */
|
||||||
|
.quick-lookup-card {
|
||||||
|
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||||
|
0 0 40px rgba(0, 217, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vendor Cards - nested depth */
|
||||||
|
.vendor-card {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%);
|
||||||
|
border: 1.5px solid rgba(14, 165, 233, 0.25);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow:
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.08);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vendor-card:hover {
|
||||||
|
border-color: rgba(14, 165, 233, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(14, 165, 233, 0.12),
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document items - recessed appearance */
|
||||||
|
.document-item {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(20, 28, 48, 0.98) 100%);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-item:hover {
|
||||||
|
border-color: rgba(14, 165, 233, 0.35);
|
||||||
|
background: linear-gradient(135deg, rgba(20, 28, 48, 1) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
||||||
|
0 2px 8px rgba(14, 165, 233, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knowledge Base Content Area */
|
||||||
|
.kb-content-area {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styling */
|
||||||
|
.markdown-content {
|
||||||
|
color: #E2E8F0;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0EA5E9;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #10B981;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F59E0B;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: #0EA5E9;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
color: #38BDF8;
|
||||||
|
border-bottom-color: #38BDF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #10B981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid #0EA5E9;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th,
|
||||||
|
.markdown-content td {
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th {
|
||||||
|
background: rgba(14, 165, 233, 0.1);
|
||||||
|
color: #0EA5E9;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content td {
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content tr:hover {
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content strong {
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content em {
|
||||||
|
color: #CBD5E1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
1568
frontend/src/App.js
1568
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
307
frontend/src/components/AuditLog.js
Normal file
307
frontend/src/components/AuditLog.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const ACTION_BADGES = {
|
||||||
|
login: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||||
|
logout: { bg: 'bg-gray-100', text: 'text-gray-800' },
|
||||||
|
login_failed: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||||
|
cve_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||||
|
cve_update_status: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||||
|
document_upload: { bg: 'bg-purple-100', text: 'text-purple-800' },
|
||||||
|
document_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||||
|
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||||
|
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||||
|
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||||
|
cve_edit: { bg: 'bg-orange-100', text: 'text-orange-800' },
|
||||||
|
cve_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||||
|
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_TYPES = ['auth', 'cve', 'document', 'user'];
|
||||||
|
|
||||||
|
export default function AuditLog({ onClose }) {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [actions, setActions] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [userFilter, setUserFilter] = useState('');
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const [entityTypeFilter, setEntityTypeFilter] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page, limit: 25 });
|
||||||
|
if (userFilter) params.append('user', userFilter);
|
||||||
|
if (actionFilter) params.append('action', actionFilter);
|
||||||
|
if (entityTypeFilter) params.append('entityType', entityTypeFilter);
|
||||||
|
if (startDate) params.append('startDate', startDate);
|
||||||
|
if (endDate) params.append('endDate', endDate);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/audit-logs?${params}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch audit logs');
|
||||||
|
const data = await response.json();
|
||||||
|
setLogs(data.logs);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userFilter, actionFilter, entityTypeFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
const fetchActions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/audit-logs/actions`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setActions(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical, ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(1);
|
||||||
|
fetchActions();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDetails = (details) => {
|
||||||
|
if (!details) return '-';
|
||||||
|
try {
|
||||||
|
const parsed = typeof details === 'string' ? JSON.parse(details) : details;
|
||||||
|
return Object.entries(parsed)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(', ');
|
||||||
|
} catch {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionBadge = (action) => {
|
||||||
|
const style = ACTION_BADGES[action] || { bg: 'bg-gray-100', text: 'text-gray-800' };
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${style.bg} ${style.text}`}>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilter = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchLogs(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setUserFilter('');
|
||||||
|
setActionFilter('');
|
||||||
|
setEntityTypeFilter('');
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Audit Log</h2>
|
||||||
|
<p className="text-gray-600">Track all user actions across the system</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-2"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<form onSubmit={handleFilter} className="p-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">Username</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-gray-400 absolute left-2 top-1/2 transform -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search user..."
|
||||||
|
value={userFilter}
|
||||||
|
onChange={(e) => setUserFilter(e.target.value)}
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">Action</label>
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
{actions.map(a => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">Entity Type</label>
|
||||||
|
<select
|
||||||
|
value={entityTypeFilter}
|
||||||
|
onChange={(e) => setEntityTypeFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{ENTITY_TYPES.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-1.5 text-sm bg-[#0476D9] text-white rounded hover:bg-[#0360B8] transition-colors"
|
||||||
|
>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-4 py-1.5 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
|
||||||
|
<p className="text-gray-600 mt-2">Loading audit logs...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
|
||||||
|
<p className="text-red-600 mt-2">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">No audit log entries found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Time</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">User</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Action</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Entity</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Details</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 font-medium text-gray-900">
|
||||||
|
{log.username}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{getActionBadge(log.action)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-gray-700">
|
||||||
|
<span className="text-gray-500">{log.entity_type}</span>
|
||||||
|
{log.entity_id && (
|
||||||
|
<span className="ml-1 text-gray-900">{log.entity_id}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-gray-600 max-w-xs truncate" title={formatDetails(log.details)}>
|
||||||
|
{formatDetails(log.details)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-gray-500 font-mono text-xs">
|
||||||
|
{log.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="p-4 border-t border-gray-200 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Showing {((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} entries
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchLogs(pagination.page - 1)}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchLogs(pagination.page + 1)}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('General');
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [existingArticles, setExistingArticles] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Fetch existing articles on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExistingArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchExistingArticles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||||
|
const data = await response.json();
|
||||||
|
setExistingArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching articles:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||||||
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(ext)) {
|
||||||
|
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Auto-populate title from filename if empty
|
||||||
|
if (!title) {
|
||||||
|
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||||
|
setTitle(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile || !title.trim()) {
|
||||||
|
setError('Please provide both a title and file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('title', title.trim());
|
||||||
|
formData.append('description', description.trim());
|
||||||
|
formData.append('category', category);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResult(data);
|
||||||
|
setPhase('success');
|
||||||
|
|
||||||
|
// Refresh the list of existing articles
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (id, filename) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id, articleTitle) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Delete failed');
|
||||||
|
|
||||||
|
// Refresh the list
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
setError('Failed to delete article');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setSelectedFile(null);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setCategory('General');
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (!bytes) return 'Unknown size';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Knowledge Base</h2>
|
||||||
|
<button onClick={onClose} className="modal-close">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Idle Phase - Upload Form */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Inventory Management Policy"
|
||||||
|
className="intel-input w-full"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this document..."
|
||||||
|
className="intel-input w-full"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="General">General</option>
|
||||||
|
<option value="Policy">Policy</option>
|
||||||
|
<option value="Procedure">Procedure</option>
|
||||||
|
<option value="Guide">Guide</option>
|
||||||
|
<option value="Reference">Reference</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Document File *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||||
|
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile || !title.trim()}
|
||||||
|
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="w-4 h-4 mr-2" />
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Phase */}
|
||||||
|
{phase === 'uploading' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Phase */}
|
||||||
|
{phase === 'success' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||||
|
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||||
|
{result.title} has been added to the knowledge base.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Upload Another Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Phase */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing Articles Section */}
|
||||||
|
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Existing Documents ({existingArticles.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{existingArticles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="intel-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||||||
|
{article.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>{formatDate(article.created_at)}</span>
|
||||||
|
<span>{formatFileSize(article.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(article.id, article.file_name)}
|
||||||
|
className="intel-button intel-button-small intel-button-success"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(article.id, article.title)}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticleContent();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [article.id]);
|
||||||
|
|
||||||
|
const fetchArticleContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
setContent(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching article content:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = article.file_name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMarkdown = article.file_name?.endsWith('.md');
|
||||||
|
const isText = article.file_name?.endsWith('.txt');
|
||||||
|
const isPDF = article.file_name?.endsWith('.pdf');
|
||||||
|
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||||
|
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category),
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>Created: {formatDate(article.created_at)}</span>
|
||||||
|
{article.created_by_username && (
|
||||||
|
<span>By: {article.created_by_username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="kb-content-area">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{/* Markdown Rendering */}
|
||||||
|
{isMarkdown && (
|
||||||
|
<div className="markdown-content">
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plain Text */}
|
||||||
|
{isText && !isMarkdown && (
|
||||||
|
<pre
|
||||||
|
className="text-sm p-4 rounded overflow-auto"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF */}
|
||||||
|
{isPDF && (
|
||||||
|
<div className="w-full" style={{ height: '700px' }}>
|
||||||
|
<iframe
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
title={article.title}
|
||||||
|
className="w-full h-full rounded"
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Your browser doesn't support PDF preview. Click the download button to view this file.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
alt={article.title}
|
||||||
|
className="max-w-full h-auto rounded"
|
||||||
|
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other file types */}
|
||||||
|
{!isMarkdown && !isText && !isPDF && !isImage && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Preview not available for this file type.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Loader, AlertCircle, Lock, User } from 'lucide-react';
|
import { AlertCircle, Lock, User } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
@@ -24,57 +24,60 @@ export default function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-intel-darkest grid-bg flex items-center justify-center p-4 relative overflow-hidden fade-in">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-8">
|
{/* Scanning line effect */}
|
||||||
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="w-16 h-16 bg-[#0476D9] rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}>
|
||||||
<Lock className="w-8 h-8 text-white" />
|
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">CVE Dashboard</h1>
|
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1>
|
||||||
<p className="text-gray-600 mt-2">Sign in to access the dashboard</p>
|
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
<div className="mb-6 p-4 bg-intel-danger/10 border border-intel-danger/30 rounded flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-intel-danger flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
<p className="text-sm text-gray-300">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="username" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
<User className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
className="intel-input w-full pl-10"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter username"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="password" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
<Lock className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
className="intel-input w-full pl-10"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter password"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,22 +86,22 @@ export default function LoginForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full intel-button intel-button-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader className="w-5 h-5 animate-spin" />
|
<div className="loading-spinner w-5 h-5"></div>
|
||||||
Signing in...
|
<span className="font-mono uppercase tracking-wider">Authenticating...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Sign In'
|
<span className="font-mono uppercase tracking-wider">Access System</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
<div className="mt-6 pt-6 border-t border-intel-grid">
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-500 text-center font-mono">
|
||||||
Default admin credentials: admin / admin123
|
Default: <span className="text-intel-accent">admin</span> / <span className="text-intel-accent">admin123</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
509
frontend/src/components/NvdSyncModal.js
Normal file
509
frontend/src/components/NvdSyncModal.js
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const FETCH_DELAY_MS = 7000; // 7 seconds between requests (safe for 5 req/30s without API key)
|
||||||
|
const RETRY_DELAY_MS = 35000; // Wait 35 seconds on 429 before retry
|
||||||
|
|
||||||
|
export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, fetching, review, applying, done
|
||||||
|
const [cveIds, setCveIds] = useState([]);
|
||||||
|
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0, currentId: '' });
|
||||||
|
const [results, setResults] = useState({}); // { cveId: { nvd: {...}, current: {...}, status: 'found'|'not_found'|'error'|'no_change', error: '' } }
|
||||||
|
const [descriptionChoices, setDescriptionChoices] = useState({}); // { cveId: 'keep' | 'nvd' }
|
||||||
|
const [applyResult, setApplyResult] = useState(null);
|
||||||
|
const [expandedDesc, setExpandedDesc] = useState({});
|
||||||
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
|
// Fetch distinct CVE IDs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/distinct-ids`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch CVE list');
|
||||||
|
const data = await response.json();
|
||||||
|
setCveIds(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching CVE IDs:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const fetchNvdData = async () => {
|
||||||
|
setPhase('fetching');
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const newResults = {};
|
||||||
|
setFetchProgress({ current: 0, total: cveIds.length, currentId: '' });
|
||||||
|
|
||||||
|
// First fetch current data for all CVEs
|
||||||
|
let currentData = {};
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves`, { credentials: 'include', signal: controller.signal });
|
||||||
|
if (response.ok) {
|
||||||
|
const allCves = await response.json();
|
||||||
|
// Group by cve_id, take first entry for description/severity/date
|
||||||
|
allCves.forEach(cve => {
|
||||||
|
if (!currentData[cve.cve_id]) {
|
||||||
|
currentData[cve.cve_id] = {
|
||||||
|
description: cve.description,
|
||||||
|
severity: cve.severity,
|
||||||
|
published_date: cve.published_date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') { setPhase('idle'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < cveIds.length; i++) {
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
|
||||||
|
const cveId = cveIds[i];
|
||||||
|
setFetchProgress({ current: i + 1, total: cveIds.length, currentId: cveId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle rate limit with one retry
|
||||||
|
if (response.status === 429) {
|
||||||
|
await sleep(RETRY_DELAY_MS);
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
newResults[cveId] = { status: 'not_found', current: currentData[cveId] || {} };
|
||||||
|
} else if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
newResults[cveId] = { status: 'error', error: data.error || `HTTP ${response.status}`, current: currentData[cveId] || {} };
|
||||||
|
} else {
|
||||||
|
const nvd = await response.json();
|
||||||
|
const current = currentData[cveId] || {};
|
||||||
|
|
||||||
|
const descChanged = nvd.description && nvd.description !== current.description;
|
||||||
|
const sevChanged = nvd.severity && nvd.severity !== current.severity;
|
||||||
|
const dateChanged = nvd.published_date && nvd.published_date !== current.published_date;
|
||||||
|
|
||||||
|
if (!descChanged && !sevChanged && !dateChanged) {
|
||||||
|
newResults[cveId] = { status: 'no_change', nvd, current };
|
||||||
|
} else {
|
||||||
|
newResults[cveId] = { status: 'found', nvd, current, descChanged, sevChanged, dateChanged };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') break;
|
||||||
|
newResults[cveId] = { status: 'error', error: err.message, current: currentData[cveId] || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update results progressively
|
||||||
|
setResults({ ...newResults });
|
||||||
|
|
||||||
|
// Rate limit delay (skip after last item)
|
||||||
|
if (i < cveIds.length - 1 && !controller.signal.aborted) {
|
||||||
|
await sleep(FETCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setResults({ ...newResults });
|
||||||
|
// Default all description choices to 'keep'
|
||||||
|
const choices = {};
|
||||||
|
Object.entries(newResults).forEach(([id, r]) => {
|
||||||
|
if (r.status === 'found' && r.descChanged) {
|
||||||
|
choices[id] = 'keep';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDescriptionChoices(choices);
|
||||||
|
setPhase('review');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelFetch = () => {
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
setPhase('idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBulkDescriptionChoice = (choice) => {
|
||||||
|
const newChoices = {};
|
||||||
|
Object.keys(descriptionChoices).forEach(id => {
|
||||||
|
newChoices[id] = choice;
|
||||||
|
});
|
||||||
|
setDescriptionChoices(newChoices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangesCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
Object.entries(results).forEach(([id, r]) => {
|
||||||
|
if (r.status === 'found') {
|
||||||
|
if (r.sevChanged || r.dateChanged || (r.descChanged && descriptionChoices[id] === 'nvd')) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyChanges = async () => {
|
||||||
|
setPhase('applying');
|
||||||
|
const updates = [];
|
||||||
|
|
||||||
|
Object.entries(results).forEach(([cveId, r]) => {
|
||||||
|
if (r.status !== 'found') return;
|
||||||
|
|
||||||
|
const update = { cve_id: cveId };
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
if (r.sevChanged) {
|
||||||
|
update.severity = r.nvd.severity;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
if (r.dateChanged) {
|
||||||
|
update.published_date = r.nvd.published_date;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
if (r.descChanged && descriptionChoices[cveId] === 'nvd') {
|
||||||
|
update.description = r.nvd.description;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChange) updates.push(update);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
setApplyResult({ updated: 0, message: 'No changes to apply' });
|
||||||
|
setPhase('done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/nvd-sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ updates })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Sync failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setApplyResult(data);
|
||||||
|
onSyncComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setApplyResult({ error: err.message });
|
||||||
|
}
|
||||||
|
setPhase('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (str, len = 120) => str && str.length > len ? str.substring(0, len) + '...' : str;
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const foundCount = Object.values(results).filter(r => r.status === 'found').length;
|
||||||
|
const noChangeCount = Object.values(results).filter(r => r.status === 'no_change').length;
|
||||||
|
const notFoundCount = Object.values(results).filter(r => r.status === 'not_found').length;
|
||||||
|
const errorCount = Object.values(results).filter(r => r.status === 'error').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-6 h-6 text-green-600" />
|
||||||
|
Sync with NVD
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
|
{/* Idle Phase */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-lg text-gray-700 mb-2">
|
||||||
|
{cveIds.length > 0
|
||||||
|
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
|
||||||
|
: 'Loading CVE count...'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
This will fetch data from NVD for each CVE and let you review changes before applying.
|
||||||
|
Rate-limited to stay within NVD API limits.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchNvdData}
|
||||||
|
disabled={cveIds.length === 0}
|
||||||
|
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Fetch NVD Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fetching Phase */}
|
||||||
|
{phase === 'fetching' && (
|
||||||
|
<div className="py-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-lg text-gray-700">
|
||||||
|
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-green-600 h-3 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={cancelFetch}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Phase */}
|
||||||
|
{phase === 'review' && (
|
||||||
|
<div>
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk controls */}
|
||||||
|
{Object.keys(descriptionChoices).length > 0 && (
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDescriptionChoice('keep')}
|
||||||
|
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Keep All Existing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDescriptionChoice('nvd')}
|
||||||
|
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
|
||||||
|
>
|
||||||
|
Use All NVD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparison table */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(results).map(([cveId, r]) => {
|
||||||
|
if (r.status === 'no_change') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
<span className="font-mono font-medium text-gray-500">{cveId}</span>
|
||||||
|
<span className="text-gray-400">No changes needed</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status === 'not_found') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
<span className="font-mono font-medium text-gray-400">{cveId}</span>
|
||||||
|
<span className="text-gray-400 italic">Not found in NVD</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status === 'error') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||||
|
<span className="font-mono font-medium text-gray-700">{cveId}</span>
|
||||||
|
<span className="text-red-600">{r.error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status === 'found' — show changes
|
||||||
|
const isExpanded = expandedDesc[cveId];
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-gray-900">{cveId}</span>
|
||||||
|
{r.sevChanged && (
|
||||||
|
<span className="text-xs">
|
||||||
|
Severity: <span className="text-red-600">{r.current.severity}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="text-green-700">{r.nvd.severity}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.dateChanged && (
|
||||||
|
<span className="text-xs">
|
||||||
|
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="text-green-700">{r.nvd.published_date}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.descChanged && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-600">Description:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
{isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="p-2 bg-red-50 rounded border border-red-200">
|
||||||
|
<span className="font-medium text-red-700">Current: </span>
|
||||||
|
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-green-50 rounded border border-green-200">
|
||||||
|
<span className="font-medium text-green-700">NVD: </span>
|
||||||
|
<span className="text-gray-700">{r.nvd.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description choice */}
|
||||||
|
<div className="flex gap-4 mt-2">
|
||||||
|
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`desc-${cveId}`}
|
||||||
|
checked={descriptionChoices[cveId] === 'keep'}
|
||||||
|
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
Keep existing
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`desc-${cveId}`}
|
||||||
|
checked={descriptionChoices[cveId] === 'nvd'}
|
||||||
|
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
|
||||||
|
className="text-green-600"
|
||||||
|
/>
|
||||||
|
Use NVD
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Applying Phase */}
|
||||||
|
{phase === 'applying' && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-gray-700">Applying changes...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done Phase */}
|
||||||
|
{phase === 'done' && applyResult && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
{applyResult.error ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
|
||||||
|
<p className="text-sm text-gray-600">{applyResult.error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
|
||||||
|
</p>
|
||||||
|
{applyResult.errors && applyResult.errors.length > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 mt-2">
|
||||||
|
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||||
|
{phase === 'review' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={applyChanges}
|
||||||
|
disabled={getChangesCount() === 0}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === 'done' && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { User, LogOut, ChevronDown, Shield } from 'lucide-react';
|
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export default function UserMenu({ onManageUsers }) {
|
export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||||
const { user, logout, isAdmin } = useAuth();
|
const { user, logout, isAdmin } = useAuth();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
@@ -42,6 +42,13 @@ export default function UserMenu({ onManageUsers }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAuditLog = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (onAuditLog) {
|
||||||
|
onAuditLog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +78,7 @@ export default function UserMenu({ onManageUsers }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleManageUsers}
|
onClick={handleManageUsers}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
||||||
@@ -78,6 +86,14 @@ export default function UserMenu({ onManageUsers }) {
|
|||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
Manage Users
|
Manage Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAuditLog}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Audit Log
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
291
frontend/src/components/WeeklyReportModal.js
Normal file
291
frontend/src/components/WeeklyReportModal.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function WeeklyReportModal({ onClose }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [existingReports, setExistingReports] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Fetch existing reports on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExistingReports();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchExistingReports = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch reports');
|
||||||
|
const data = await response.json();
|
||||||
|
setExistingReports(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching reports:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.name.endsWith('.xlsx')) {
|
||||||
|
setError('Please select an Excel file (.xlsx)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadProgress(50); // Simulated progress
|
||||||
|
setPhase('processing');
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/weekly-reports/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResult(data);
|
||||||
|
setPhase('success');
|
||||||
|
|
||||||
|
// Refresh the list of existing reports
|
||||||
|
await fetchExistingReports();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (id, type) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `vulnerability_report_${type}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError(`Failed to download ${type} file`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setSelectedFile(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Weekly Vulnerability Report</h2>
|
||||||
|
<button onClick={onClose} className="modal-close">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Idle Phase - File Selection */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Upload Excel File (.xlsx)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||||
|
Selected: {selectedFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile}
|
||||||
|
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="w-4 h-4 mr-2" />
|
||||||
|
Upload & Process
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Phase */}
|
||||||
|
{phase === 'uploading' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Uploading file...</p>
|
||||||
|
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processing Phase */}
|
||||||
|
{phase === 'processing' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Processing vulnerability report...</p>
|
||||||
|
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Phase */}
|
||||||
|
{phase === 'success' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||||
|
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||||
|
Original: {result.original_rows} rows → Processed: {result.processed_rows} rows
|
||||||
|
<span className="ml-2" style={{ color: '#10B981' }}>
|
||||||
|
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(result.id, 'original')}
|
||||||
|
className="intel-button flex-1"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download Original
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(result.id, 'processed')}
|
||||||
|
className="intel-button intel-button-success flex-1"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download Processed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Upload Another Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Phase */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing Reports Section */}
|
||||||
|
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Previous Reports
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{existingReports.map((report) => (
|
||||||
|
<div
|
||||||
|
key={report.id}
|
||||||
|
className="intel-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{report.is_current && (
|
||||||
|
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
|
||||||
|
)}
|
||||||
|
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
|
||||||
|
{report.week_label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: '#64748B' }}>
|
||||||
|
{new Date(report.upload_date).toLocaleDateString()} •
|
||||||
|
{report.row_count_original} → {report.row_count_processed} rows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(report.id, 'original')}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Download Original"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(report.id, 'processed')}
|
||||||
|
className="intel-button intel-button-success intel-button-small"
|
||||||
|
title="Download Processed"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
ivantiAPI.py
Normal file
155
ivantiAPI.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025
|
||||||
|
|
||||||
|
### ! README | IMPORTANT INFORMATION ! ###
|
||||||
|
# requires an "Ivanti_config.ini" file in the same directory
|
||||||
|
# edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini"
|
||||||
|
|
||||||
|
### ? CODE PURPOSE ? ###
|
||||||
|
# the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file
|
||||||
|
# this class primarily targets these endpoints: host, tag, hostFinding, vulnerability
|
||||||
|
# it should work on other endpoints as well, but the 4 above are the only ones tested
|
||||||
|
# usage examples of this class are at the end of this file
|
||||||
|
|
||||||
|
# library imports
|
||||||
|
import requests, urllib3, configparser, pandas as pd
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3 import Retry
|
||||||
|
|
||||||
|
# fix (ignore) SSL verification...
|
||||||
|
# Charter-specific issue; feel free to fix this if you can...
|
||||||
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
|
# Ivanti API class
|
||||||
|
class Ivanti:
|
||||||
|
def __init__(self, config_file='./Ivanti_config.ini'):
|
||||||
|
# read our config file
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_file)
|
||||||
|
|
||||||
|
# set up environment & auth
|
||||||
|
PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver')
|
||||||
|
IVANTI_API_KEY = config.get('secrets', 'api_key')
|
||||||
|
self.CLIENT_ID = config.get('platform', 'client_id')
|
||||||
|
self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}'
|
||||||
|
|
||||||
|
# universal header for our requests
|
||||||
|
self.header = {
|
||||||
|
'x-api-key': IVANTI_API_KEY,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# dictionaries for filters and fields, sorted with keys by endpoint prefixes
|
||||||
|
self.filters = {}
|
||||||
|
self.fields = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
# function used for HTTP requests- thank you, Ivanti... useful code
|
||||||
|
def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)):
|
||||||
|
"""
|
||||||
|
Create a Requests session that uses automatic retries.
|
||||||
|
:param max_retries: Maximum number of retries to attempt
|
||||||
|
:type max_retries: int
|
||||||
|
:param backoff_factor: Backoff factor used to calculate time between retries.
|
||||||
|
:type backoff_factor: float
|
||||||
|
:param status_forcelist: A tuple containing the response status codes that should trigger a retry.
|
||||||
|
:type status_forcelist: tuple
|
||||||
|
:return: Requests Session
|
||||||
|
:rtype: Requests Session Object
|
||||||
|
"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry = Retry(
|
||||||
|
total=max_retries,
|
||||||
|
read=max_retries,
|
||||||
|
connect=max_retries,
|
||||||
|
backoff_factor=backoff_factor,
|
||||||
|
status_forcelist=status_forcelist,
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
# retrieve all filters for an endpoint (tag, host, etc)
|
||||||
|
def get_filters(self, endp='tag'):
|
||||||
|
URL_FILTERS = f'{self.URL_BASE}/{endp}/filter'
|
||||||
|
self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False)
|
||||||
|
self.filters[endp] = self.last_resp.json()
|
||||||
|
return self.filters[endp]
|
||||||
|
|
||||||
|
# retrieve all fields for an endpoint (tag, host, etc)
|
||||||
|
def get_fields(self, endp='tag'):
|
||||||
|
URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template'
|
||||||
|
self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False)
|
||||||
|
self.fields[endp] = self.last_resp.json()['exportableFields']
|
||||||
|
return self.fields[endp]
|
||||||
|
|
||||||
|
# this uses the "{subject}/search" endpoint instead of "{subject}/export"
|
||||||
|
def search(self, endp='tag', save=None, pages=None, size=750):
|
||||||
|
'''
|
||||||
|
Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON.
|
||||||
|
:param endp: String for endpoint name; host, tag, group, etc. (default: "tag")
|
||||||
|
:param save: String for filename to save, end with ".csv" (default: none)
|
||||||
|
:param pages: Integer to limit the number of pages to pull (default: all pages)
|
||||||
|
:param size: Integer defining how many records to pull per page (default: 750 records)
|
||||||
|
:return: Pandas DataFrame
|
||||||
|
'''
|
||||||
|
# most endpoints follow the same URL structure and usage pattern
|
||||||
|
# filters and fields dont matter for searches- only for exports!
|
||||||
|
URL_SEARCH = f'{self.URL_BASE}/{endp}/search'
|
||||||
|
body = {
|
||||||
|
'projection': 'basic', # can also be set to 'detail'
|
||||||
|
'sort': [
|
||||||
|
{
|
||||||
|
'field': 'id',
|
||||||
|
'direction': 'ASC'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'page': 0,
|
||||||
|
'size': size
|
||||||
|
}
|
||||||
|
|
||||||
|
# post a search, get first page
|
||||||
|
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}')
|
||||||
|
totalPages = int(resp.json()['page']['totalPages'])
|
||||||
|
totalRecords = int(resp.json()['page']['totalElements'])
|
||||||
|
body['page'] = int(resp.json()['page']['number']) + 1
|
||||||
|
msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}'
|
||||||
|
if pages:
|
||||||
|
msg += f'\n[?] Page limit: {pages} pages'
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
# limit results?
|
||||||
|
if pages:
|
||||||
|
totalPages = pages
|
||||||
|
|
||||||
|
# loop until the last page
|
||||||
|
subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s'
|
||||||
|
data = []
|
||||||
|
while body['page'] < totalPages:
|
||||||
|
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||||
|
body['page'] = int(resp.json()['page']['number']) + 1
|
||||||
|
data.extend(resp.json()['_embedded'][subject])
|
||||||
|
print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='')
|
||||||
|
print(f'\n[+] Search completed. {len(data)} records retrieved!')
|
||||||
|
|
||||||
|
# make a nice dataframe, save file if wanted, return the frame
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
if save:
|
||||||
|
df.to_csv(save, index=False)
|
||||||
|
return df
|
||||||
|
|
||||||
|
### ? EXAMPLE USAGE ? ###
|
||||||
|
# configure the connection and auth, create an instance object
|
||||||
|
#API = Ivanti('./Ivanti_config.ini')
|
||||||
|
|
||||||
|
# the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint
|
||||||
|
#df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5)
|
||||||
|
|
||||||
|
# you can also retrieve all possible filters and exportable fields per subject
|
||||||
|
#filters = API.get_fields('host')
|
||||||
|
#fields = API.get_filters('tag')
|
||||||
297
plan.md
Normal file
297
plan.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# NVD Lookup + Retroactive Sync — Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Two capabilities on `feature/nvd-lookup` branch:
|
||||||
|
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
|
||||||
|
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Git State
|
||||||
|
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
|
||||||
|
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
|
||||||
|
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
|
||||||
|
- Offsite repo is up to date through the feature/audit merge to master
|
||||||
|
|
||||||
|
### What's in the Stash
|
||||||
|
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
|
||||||
|
|
||||||
|
**`backend/routes/nvdLookup.js` (NEW file)**
|
||||||
|
- Factory function: `createNvdLookupRouter(db, requireAuth)`
|
||||||
|
- `GET /lookup/:cveId` endpoint
|
||||||
|
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
|
||||||
|
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
|
||||||
|
- 10-second timeout via `AbortSignal.timeout(10000)`
|
||||||
|
- Optional `apiKey` header from `NVD_API_KEY` env var
|
||||||
|
- CVSS severity cascade: v3.1 → v3.0 → v2.0
|
||||||
|
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
|
||||||
|
- Returns: `{ description, severity, published_date }`
|
||||||
|
|
||||||
|
**`backend/server.js` (MODIFIED)**
|
||||||
|
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
|
||||||
|
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
|
||||||
|
|
||||||
|
**`frontend/src/App.js` (MODIFIED)**
|
||||||
|
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
|
||||||
|
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
|
||||||
|
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
|
||||||
|
- Spinner (Loader icon) in CVE ID field while loading
|
||||||
|
- Green "Auto-filled from NVD" with CheckCircle on success
|
||||||
|
- Amber warning with AlertCircle on errors (non-blocking)
|
||||||
|
- Description only fills if currently empty; severity + published_date always update
|
||||||
|
- NVD state resets on modal close (X, Cancel) and form submit
|
||||||
|
|
||||||
|
**`backend/.env.example` (MODIFIED)**
|
||||||
|
- Adds `NVD_API_KEY=` with comment about rate limits
|
||||||
|
|
||||||
|
### Stash Conflict Resolution
|
||||||
|
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
|
||||||
|
|
||||||
|
The conflict is in the imports section. Keep ALL existing audit lines from master:
|
||||||
|
```js
|
||||||
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
|
const logAudit = require('./helpers/auditLog');
|
||||||
|
```
|
||||||
|
AND add the NVD line:
|
||||||
|
```js
|
||||||
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
|
```
|
||||||
|
|
||||||
|
Similarly, keep the audit route mount and add the NVD mount after it:
|
||||||
|
```js
|
||||||
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||||
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `git add backend/server.js` to mark resolved and `git stash drop`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Resolve Stash + Rebase onto Master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout feature/nvd-lookup
|
||||||
|
git rebase master # Get audit changes into the branch
|
||||||
|
git stash pop # Apply NVD changes (will conflict in server.js)
|
||||||
|
# Resolve conflict in server.js as described above
|
||||||
|
git add backend/server.js
|
||||||
|
git stash drop
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Backend — New Endpoints in `server.js`
|
||||||
|
|
||||||
|
### 2A: `GET /api/cves/distinct-ids`
|
||||||
|
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
|
||||||
|
```js
|
||||||
|
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||||
|
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||||
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
res.json(rows.map(r => r.cve_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2B: `POST /api/cves/nvd-sync`
|
||||||
|
Place after the existing `PATCH /api/cves/:cveId/status`:
|
||||||
|
```js
|
||||||
|
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { updates } = req.body;
|
||||||
|
if (!Array.isArray(updates) || updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No updates provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
const errors = [];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
updates.forEach((entry) => {
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
if (entry.description !== null && entry.description !== undefined) {
|
||||||
|
fields.push('description = ?');
|
||||||
|
values.push(entry.description);
|
||||||
|
}
|
||||||
|
if (entry.severity !== null && entry.severity !== undefined) {
|
||||||
|
fields.push('severity = ?');
|
||||||
|
values.push(entry.severity);
|
||||||
|
}
|
||||||
|
if (entry.published_date !== null && entry.published_date !== undefined) {
|
||||||
|
fields.push('published_date = ?');
|
||||||
|
values.push(entry.published_date);
|
||||||
|
}
|
||||||
|
if (fields.length === 0) {
|
||||||
|
completed++;
|
||||||
|
if (completed === updates.length) sendResponse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(entry.cve_id);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
||||||
|
values,
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
errors.push({ cve_id: entry.cve_id, error: err.message });
|
||||||
|
} else {
|
||||||
|
updated += this.changes;
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
if (completed === updates.length) sendResponse();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResponse() {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_nvd_sync',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: null,
|
||||||
|
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
const result = { message: 'NVD sync completed', updated };
|
||||||
|
if (errors.length > 0) result.errors = errors;
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Frontend — New `NvdSyncModal.js` Component
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/NvdSyncModal.js`
|
||||||
|
|
||||||
|
### Props
|
||||||
|
```jsx
|
||||||
|
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase Machine
|
||||||
|
| Phase | What's shown |
|
||||||
|
|-------|-------------|
|
||||||
|
| `idle` | CVE count + "Fetch NVD Data" button |
|
||||||
|
| `fetching` | Progress bar, current CVE being fetched, cancel button |
|
||||||
|
| `review` | Comparison table with per-CVE description choice |
|
||||||
|
| `applying` | Spinner |
|
||||||
|
| `done` | Summary (X updated, Y errors) + Close button |
|
||||||
|
|
||||||
|
### Fetching Logic
|
||||||
|
- Iterate CVE IDs sequentially
|
||||||
|
- Call `GET /api/nvd/lookup/:cveId` for each
|
||||||
|
- 7-second delay between requests (safe for 5 req/30s without API key)
|
||||||
|
- On 429: wait 35 seconds, retry once
|
||||||
|
- On 404: mark as "Not found in NVD" (gray, skipped)
|
||||||
|
- On timeout/error: mark with warning (skipped)
|
||||||
|
- Support cancellation via AbortController
|
||||||
|
|
||||||
|
### Comparison Table Columns
|
||||||
|
| Column | Content |
|
||||||
|
|--------|---------|
|
||||||
|
| CVE ID | The identifier |
|
||||||
|
| Status | Icon: check=found, warning=error, dash=no changes |
|
||||||
|
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
|
||||||
|
| Published Date | `Current → NVD` or "No change" |
|
||||||
|
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
|
||||||
|
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
|
||||||
|
|
||||||
|
### Bulk Controls
|
||||||
|
Above the table:
|
||||||
|
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
|
||||||
|
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
|
||||||
|
|
||||||
|
Below the table:
|
||||||
|
- "Apply N Changes" button (count updates dynamically)
|
||||||
|
- "Cancel" button
|
||||||
|
|
||||||
|
### Apply Logic
|
||||||
|
Build updates array:
|
||||||
|
- For each CVE with NVD data (no error):
|
||||||
|
- Always include `severity` and `published_date` if different from current
|
||||||
|
- Include `description` only if user chose "Use NVD" — otherwise send `null`
|
||||||
|
- Skip CVEs where nothing changed
|
||||||
|
- POST to `/api/cves/nvd-sync`
|
||||||
|
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Frontend — App.js Integration
|
||||||
|
|
||||||
|
Minimal changes following `AuditLog`/`UserManagement` pattern:
|
||||||
|
|
||||||
|
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
|
||||||
|
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
|
||||||
|
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
|
||||||
|
```jsx
|
||||||
|
{canWrite() && (
|
||||||
|
<button onClick={() => setShowNvdSync(true)}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md">
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Sync with NVD
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
4. **Modal render** (alongside other modals):
|
||||||
|
```jsx
|
||||||
|
{showNvdSync && (
|
||||||
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: AuditLog Badge
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/AuditLog.js`
|
||||||
|
|
||||||
|
Add to the `ACTION_BADGES` object:
|
||||||
|
```js
|
||||||
|
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: .env.example (already in stash)
|
||||||
|
```
|
||||||
|
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||||
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
|
NVD_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| File | Action | Lines Changed (est.) |
|
||||||
|
|------|--------|---------------------|
|
||||||
|
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
|
||||||
|
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
|
||||||
|
| `backend/.env.example` | From stash | +3 |
|
||||||
|
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
|
||||||
|
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
|
||||||
|
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
|
||||||
|
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
|
||||||
|
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
|
||||||
|
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
|
||||||
|
5. Click "Sync with NVD" button → modal opens with CVE count
|
||||||
|
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
|
||||||
|
7. Review comparison table → verify diffs shown correctly
|
||||||
|
8. Toggle description choices, click "Apply" → verify database updated
|
||||||
|
9. Confirm main CVE list refreshes with new data
|
||||||
|
10. Check audit log for `cve_nvd_sync` entry
|
||||||
Reference in New Issue
Block a user