Merge feature/usergroups: group-based access control (Admin, Standard_User, Leadership, Read_Only)

This commit is contained in:
jramos
2026-04-07 10:11:21 -06:00
23 changed files with 2006 additions and 215 deletions

View File

@@ -0,0 +1,143 @@
# Requirements Document
## Introduction
Replace the existing simple role-based access control system (admin/editor/viewer) with a group-based access control model. The system supports exactly four user groups (Admin, Standard User, Leadership, Read Only) with distinct permission boundaries. This change affects the database schema, backend middleware, API endpoint authorization, frontend conditional rendering, and the admin panel user management interface.
## Glossary
- **Dashboard**: The STEAM Security Dashboard application comprising a React frontend and Express backend
- **Group**: One of four access control categories (Admin, Standard_User, Leadership, Read_Only) that determines a user's permissions
- **Admin_Group**: The group with full CRUD access to all resources, user management, and admin panel access
- **Standard_User_Group**: The working group with view-all, create, edit, and conditional delete permissions plus basic export
- **Leadership_Group**: The read-only group with additional export capabilities for reports, compliance documents, and visualizations
- **Read_Only_Group**: The view-only group with no create, edit, delete, or export capabilities
- **Permission_Middleware**: Backend Express middleware that validates a user's group membership before allowing an API action
- **Cascade_Impact**: The set of associated Archer tickets, JIRA tickets, and documents that would be deleted when a CVE is deleted
- **Compliance_Link**: An association between a ticket (Archer or JIRA) and a compliance report that blocks Standard_User deletion
- **Group_Migration**: The database migration that replaces the role field with a group field and maps existing users
## Requirements
### Requirement 1: Group Data Model
**User Story:** As a system administrator, I want the user model to reference one of four defined groups instead of the legacy role field, so that permissions are enforced through a well-defined group structure.
#### Acceptance Criteria
1. THE Dashboard SHALL store exactly four groups: Admin, Standard_User, Leadership, and Read_Only
2. THE Dashboard SHALL assign each user to exactly one group via a group field on the user record
3. WHEN a user record is created, THE Dashboard SHALL default the group to Read_Only
4. THE Dashboard SHALL enforce a foreign key or CHECK constraint so that the group field only accepts valid group values
### Requirement 2: Group Migration
**User Story:** As a system administrator, I want existing users to be automatically mapped from the old role system to the new group system, so that no manual re-assignment is needed after the upgrade.
#### Acceptance Criteria
1. WHEN the migration runs, THE Group_Migration SHALL map users with role "admin" to Admin_Group
2. WHEN the migration runs, THE Group_Migration SHALL map users with role "editor" to Standard_User_Group
3. WHEN the migration runs, THE Group_Migration SHALL map users with role "viewer" to Read_Only_Group
4. WHEN the migration runs, THE Group_Migration SHALL remove the CHECK constraint on the old role column and replace it with the new group field
5. IF a user record has no role value or an unrecognized role value, THEN THE Group_Migration SHALL assign that user to Read_Only_Group
### Requirement 3: Backend Permission Enforcement
**User Story:** As a security-conscious developer, I want every API endpoint to check the requesting user's group before allowing the action, so that permissions are enforced server-side and cannot be bypassed through direct API calls.
#### Acceptance Criteria
1. THE Permission_Middleware SHALL replace the existing requireRole middleware with a requireGroup middleware that accepts one or more group names
2. WHEN an unauthenticated request reaches a protected endpoint, THE Permission_Middleware SHALL return HTTP 401
3. WHEN an authenticated user's group is not in the allowed groups for an endpoint, THE Permission_Middleware SHALL return HTTP 403
4. THE Permission_Middleware SHALL attach the user's group to the request object for downstream route handlers to use
5. WHEN a Standard_User_Group user attempts to delete a resource they did not create, THE Dashboard SHALL return HTTP 403
6. WHEN a Standard_User_Group user attempts to delete a finding that is marked as resolved or closed, THE Dashboard SHALL return HTTP 403
7. WHEN a Standard_User_Group user attempts to delete a ticket that is linked to a compliance report, THE Dashboard SHALL return HTTP 403
8. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL check for Cascade_Impact and return the list of associated Archer tickets, JIRA tickets, and documents
9. IF any ticket in the Cascade_Impact is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion and return HTTP 403 with a message indicating Admin-only deletion is required
10. WHEN an Admin_Group user performs any CRUD operation, THE Dashboard SHALL allow the operation without ownership or state restrictions
### Requirement 4: Admin Group Permissions
**User Story:** As an admin, I want full unrestricted access to all resources and management functions, so that I can manage the entire system without limitations.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Admin_Group users to create, read, update, and delete all resources (CVEs, findings, tickets, comments, compliance reports)
2. THE Dashboard SHALL allow Admin_Group users to access the admin panel
3. THE Dashboard SHALL allow Admin_Group users to manage users and assign users to groups
4. THE Dashboard SHALL allow Admin_Group users to export all data
5. THE Dashboard SHALL allow Admin_Group users to delete any resource regardless of ownership, state, or compliance linkage
### Requirement 5: Standard User Group Permissions
**User Story:** As a standard user, I want to view all data and create/edit resources while having controlled delete access, so that I can do my daily work without accidentally removing critical linked data.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Standard_User_Group users to view all data across the dashboard
2. THE Dashboard SHALL allow Standard_User_Group users to create and edit CVEs, findings, tickets, and comments
3. THE Dashboard SHALL allow Standard_User_Group users to delete their own findings, tickets, and comments subject to state and linkage restrictions
4. WHEN a Standard_User_Group user attempts to delete a finding that is resolved or closed, THE Dashboard SHALL reject the deletion
5. WHEN a Standard_User_Group user attempts to delete a ticket linked to a compliance report, THE Dashboard SHALL reject the deletion
6. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL display a warning listing associated Archer tickets, JIRA tickets, and documents that will be cascade-deleted
7. IF any associated ticket in the cascade is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion entirely
8. THE Dashboard SHALL allow Standard_User_Group users to perform basic exports (CSV and XLSX of CVEs and findings)
### Requirement 6: Leadership Group Permissions
**User Story:** As a leadership user, I want read-only access with export capabilities, so that I can review data and generate reports without risk of modifying records.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Leadership_Group users to view all data across the dashboard
2. THE Dashboard SHALL allow Leadership_Group users to export reports, compliance documents, and graph visualizations
3. THE Dashboard SHALL prevent Leadership_Group users from creating, editing, or deleting any records
4. THE Dashboard SHALL prevent Leadership_Group users from accessing the admin panel
### Requirement 7: Read Only Group Permissions
**User Story:** As a read-only user, I want view-only access to the dashboard, so that I can see data without any ability to modify or export it.
#### Acceptance Criteria
1. THE Dashboard SHALL allow Read_Only_Group users to view all data across the dashboard
2. THE Dashboard SHALL prevent Read_Only_Group users from creating, editing, or deleting any records
3. THE Dashboard SHALL prevent Read_Only_Group users from exporting any data
4. THE Dashboard SHALL prevent Read_Only_Group users from accessing the admin panel
### Requirement 8: Admin Panel Group Management
**User Story:** As an admin, I want to view all users with their current group and reassign groups through the admin panel, so that I can manage access control centrally.
#### Acceptance Criteria
1. WHEN an Admin_Group user opens the user management section, THE Dashboard SHALL display all users with their current group assignment
2. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL update the group assignment and persist it to the database
3. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL display a confirmation dialog before applying the change
4. WHEN an Admin_Group user downgrades another Admin_Group user, THE Dashboard SHALL display an additional warning in the confirmation dialog
5. THE Dashboard SHALL prevent an Admin_Group user from changing their own group to a non-Admin group
### Requirement 9: Audit Logging for Group Changes
**User Story:** As a system administrator, I want all group assignment changes to be logged with full context, so that I can audit who changed access for whom and when.
#### Acceptance Criteria
1. WHEN a user's group is changed, THE Dashboard SHALL log the change with the acting user's ID, the target user's ID, the previous group, the new group, and a timestamp
2. THE Dashboard SHALL preserve existing audit trail behavior for all CRUD operations performed under the new group system
3. WHEN a group change is logged, THE Dashboard SHALL record the IP address of the acting user
### Requirement 10: Frontend Conditional Rendering
**User Story:** As a user, I want the UI to show only the actions available to my group, so that I have a clear and uncluttered interface matching my permissions.
#### Acceptance Criteria
1. THE Dashboard SHALL conditionally render create, edit, and delete buttons based on the current user's group
2. THE Dashboard SHALL conditionally render export options based on the current user's group
3. THE Dashboard SHALL conditionally render the admin panel link based on the current user's group
4. WHEN a Standard_User_Group user views a resource they did not create, THE Dashboard SHALL hide the delete button for that resource
5. THE Dashboard SHALL replace the existing role-based helper functions (hasRole, canWrite, isAdmin) with group-based equivalents (isInGroup, canWrite, canDelete, canExport, isAdmin)

View File

@@ -0,0 +1,279 @@
# Implementation Plan: Group-Based Access Control
## Overview
Replace the existing role-based access control (admin/editor/viewer) with a four-group model (Admin, Standard_User, Leadership, Read_Only). This touches the database schema, backend middleware, all route authorization, frontend permission helpers, and the admin panel UI. Tasks build incrementally: migration first, then middleware, then routes, then frontend.
## Tasks
- [ ] 1. Create database migration for user groups
- [x] 1.1 Create `backend/migrations/add_user_groups.js` migration script
- Add `user_group` column (VARCHAR(20), NOT NULL, DEFAULT 'Read_Only') to users table
- Map existing role values: admin to Admin, editor to Standard_User, viewer to Read_Only
- Map NULL or unrecognized role values to Read_Only
- Add CHECK constraint: user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
- Add index `idx_users_user_group` on user_group column
- Use idempotent checks so migration is safe to run multiple times
- Follow existing migration pattern: open db, db.serialize(), log progress, close db
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_
- [ ]* 1.2 Write property test for migration role mapping
- **Property 8: Migration maps all role values correctly**
- Generate users with random roles from {admin, editor, viewer, NULL, arbitrary}, run migration against in-memory SQLite, verify mapping
- **Validates: Requirements 2.1, 2.2, 2.3, 2.5**
- [ ]* 1.3 Write property test for migration idempotency
- **Property 9: Migration is idempotent**
- Run migration N times (N in 1-5) against in-memory SQLite, verify schema and data identical each time
- **Validates: Requirements 2.4**
- [ ] 1.4 Write unit tests for migration
- Test column creation with correct CHECK constraint
- Test role mapping: admin to Admin, editor to Standard_User, viewer to Read_Only
- Test NULL and unrecognized role handling defaults to Read_Only
- Test new user defaults to Read_Only group
- _Requirements: 1.3, 1.4, 2.1, 2.2, 2.3, 2.5_
- [ ] 2. Update auth middleware to use groups
- [x] 2.1 Update `requireAuth` in `backend/middleware/auth.js`
- Modify session join query to SELECT user_group and attach as req.user.group
- _Requirements: 3.4_
- [x] 2.2 Add `requireGroup` middleware function
- Accept spread of allowed group names
- Return 401 if req.user is missing
- Return 403 with error details if user group not in allowed set
- Call next() if group is allowed
- _Requirements: 3.1, 3.2, 3.3_
- [x] 2.3 Remove `requireRole` and export `requireGroup`
- Remove requireRole function and its export
- Export requireGroup in its place
- _Requirements: 3.1_
- [ ]* 2.4 Write property test for group constraint
- **Property 1: Group constraint rejects invalid values**
- Generate random strings not in valid group set, attempt DB insert, verify constraint error
- **Validates: Requirements 1.1, 1.4**
- [ ]* 2.5 Write property test for requireGroup
- **Property 3: requireGroup rejects unauthorized groups**
- Generate random group and allowedGroups pairs where group is not in allowed set, verify 403
- **Validates: Requirements 3.3**
- [ ] 2.6 Write unit tests for requireGroup middleware
- Test 401 for unauthenticated requests
- Test 403 for wrong group
- Test group attached to req.user
- Test next() called for allowed group
- _Requirements: 3.2, 3.3, 3.4_
- [x] 3. Checkpoint: Verify migration and middleware
- Ensure all tests pass, ask the user if questions arise.
- [ ] 4. Update auth routes to return group
- [x] 4.1 Update login endpoint in `backend/routes/auth.js`
- Return group (from user_group) instead of role in user response object
- Update audit log details to log group instead of role
- _Requirements: 3.4, 9.2_
- [x] 4.2 Update me endpoint in `backend/routes/auth.js`
- Return group instead of role in user response object
- _Requirements: 3.4_
- [ ] 5. Update user management routes
- [x] 5.1 Switch `backend/routes/users.js` to use requireGroup
- Replace requireRole('admin') with requireGroup('Admin')
- _Requirements: 4.2, 4.3_
- [x] 5.2 Update GET endpoints to return user_group
- Return user_group instead of role in user records
- _Requirements: 8.1_
- [x] 5.3 Update POST create user to accept group param
- Validate group against valid values
- Default to Read_Only if not provided
- Return 400 for invalid group values
- _Requirements: 1.3, 8.2_
- [x] 5.4 Update PATCH update user to accept group param
- Validate group against valid values
- Prevent admin self-demotion (return 400)
- _Requirements: 8.2, 8.5_
- [x] 5.5 Add audit logging for group changes
- Log acting user ID, target user ID, previous group, new group, IP address, timestamp
- _Requirements: 9.1, 9.3_
- [ ]* 5.6 Write property test for user group validity
- **Property 2: Every user has exactly one valid group**
- Generate random user sets, query all users, verify each has exactly one valid group
- **Validates: Requirements 1.2**
- [ ] 5.7 Write unit tests for user management group logic
- Test group validation rejects invalid values
- Test self-demotion prevention
- Test audit logging includes all required fields
- _Requirements: 8.2, 8.5, 9.1, 9.3_
- [ ] 6. Update backend route authorization across all routes
- [x] 6.1 Update `backend/routes/auditLog.js`
- Replace requireRole('admin') with requireGroup('Admin')
- _Requirements: 4.2_
- [x] 6.2 Update `backend/routes/archerTickets.js`
- Use requireGroup('Admin', 'Standard_User') for create, update, delete
- _Requirements: 5.2_
- [x] 6.3 Update `backend/routes/knowledgeBase.js`
- Use requireGroup('Admin', 'Standard_User') for upload and delete
- _Requirements: 5.2_
- [x] 6.4 Update `backend/routes/ivantiFindings.js`
- Use requireGroup('Admin', 'Standard_User') for override endpoint
- _Requirements: 5.2_
- [x] 6.5 Update `backend/routes/compliance.js`
- Use requireGroup('Admin', 'Standard_User') for preview and commit
- _Requirements: 5.2_
- [x] 6.6 Update `backend/server.js` inline CVE routes
- Use requireGroup('Admin', 'Standard_User') for POST, PUT, PATCH, DELETE
- _Requirements: 5.2_
- [x] 6.7 Update `backend/server.js` route mounting
- Pass requireGroup instead of requireRole to route factories
- _Requirements: 3.1_
- [ ]* 6.8 Write property test for Leadership restrictions
- **Property 5: Leadership cannot mutate any resource**
- Generate random mutation requests as Leadership, verify 403
- **Validates: Requirements 6.3**
- [ ]* 6.9 Write property test for Read_Only restrictions
- **Property 6: Read_Only cannot mutate or export**
- Generate random mutation and export requests as Read_Only, verify 403
- **Validates: Requirements 7.2, 7.3**
- [x] 7. Checkpoint: Verify backend route authorization
- Ensure all tests pass, ask the user if questions arise.
- [ ] 8. Implement Standard User conditional delete logic
- [x] 8.1 Add created_by column tracking
- Add created_by to CVE, finding, and ticket creation endpoints storing req.user.id on insert
- _Requirements: 3.5_
- [x] 8.2 Implement ownership check for CVE delete
- Standard_User can only delete CVEs they created
- Return 403 if not owner
- _Requirements: 3.5_
- [x] 8.3 Implement cascade impact check for CVE delete
- Query associated Archer tickets and documents
- Check compliance linkage on cascaded tickets
- Return cascade_impact response schema
- Block deletion if any cascaded ticket is compliance-linked
- _Requirements: 3.8, 3.9_
- [x] 8.4 Implement state check for finding delete
- Standard_User cannot delete resolved or closed findings
- Return 403 with appropriate error message
- _Requirements: 3.6_
- [x] 8.5 Implement compliance linkage check for ticket delete
- Standard_User cannot delete tickets linked to compliance reports
- Return 403 with appropriate error message
- _Requirements: 3.7_
- [x] 8.6 Ensure Admin bypasses all delete restrictions
- Admin group skips ownership, state, and compliance checks
- _Requirements: 3.10, 4.5_
- [ ]* 8.7 Write property test for Admin delete bypass
- **Property 4: Admin bypasses all delete restrictions**
- Generate resources with random ownership, state, compliance linkage, delete as Admin, verify success
- **Validates: Requirements 3.10, 4.1, 4.5**
- [ ] 8.8 Write unit tests for conditional delete logic
- Test ownership rejection for non-owner
- Test state rejection for resolved/closed findings
- Test compliance linkage rejection
- Test cascade impact response format
- Test Admin bypass of all restrictions
- _Requirements: 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
- [x] 9. Checkpoint: Verify conditional delete logic
- Ensure all tests pass, ask the user if questions arise.
- [ ] 10. Update frontend AuthContext with group helpers
- [x] 10.1 Update `frontend/src/contexts/AuthContext.js`
- Read group from user object instead of role
- Replace hasRole with isInGroup(...groups) helper
- Update canWrite to check isInGroup('Admin', 'Standard_User')
- Add canDelete(resource) helper: Admin always true, Standard_User only if owns resource, others false
- Add canExport() helper: true for Admin, Standard_User, Leadership
- Update isAdmin() to check isInGroup('Admin')
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
- [ ]* 10.2 Write property test for permission helpers
- **Property 7: Group permission helpers are consistent with group matrix**
- Generate all valid group values, call each helper, verify against permission matrix
- **Validates: Requirements 10.5**
- [ ] 11. Update frontend UI for group-based rendering
- [x] 11.1 Update `App.js` conditional rendering
- Use canWrite, canDelete, canExport, isAdmin for button and link visibility
- _Requirements: 10.1, 10.2, 10.3_
- [x] 11.2 Update `NavDrawer.js`
- Show admin panel link only when isAdmin() is true
- _Requirements: 10.3_
- [x] 11.3 Update `UserMenu.js`
- Display user group instead of role
- _Requirements: 10.1_
- [x] 11.4 Update all components using hasRole or canWrite
- Replace with new group-based helpers throughout components
- _Requirements: 10.5_
- [x] 11.5 Hide delete buttons for non-owned resources
- Standard_User sees delete only on resources they created
- _Requirements: 10.4_
- [ ] 12. Update User Management UI
- [x] 12.1 Replace role dropdown with group dropdown in `UserManagement.js`
- Options: Admin, Standard_User, Leadership, Read_Only
- _Requirements: 8.1, 8.2_
- [x] 12.2 Update form data and API calls to use group field
- Send group instead of role in create and update requests
- _Requirements: 8.2_
- [x] 12.3 Add confirmation dialog for group changes
- Show confirmation before applying any group change
- _Requirements: 8.3_
- [x] 12.4 Add extra warning when downgrading Admin
- Show additional warning in confirmation dialog
- _Requirements: 8.4_
- [x] 12.5 Prevent admin self-demotion in UI
- Disable group change dropdown for current user if Admin
- _Requirements: 8.5_
- [x] 12.6 Update user table to show group badges
- Display group badge with appropriate colors instead of role badge
- _Requirements: 8.1_
- [x] 13. Final checkpoint: Verify full integration
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests use `fast-check` library with minimum 100 iterations per test
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
- All frontend code uses plain JavaScript (no TypeScript)

View File

@@ -12,7 +12,7 @@ function requireAuth(db) {
try { try {
const session = await new Promise((resolve, reject) => { const session = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -37,7 +37,8 @@ function requireAuth(db) {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
role: session.role role: session.role,
group: session.user_group
}; };
next(); next();
@@ -48,18 +49,18 @@ function requireAuth(db) {
}; };
} }
// Require specific role(s) // Require specific group(s)
function requireRole(...allowedRoles) { function requireGroup(...allowedGroups) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
if (!allowedRoles.includes(req.user.role)) { if (!allowedGroups.includes(req.user.group)) {
return res.status(403).json({ return res.status(403).json({
error: 'Insufficient permissions', error: 'Insufficient permissions',
required: allowedRoles, required: allowedGroups,
current: req.user.role current: req.user.group
}); });
} }
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
}; };
} }
module.exports = { requireAuth, requireRole }; module.exports = { requireAuth, requireGroup };

View File

@@ -0,0 +1,76 @@
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
// Stores the user ID of the creator for ownership-based delete checks.
// Idempotent — safe to run multiple times.
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
let completed = 0;
db.serialize(() => {
tables.forEach((table) => {
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
if (err) {
// Table may not exist yet — skip gracefully
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
const hasCreatedBy = columns.some(col => col.name === 'created_by');
if (hasCreatedBy) {
console.log(`${table}.created_by already exists — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
db.run(
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
(err) => {
if (err) {
reject(err);
return;
}
console.log(`✓ Added created_by column to ${table}`);
completed++;
if (completed === tables.length) resolve();
}
);
});
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_created_by_columns migration...');
runMigration(db)
.then(() => {
console.log('Migration complete!');
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -0,0 +1,146 @@
// Migration: Add user_group column to users table and map legacy roles
// Mapping: admin→Admin, editor→Standard_User, viewer→Read_Only
// NULL/unrecognized roles default to Read_Only
// Idempotent — safe to run multiple times
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Check if user_group column already exists
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
reject(err);
return;
}
const hasUserGroup = columns.some(col => col.name === 'user_group');
if (hasUserGroup) {
console.log('✓ user_group column already exists — skipping migration');
resolve();
return;
}
console.log('Adding user_group column to users table...');
// SQLite doesn't support ADD COLUMN with CHECK inline in all versions,
// so we add the column first, map values, then recreate with constraint.
// However, SQLite also doesn't support ALTER TABLE ADD CONSTRAINT.
// Strategy: add column, map values, create index.
// The CHECK constraint is enforced via table rebuild.
db.run(
`ALTER TABLE users ADD COLUMN user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'`,
(err) => {
if (err) {
reject(err);
return;
}
console.log('✓ Added user_group column');
// Map existing roles to groups
db.run(
`UPDATE users SET user_group = 'Admin' WHERE role = 'admin'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} admin(s) → Admin`);
db.run(
`UPDATE users SET user_group = 'Standard_User' WHERE role = 'editor'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} editor(s) → Standard_User`);
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE role = 'viewer'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} viewer(s) → Read_Only`);
// Map NULL or unrecognized roles to Read_Only
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE user_group = 'Read_Only' AND role NOT IN ('admin', 'editor', 'viewer')`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} unrecognized role(s) → Read_Only`);
// Create index on user_group
db.run(
`CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group)`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created idx_users_user_group index');
// Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT)
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_insert
BEFORE INSERT ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_update
BEFORE UPDATE OF user_group ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created user_group validation triggers');
console.log('Migration complete!');
resolve();
}
);
}
);
}
);
}
);
}
);
}
);
}
);
}
);
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_user_groups migration...');
runMigration(db)
.then(() => {
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -1,6 +1,6 @@
// routes/archerTickets.js // routes/archerTickets.js
const express = require('express'); const express = require('express');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
// Validation helpers // Validation helpers
@@ -48,7 +48,7 @@ function createArcherTicketsRouter(db) {
}); });
// Create Archer ticket // Create Archer ticket
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { exc_number, archer_url, status, cve_id, vendor } = req.body; const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation // Validation
@@ -74,9 +74,9 @@ function createArcherTicketsRouter(db) {
const validatedStatus = status || 'Draft'; const validatedStatus = status || 'Draft';
db.run( db.run(
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor) `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor], [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
function(err) { function(err) {
if (err) { if (err) {
console.error('Error creating Archer ticket:', err); console.error('Error creating Archer ticket:', err);
@@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'CREATE_ARCHER_TICKET', action: 'CREATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: this.lastID, entityId: String(this.lastID),
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -104,7 +104,7 @@ function createArcherTicketsRouter(db) {
}); });
// Update Archer ticket // Update Archer ticket
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { exc_number, archer_url, status } = req.body; const { exc_number, archer_url, status } = req.body;
@@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'UPDATE_ARCHER_TICKET', action: 'UPDATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: id, entityId: String(id),
details: { before: existing, changes: req.body }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -184,8 +184,29 @@ function createArcherTicketsRouter(db) {
}); });
}); });
// Helper: perform the actual Archer ticket deletion
function performArcherDelete(db, req, res, id, ticket) {
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'DELETE_ARCHER_TICKET',
entityType: 'archer_ticket',
entityId: String(id),
details: { deleted: ticket },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket deleted successfully' });
});
}
// Delete Archer ticket // Delete Archer ticket
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => { db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
@@ -197,23 +218,45 @@ function createArcherTicketsRouter(db) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) { // Admin bypasses all delete restrictions
if (err) { if (req.user.group === 'Admin') {
console.error(err); return performArcherDelete(db, req, res, id, ticket);
}
// Standard_User: ownership check
if (ticket.created_by && ticket.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Standard_User: compliance linkage check
const excNumber = ticket.exc_number;
db.all(
`SELECT ci.id, ci.extra_json
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
[`%${excNumber}%`],
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' }); return res.status(500).json({ error: 'Internal server error.' });
} }
logAudit(db, { const isLinked = (compLinks || []).some(cl => {
userId: req.user.id, const json = cl.extra_json || '';
action: 'DELETE_ARCHER_TICKET', return json.includes(excNumber);
targetType: 'archer_ticket',
targetId: id,
details: { deleted: ticket },
ipAddress: req.ip
}); });
res.json({ message: 'Archer ticket deleted successfully' }); if (isLinked) {
}); return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performArcherDelete(db, req, res, id, ticket);
}
);
}); });
}); });

View File

@@ -1,11 +1,11 @@
// Audit Log Routes (Admin only) // Audit Log Routes (Admin only)
const express = require('express'); const express = require('express');
function createAuditLogRouter(db, requireAuth, requireRole) { function createAuditLogRouter(db, requireAuth, requireGroup) {
const router = express.Router(); const router = express.Router();
// All routes require admin role // All routes require Admin group
router.use(requireAuth(db), requireRole('admin')); router.use(requireAuth(db), requireGroup('Admin'));
// Get paginated audit logs with filters // Get paginated audit logs with filters
router.get('/', async (req, res) => { router.get('/', async (req, res) => {

View File

@@ -2,6 +2,7 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const { requireAuth, requireGroup } = require('../middleware/auth');
function createAuthRouter(db, logAudit) { function createAuthRouter(db, logAudit) {
const router = express.Router(); const router = express.Router();
@@ -110,7 +111,7 @@ function createAuthRouter(db, logAudit) {
action: 'login', action: 'login',
entityType: 'auth', entityType: 'auth',
entityId: null, entityId: null,
details: { role: user.role }, details: { group: user.user_group },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -120,7 +121,7 @@ function createAuthRouter(db, logAudit) {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
role: user.role group: user.user_group
} }
}); });
} catch (err) { } catch (err) {
@@ -183,7 +184,7 @@ function createAuthRouter(db, logAudit) {
try { try {
const session = await new Promise((resolve, reject) => { const session = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -210,7 +211,7 @@ function createAuthRouter(db, logAudit) {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
role: session.role group: session.user_group
} }
}); });
} catch (err) { } catch (err) {
@@ -220,12 +221,7 @@ function createAuthRouter(db, logAudit) {
}); });
// Clean up expired sessions (admin only) // Clean up expired sessions (admin only)
router.post('/cleanup-sessions', async (req, res) => { router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), 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(

View File

@@ -213,7 +213,7 @@ function groupByHostname(rows, noteHostnames) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createComplianceRouter(db, upload, requireAuth, requireRole) { function createComplianceRouter(db, upload, requireAuth, requireGroup) {
const router = express.Router(); const router = express.Router();
// Idempotent column additions — errors mean column already exists, which is fine // Idempotent column additions — errors mean column already exists, which is fine
@@ -228,7 +228,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON. // Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
// Returns diff counts + tempFile path for the commit step. // Returns diff counts + tempFile path for the commit step.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/preview', requireRole('editor', 'admin'), (req, res) => { router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
upload.single('file')(req, res, async (uploadErr) => { upload.single('file')(req, res, async (uploadErr) => {
if (uploadErr) { if (uploadErr) {
return res.status(400).json({ error: uploadErr.message }); return res.status(400).json({ error: uploadErr.message });
@@ -291,7 +291,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Commit a previewed upload to the DB. // Commit a previewed upload to the DB.
// Body: { tempFile, filename, report_date } // Body: { tempFile, filename, report_date }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => { router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { tempFile, filename, report_date } = req.body; const { tempFile, filename, report_date } = req.body;
if (!tempFile || typeof tempFile !== 'string') { if (!tempFile || typeof tempFile !== 'string') {
@@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Add a note to a (hostname, metric_id) pair. // Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note } // Body: { hostname, metric_id, note }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/notes', async (req, res) => { router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, note } = req.body; const { hostname, metric_id, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) { if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {

View File

@@ -4,7 +4,7 @@
const express = require('express'); const express = require('express');
const https = require('https'); const https = require('https');
const { requireRole } = require('../middleware/auth'); const { requireGroup } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -829,7 +829,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// POST /sync — trigger immediate sync, return fresh state // POST /sync — trigger immediate sync, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(db); await syncFindings(db);
try { try {
res.json(await readStateWithNotes(db)); res.json(await readStateWithNotes(db));
@@ -899,7 +899,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
// PUT /:findingId/override — save or clear a field override (editor/admin only) // PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns']; const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => { router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const { field, value } = req.body; const { field, value } = req.body;
@@ -934,7 +934,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// PUT /:findingId/note — save or update a note (max 255 chars enforced here) // PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255); const note = String(req.body.note || '').slice(0, 255);

View File

@@ -1,5 +1,6 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete']; const VALID_STATUSES = ['pending', 'complete'];
@@ -36,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// POST /api/ivanti/todo-queue // POST /api/ivanti/todo-queue
// Add a finding to the queue // Add a finding to the queue
router.post('/', requireAuth(db), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
@@ -86,7 +87,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// PUT /api/ivanti/todo-queue/:id // PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user // Update vendor, workflow_type, or status — scoped to current user
router.put('/:id', requireAuth(db), (req, res) => { router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { vendor, workflow_type, status } = req.body; const { vendor, workflow_type, status } = req.body;
@@ -162,7 +163,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/completed // DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user // Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id // IMPORTANT: This route must be registered BEFORE DELETE /:id
router.delete('/completed', requireAuth(db), (req, res) => { router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
db.run( db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id], [req.user.id],
@@ -178,7 +179,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/:id // DELETE /api/ivanti/todo-queue/:id
// Delete a single item — scoped to current user // Delete a single item — scoped to current user
router.delete('/:id', requireAuth(db), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get( db.get(

View File

@@ -5,6 +5,7 @@
const express = require('express'); const express = require('express');
const https = require('https'); const https = require('https');
const { requireGroup } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -259,7 +260,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
}); });
// POST /sync — trigger an immediate sync, await completion, return fresh state // POST /sync — trigger an immediate sync, await completion, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db); await syncWorkflows(db);
try { try {
res.json(await readState(db)); res.json(await readState(db));

View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
function createKnowledgeBaseRouter(db, upload) { function createKnowledgeBaseRouter(db, upload) {
@@ -39,8 +39,20 @@ function createKnowledgeBaseRouter(db, upload) {
return ALLOWED_EXTENSIONS.has(ext); 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) => { * POST /api/knowledge-base/upload
* Upload a new knowledge base document.
*
* @body {string} title - Article title (required)
* @body {string} [description] - Article description
* @body {string} [category] - Article category (defaults to 'General')
* @body {File} file - The document file to upload (multipart/form-data)
*
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
* @response 400 - { error: string } - Missing title, no file, or invalid file type
* @response 500 - { error: string } - Database or filesystem error
*/
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('[KB Upload] Multer error:', err); console.error('[KB Upload] Multer error:', err);
@@ -132,16 +144,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'CREATE_KB_ARTICLE',
'CREATE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(this.lastID),
this.lastID, details: { title: title.trim(), filename: sanitizedName },
JSON.stringify({ title: title.trim(), filename: sanitizedName }), ipAddress: req.ip
req.ip });
);
res.json({ res.json({
success: true, success: true,
@@ -161,7 +172,13 @@ function createKnowledgeBaseRouter(db, upload) {
} }
}); });
// GET /api/knowledge-base - List all articles /**
* GET /api/knowledge-base
* List all knowledge base articles.
*
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
* @response 500 - { error: string }
*/
router.get('/', requireAuth(db), (req, res) => { router.get('/', requireAuth(db), (req, res) => {
const sql = ` const sql = `
SELECT SELECT
@@ -183,7 +200,16 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// GET /api/knowledge-base/:id - Get single article details /**
* GET /api/knowledge-base/:id
* Get a single article's details by ID.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.get('/:id', requireAuth(db), (req, res) => { router.get('/:id', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -211,7 +237,17 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// GET /api/knowledge-base/:id/content - Get document content for display /**
* GET /api/knowledge-base/:id/content
* Get document content for inline display. Returns the raw file with appropriate
* Content-Type headers. Markdown and text files are served as text/plain.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/content', requireAuth(db), (req, res) => { router.get('/:id/content', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -232,16 +268,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'VIEW_KB_ARTICLE',
'VIEW_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
// Determine content type for inline display // Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream'; let contentType = row.file_type || 'application/octet-stream';
@@ -263,7 +298,16 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// GET /api/knowledge-base/:id/download - Download document /**
* GET /api/knowledge-base/:id/download
* Download a knowledge base document as an attachment.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - File download with Content-Disposition: attachment header
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/download', requireAuth(db), (req, res) => { router.get('/:id/download', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -284,16 +328,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DOWNLOAD_KB_ARTICLE',
'DOWNLOAD_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
@@ -301,11 +344,22 @@ function createKnowledgeBaseRouter(db, upload) {
}); });
}); });
// DELETE /api/knowledge-base/:id - Delete article /**
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => { * DELETE /api/knowledge-base/:id
* Delete a knowledge base article and its associated file.
* Standard_User can only delete articles they created. Admin can delete any article.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { success: true }
* @response 403 - { error: string } - Ownership check failed for Standard_User
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?'; const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
db.get(sql, [id], (err, row) => { db.get(sql, [id], (err, row) => {
if (err) { if (err) {
@@ -317,6 +371,11 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(404).json({ error: 'Article not found' }); return res.status(404).json({ error: 'Article not found' });
} }
// Ownership check: Standard_User can only delete articles they created
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Delete database record // Delete database record
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
if (err) { if (err) {
@@ -330,16 +389,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DELETE_KB_ARTICLE',
'DELETE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { title: row.title },
JSON.stringify({ title: row.title }), ipAddress: req.ip
req.ip });
);
res.json({ success: true }); res.json({ success: true });
}); });

View File

@@ -2,18 +2,18 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
function createUsersRouter(db, requireAuth, requireRole, logAudit) { function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const router = express.Router(); const router = express.Router();
// All routes require admin role // All routes require Admin group
router.use(requireAuth(db), requireRole('admin')); router.use(requireAuth(db), requireGroup('Admin'));
// Get all users // Get all users
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const users = await new Promise((resolve, reject) => { const users = await new Promise((resolve, reject) => {
db.all( db.all(
`SELECT id, username, email, role, is_active, created_at, last_login `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users ORDER BY created_at DESC`, FROM users ORDER BY created_at DESC`,
(err, rows) => { (err, rows) => {
if (err) reject(err); if (err) reject(err);
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
try { try {
const user = await new Promise((resolve, reject) => { const user = await new Promise((resolve, reject) => {
db.get( db.get(
`SELECT id, username, email, role, is_active, created_at, last_login `SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users WHERE id = ?`, FROM users WHERE id = ?`,
[req.params.id], [req.params.id],
(err, row) => { (err, row) => {
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Create new user // Create new user
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { username, email, password, role } = req.body; const { username, email, password, group } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
if (!username || !email || !password) { if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' }); return res.status(400).json({ error: 'Username, email, and password are required' });
} }
if (role && !['admin', 'editor', 'viewer'].includes(role)) { const userGroup = group || 'Read_Only';
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
if (!VALID_GROUPS.includes(userGroup)) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
} }
try { try {
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
db.run( db.run(
`INSERT INTO users (username, email, password_hash, role) `INSERT INTO users (username, email, password_hash, user_group)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, role || 'viewer'], [username, email, passwordHash, userGroup],
function(err) { function(err) {
if (err) reject(err); if (err) reject(err);
else resolve({ id: this.lastID }); else resolve({ id: this.lastID });
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
action: 'user_create', action: 'user_create',
entityType: 'user', entityType: 'user',
entityId: String(result.id), entityId: String(result.id),
details: { created_username: username, role: role || 'viewer' }, details: { created_username: username, group: userGroup },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
id: result.id, id: result.id,
username, username,
email, email,
role: role || 'viewer' group: userGroup
} }
}); });
} catch (err) { } catch (err) {
@@ -111,12 +114,18 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Update user // Update user
router.patch('/:id', async (req, res) => { router.patch('/:id', async (req, res) => {
const { username, email, password, role, is_active } = req.body; const { username, email, password, group, is_active } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const userId = req.params.id; const userId = req.params.id;
// Prevent self-demotion from admin // Validate group if provided
if (userId == req.user.id && role && role !== 'admin') { if (group && !VALID_GROUPS.includes(group)) {
return res.status(400).json({ error: 'Cannot remove your own admin role' }); return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
}
// Prevent admin self-demotion
if (userId == req.user.id && group && group !== 'Admin') {
return res.status(400).json({ error: 'Cannot remove your own admin group' });
} }
// Prevent self-deactivation // Prevent self-deactivation
@@ -125,6 +134,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
} }
try { try {
// Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => {
db.get(
'SELECT user_group FROM users WHERE id = ?',
[userId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!currentUser) {
return res.status(404).json({ error: 'User not found' });
}
const updates = []; const updates = [];
const values = []; const values = [];
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
updates.push('password_hash = ?'); updates.push('password_hash = ?');
values.push(passwordHash); values.push(passwordHash);
} }
if (role) { if (group) {
if (!['admin', 'editor', 'viewer'].includes(role)) { updates.push('user_group = ?');
return res.status(400).json({ error: 'Invalid role' }); values.push(group);
}
updates.push('role = ?');
values.push(role);
} }
if (typeof is_active === 'boolean') { if (typeof is_active === 'boolean') {
updates.push('is_active = ?'); updates.push('is_active = ?');
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const updatedFields = {}; const updatedFields = {};
if (username) updatedFields.username = username; if (username) updatedFields.username = username;
if (email) updatedFields.email = email; if (email) updatedFields.email = email;
if (role) updatedFields.role = role; if (group) updatedFields.group = group;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true; if (password) updatedFields.password_changed = true;
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
ipAddress: req.ip ipAddress: req.ip
}); });
// Log specific audit entry for group changes
if (group && group !== currentUser.user_group) {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_group_change',
entityType: 'user',
entityId: String(userId),
details: {
previous_group: currentUser.user_group,
new_group: group
},
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) => {

View File

@@ -12,7 +12,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Auth imports // Auth imports
const { requireAuth, requireRole } = require('./middleware/auth'); const { requireAuth, requireGroup } = 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 createAuditLogRouter = require('./routes/auditLog');
@@ -161,10 +161,10 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
app.use('/api/auth', createAuthRouter(db, logAudit)); 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, logAudit)); app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
// Audit log routes (admin only) // Audit log routes (admin only)
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole)); app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
// NVD lookup routes (authenticated users) // NVD lookup routes (authenticated users)
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth)); app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
@@ -224,7 +224,7 @@ app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth)); app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes // AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole)); app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
@@ -353,7 +353,7 @@ app.get('/api/cves/compliance', 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), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, severity, description, published_date } = req.body; const { cve_id, vendor, severity, description, published_date } = req.body;
// Input validation // Input validation
@@ -374,11 +374,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
} }
const query = ` const query = `
INSERT INTO cves (cve_id, vendor, severity, description, published_date) INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`; `;
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) { db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
if (err) { if (err) {
console.error('DATABASE ERROR:', err); console.error('DATABASE ERROR:', err);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
@@ -407,7 +407,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
// Update CVE status (editor or admin) // Update CVE status (editor or admin)
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
const { status } = req.body; const { status } = req.body;
@@ -435,7 +435,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
}); });
// Bulk sync CVE data from NVD (editor or admin) // Bulk sync CVE data from NVD (editor or admin)
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { updates } = req.body; const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) { if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' }); return res.status(400).json({ error: 'No updates provided' });
@@ -505,7 +505,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
// ========== CVE EDIT & DELETE ENDPOINTS ========== // ========== CVE EDIT & DELETE ENDPOINTS ==========
// Edit single CVE entry (editor or admin) // Edit single CVE entry (editor or admin)
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { cve_id, vendor, severity, description, published_date, status } = req.body; const { cve_id, vendor, severity, description, published_date, status } = req.body;
@@ -649,7 +649,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
}); });
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route // 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) => { app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params; const { cveId } = req.params;
// Get all rows for this CVE ID to know what we're deleting // Get all rows for this CVE ID to know what we're deleting
@@ -657,6 +657,151 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } 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' }); if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User') {
const notOwned = rows.some(row => row.created_by !== req.user.id);
if (notOwned) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Cascade impact check for Standard_User
// Query all three cascade-deleted resource types in parallel
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
// If jira_tickets table doesn't exist yet, treat as empty
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
jiraTickets = [];
} else if (jiraErr) {
console.error(jiraErr);
return res.status(500).json({ error: 'Internal server error.' });
}
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
// If no tickets at all, no compliance linkage possible — return cascade info
if (allTickets.length === 0) {
return res.json({
cascade_impact: {
archer_tickets: [],
jira_tickets: [],
documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })),
blocked: false,
blocked_reason: null
}
});
}
// Check compliance linkage for each ticket
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
// appears in active compliance_items extra_json
const likeConditions = [];
const likeParams = [];
for (const t of allTickets) {
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${t.key}%`);
}
// Also check if the CVE ID itself appears in compliance extra_json
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${cveId}%`);
db.all(
`SELECT ci.id, ci.extra_json, cu.report_date
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
// Determine which tickets are compliance-linked by checking extra_json matches
const linkedTicketKeys = new Set();
for (const cl of (compLinks || [])) {
const json = cl.extra_json || '';
for (const t of allTickets) {
if (json.includes(t.key)) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
// If CVE ID itself is in compliance data, all tickets are considered linked
if (json.includes(cveId)) {
for (const t of allTickets) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
}
const archerTicketsResult = (archerTickets || []).map(t => ({
id: t.id,
exc_number: t.exc_number,
compliance_linked: linkedTicketKeys.has(`archer:${t.id}`)
}));
const jiraTicketsResult = (jiraTickets || []).map(t => ({
id: t.id,
ticket_key: t.ticket_key,
compliance_linked: linkedTicketKeys.has(`jira:${t.id}`)
}));
const documentsResult = (docs || []).map(d => ({
id: d.id,
name: d.name,
type: d.type
}));
const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked)
|| jiraTicketsResult.some(t => t.compliance_linked);
if (hasComplianceLink) {
const blockedArcher = archerTicketsResult.find(t => t.compliance_linked);
const blockedJira = jiraTicketsResult.find(t => t.compliance_linked);
const blockedLabel = blockedArcher
? `Archer ticket ${blockedArcher.exc_number}`
: `JIRA ticket ${blockedJira.ticket_key}`;
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: true,
blocked_reason: `${blockedLabel} is linked to a compliance report`
}
});
}
// Not blocked — return cascade impact for frontend warning
return res.json({
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: false,
blocked_reason: null
}
});
}
);
});
});
});
return; // Exit early — Standard_User flow handled above
}
// Admin flow: proceed directly with deletion (no cascade check)
// Delete all documents from DB // Delete all documents from DB
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => { db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
if (docErr) console.error('Error deleting documents:', docErr); if (docErr) console.error('Error deleting documents:', docErr);
@@ -689,13 +834,71 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
}); });
// Delete single CVE vendor entry (editor or admin) // Delete single CVE vendor entry (editor or admin)
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => { 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 (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' }); if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Cascade/compliance check for Standard_User
if (req.user.group === 'Standard_User') {
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
if (allTickets.length === 0) {
return doSingleCveDelete(req, res, id, cve);
}
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
const likeParams = allTickets.map(t => `%${t.key}%`);
db.all(
`SELECT ci.id, ci.extra_json FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(compErr, compLinks) => {
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
const hasLink = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return allTickets.some(t => json.includes(t.key));
});
if (hasLink) {
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
});
}
return doSingleCveDelete(req, res, id, cve);
}
);
});
});
}
doSingleCveDelete(req, res, id, cve);
});
function doSingleCveDelete(req, res, id, cve) {
// Delete associated documents from DB // 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) => { 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); if (docErr) console.error('Error fetching documents:', docErr);
@@ -742,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
}); });
}); });
}); });
}); }
}); });
// ========== DOCUMENT ENDPOINTS ========== // ========== DOCUMENT ENDPOINTS ==========
@@ -771,7 +974,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
}); });
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin) // Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => { app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('Upload error:', err.message); console.error('Upload error:', err.message);
@@ -879,7 +1082,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
}); });
}); });
// Delete document (admin only) // Delete document (admin only)
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => { app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
const { id } = req.params; const { id } = req.params;
// First get the file path to delete the actual file // First get the file path to delete the actual file
@@ -981,7 +1184,7 @@ app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
}); });
// Create JIRA ticket // Create JIRA ticket
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body; const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
// Validation // Validation
@@ -1007,11 +1210,11 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
const ticketStatus = status || 'Open'; const ticketStatus = status || 'Open';
const query = ` const query = `
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status) INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) { db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
if (err) { if (err) {
console.error('Error creating JIRA ticket:', err); console.error('Error creating JIRA ticket:', err);
return res.status(500).json({ error: 'Internal server error.' }); return res.status(500).json({ error: 'Internal server error.' });
@@ -1035,7 +1238,7 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
}); });
// Update JIRA ticket // Update JIRA ticket
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ticket_key, url, summary, status } = req.body; const { ticket_key, url, summary, status } = req.body;
@@ -1100,7 +1303,7 @@ app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin')
}); });
// Delete JIRA ticket // Delete JIRA ticket
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
@@ -1112,6 +1315,47 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
// Admin bypasses all delete restrictions
if (req.user.group === 'Admin') {
return performJiraDelete();
}
// Standard_User: ownership check
if (ticket.created_by && ticket.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Standard_User: compliance linkage check
const ticketKey = ticket.ticket_key;
db.all(
`SELECT ci.id, ci.extra_json
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
[`%${ticketKey}%`],
(compErr, compLinks) => {
// If compliance_items table doesn't exist yet, treat as no linkage
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return json.includes(ticketKey);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performJiraDelete();
}
);
function performJiraDelete() {
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) { db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
if (deleteErr) { if (deleteErr) {
console.error('Error deleting JIRA ticket:', deleteErr); console.error('Error deleting JIRA ticket:', deleteErr);
@@ -1130,6 +1374,7 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
res.json({ message: 'JIRA ticket deleted successfully' }); res.json({ message: 'JIRA ticket deleted successfully' });
}); });
}
}); });
}); });

View File

@@ -0,0 +1,617 @@
# Security Audit Report — STEAM Security Dashboard
**Date:** 2026-04-01
**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend
**Methodology:** Static analysis across four parallel audit tracks
---
## Executive Summary
The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.
| Severity | Count |
|----------|-------|
| Critical | 6 |
| High | 9 |
| Medium | 10 |
| Low / Info | 6 |
| **Total** | **31** |
The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.
---
## Critical Findings
---
### C-1 — Missing Authentication on Ivanti Findings Endpoints
**File:** `backend/routes/ivantiFindings.js:552600`
The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:
```js
const { requireRole } = require('../middleware/auth'); // requireAuth never imported
router.get('/', async (req, res) => { // line 552 — no auth
router.post('/sync', async (req, res) => { // line 561 — no auth
router.get('/counts', async (req, res) => { // line 571 — no auth
router.get('/fp-workflow-counts', ...) // line 580 — no auth
```
**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.
**Fix:** Import `requireAuth` and apply it to the router or each route:
```js
const { requireAuth, requireRole } = require('../middleware/auth');
router.use(requireAuth(db));
```
---
### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base
**File:** `backend/routes/knowledgeBase.js:43, 305`
`requireRole` is called with `db` as the first argument:
```js
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
```
The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents.
**Fix:** Remove `db` from all `requireRole` calls:
```js
requireRole('editor', 'admin')
```
---
### C-3 — Unauthenticated Ivanti Finding Note Writes
**File:** `backend/routes/ivantiFindings.js:639`
The PUT endpoint for saving finding notes has no authentication middleware:
```js
router.put('/:findingId/note', (req, res) => {
const note = String(req.body.note || '').slice(0, 255);
db.run(`INSERT INTO ivanti_finding_notes ...`);
});
```
**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.
**Fix:** Add `requireAuth(db)` to this route.
---
### C-4 — No Brute Force Protection on Login Endpoint
**File:** `backend/routes/auth.js:10`
The login endpoint has no rate limiting, attempt counting, or lockout:
```js
router.post('/login', async (req, res) => {
const { username, password } = req.body;
// Direct DB lookup, unlimited attempts
```
**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.
**Fix:** Apply `express-rate-limit` to the login route:
```js
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
router.post('/login', loginLimiter, async (req, res) => { ... });
```
---
### C-5 — Default Credentials Displayed in Login UI
**File:** `frontend/src/components/LoginForm.js:104`
The login form renders hardcoded credentials in plain text:
```jsx
<p className="text-sm text-gray-500 text-center font-mono">
Default: <span className="text-intel-accent">admin</span> /
<span className="text-intel-accent">admin123</span>
</p>
```
**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.
**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.
---
### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe
**File:** `frontend/src/components/KnowledgeBaseViewer.js:195`
The inline document viewer renders uploaded files in an unsandboxed iframe:
```jsx
<iframe
src={`${API_BASE}/knowledge-base/${article.id}/content`}
title={article.title}
className="w-full h-full rounded"
>
```
**Impact:** A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing `localStorage`, `sessionStorage`, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.
**Fix:** Add a restrictive `sandbox` attribute:
```jsx
<iframe
sandbox="allow-same-origin allow-scripts"
src={...}
title={article.title}
/>
```
---
## High Findings
---
### H-1 — /cleanup-sessions Missing Role Check
**File:** `backend/routes/auth.js:223`
The comment says "admin only" but the endpoint only checks for any valid session:
```js
router.post('/cleanup-sessions', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) return res.status(401).json({ error: '...' });
// No role check
```
**Fix:** Apply `requireAuth(db)` and `requireRole('admin')`.
---
### H-2 — Hardcoded Fallback SESSION_SECRET
**File:** `backend/server.js:31`
```js
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
```
If the `.env` file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.
**Fix:** Fail hard on startup if the secret is not set:
```js
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');
```
---
### H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps
**Files:** `backend/routes/archerTickets.js:8995, 172, 206` and `backend/routes/knowledgeBase.js:235244, 287296`
The `logAudit` helper expects an object with `entityType` and `entityId`. These callers use the wrong keys (`targetType`, `targetId`) or pass positional arguments instead of an object:
```js
// archerTickets.js — wrong keys
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });
// knowledgeBase.js — positional (wrong pattern)
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);
```
**Impact:** All Archer ticket and Knowledge Base operations produce audit log rows with `NULL` entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.
**Fix:** Align all callers to the object format expected by `auditLog.js`:
```js
logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });
```
---
### H-4 — Viewers Can Write Compliance Notes
**Files:** `backend/routes/compliance.js:522` (also flagged by file-upload audit)
The POST /notes endpoint is protected by authentication but not by role:
```js
router.post('/notes', async (req, res) => { // no requireRole()
```
**Impact:** Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.
**Fix:** `requireRole('editor', 'admin')` on this route.
---
### H-5 — Sync Endpoints Accessible to All Authenticated Users
**Files:** `backend/routes/ivantiFindings.js:561`, `backend/routes/ivantiWorkflows.js:262`
POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.
**Impact:** Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.
**Fix:** Add `requireRole('editor', 'admin')` to both POST /sync routes.
---
### H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition
**File:** `backend/routes/knowledgeBase.js:258, 299`
The original uploaded filename (user-controlled) is written directly into the `Content-Disposition` response header:
```js
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
```
`row.file_name` stores `uploadedFile.originalname` which is not sanitized for use in HTTP headers. A filename containing `"\r\n` characters can split the response and inject arbitrary headers.
**Fix:**
```js
const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
```
---
### H-7 — Race Condition in Knowledge Base File Upload
**File:** `backend/routes/knowledgeBase.js:91155`
The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.
**Fix:** Keep the file in the temp directory until the DB insert succeeds, then move it:
```js
db.run(insertSql, [...], function(err) {
if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
fs.renameSync(uploadedFile.path, filePath);
res.json({ success: true });
});
```
---
### H-8 — Hardcoded Default Admin Password in setup.js
**File:** `backend/setup.js:175`
```js
const passwordHash = await bcrypt.hash('admin123', 10);
```
If `setup.js` is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).
**Fix:** Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.
---
### H-9 — ReactMarkdown Renders HTML Without Sanitization
**File:** `frontend/src/components/KnowledgeBaseViewer.js:169171`
```jsx
<ReactMarkdown>{content}</ReactMarkdown>
```
`ReactMarkdown` by default allows raw HTML in markdown (via `rehype-raw`). A knowledge base article containing `<img src=x onerror="...">` or `<script>` tags would execute JavaScript in the viewer's browser.
**Fix:** Add `rehype-sanitize`:
```jsx
import rehypeSanitize from 'rehype-sanitize';
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>
```
---
## Medium Findings
---
### M-1 — No CSRF Token Protection on State-Changing Requests
**Files:** All POST / PUT / DELETE routes
Cookies are `SameSite: lax` which provides partial protection, but `lax` still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.
**Fix:** Either upgrade session cookie to `SameSite: strict`, or implement a CSRF token (double-submit cookie pattern or `csurf` middleware).
---
### M-2 — CORS Allows Credentials with Explicit Origin List
**File:** `backend/server.js:111114`
```js
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
```
`credentials: true` with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.
**Fix:** Ensure `CORS_ORIGINS` is reviewed whenever the deployment changes. Consider `SameSite: strict` on cookies to reduce reliance on CORS for CSRF protection.
---
### M-3 — No Rate Limiting on NVD API Proxy
**File:** `backend/routes/nvdLookup.js:13`
Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.
**Fix:** Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.
---
### M-4 — Admin Self-Demotion Check Uses Loose Equality
**File:** `backend/routes/users.js:118`
```js
if (userId == req.user.id && role && role !== 'admin') {
```
Using `==` allows type coercion. If `userId` is passed as a different type than `req.user.id`, the comparison may not match correctly.
**Fix:** `String(userId) === String(req.user.id)`.
---
### M-5 — Missing Hostname Format Validation
**File:** `backend/routes/compliance.js:451`
The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.
**Fix:**
```js
if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname format' });
}
```
---
### M-6 — Vendor Field Validated Before Trim
**File:** `backend/routes/ivantiTodoQueue.js:8, 56`
Vendor length is validated before `.trim()` is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.
**Fix:** Trim first, then validate length and presence.
---
### M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON
**File:** `backend/routes/compliance.js:262`
```js
filename: req.file.originalname, // user-controlled, unsanitized
```
The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.
**Fix:** `filename: sanitizePathSegment(req.file.originalname)`.
---
### M-8 — Hardcoded Frontend Origin in CSP Header
**File:** `backend/routes/knowledgeBase.js:261`
```js
res.setHeader('Content-Security-Policy',
"frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
```
IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.
**Fix:** Use `CORS_ORIGINS` from the environment variable.
---
### M-9 — Sensitive API Error Messages Forwarded to UI
**Files:** `frontend/src/App.js:801, 816, 847, 886`
```js
} catch (err) {
alert(`Error: ${err.message}`);
}
```
Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.
**Fix:** Show generic user-facing messages; log details to the console in development only.
---
### M-10 — User-Supplied Data in window.confirm Dialogs
**File:** `frontend/src/App.js:806, 891`
```js
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
```
A ticket with a crafted `ticket_key` value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.
**Fix:** Use a React modal component with escaped, controlled text instead of `window.confirm`.
---
## Low / Info Findings
---
### L-1 — Silent ROLLBACK on Compliance Transaction Failure
**File:** `backend/routes/compliance.js:167`
```js
await dbRun(db, 'ROLLBACK').catch(() => {});
```
If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.
**Fix:** Log rollback failures even if execution continues.
---
### L-2 — Fire-and-Forget Audit Logging
**File:** `backend/helpers/auditLog.js:9`
Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.
**Fix:** Log audit write failures to stderr so they surface in server logs.
---
### L-3 — Async Temp File Cleanup With No Error Handling
**File:** `backend/routes/compliance.js:239, 247, 266, 281, 322`
```js
fs.unlink(req.file.path, () => {});
```
Cleanup failures accumulate silently, potentially causing disk exhaustion over time.
**Fix:** Log errors on unlink failure (excluding ENOENT which is expected).
---
### L-4 — IVANTI_SKIP_TLS Disables Certificate Validation
**File:** `backend/routes/ivantiFindings.js:385`
`IVANTI_SKIP_TLS=true` disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.
**Fix:** Log a prominent warning on startup when this flag is active, and ensure it is never set in production.
---
### L-5 — console.error in Production Frontend Code
**Files:** `frontend/src/contexts/AuthContext.js:26`, `KnowledgeBaseViewer.js:31, 56`
Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.
**Fix:** Guard with `if (process.env.NODE_ENV === 'development')` or use a structured logging library.
---
### L-6 — localStorage Column Config Lacks Structural Validation
**File:** `frontend/src/components/pages/ReportingPage.js:5168`
Column order/visibility is loaded from `localStorage` and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.
**Fix:** Validate each loaded item against the known `COLUMN_DEFS` whitelist before use (a `hasOwnProperty` check is already present; ensure it runs on every item before the merge).
---
## Summary Table
| ID | Severity | Title | File |
|----|----------|-------|------|
| C-1 | Critical | Missing auth on Ivanti findings endpoints | ivantiFindings.js:552 |
| C-2 | Critical | requireRole(db) call bypasses role check in KB routes | knowledgeBase.js:43,305 |
| C-3 | Critical | Unauthenticated finding note writes | ivantiFindings.js:639 |
| C-4 | Critical | No brute force protection on login | auth.js:10 |
| C-5 | Critical | Default credentials displayed in login UI | LoginForm.js:104 |
| C-6 | Critical | Missing sandbox on PDF/document iframe | KnowledgeBaseViewer.js:195 |
| H-1 | High | /cleanup-sessions missing role check | auth.js:223 |
| H-2 | High | Hardcoded fallback SESSION_SECRET | server.js:31 |
| H-3 | High | Audit log parameter mismatch — silent trail gaps | archerTickets.js, knowledgeBase.js |
| H-4 | High | Viewers can write compliance notes | compliance.js:522 |
| H-5 | High | Sync endpoints accessible to all authenticated users | ivantiFindings.js:561, ivantiWorkflows.js:262 |
| H-6 | High | HTTP header injection via Content-Disposition filename | knowledgeBase.js:258,299 |
| H-7 | High | Race condition in KB file upload | knowledgeBase.js:91 |
| H-8 | High | Hardcoded default admin password in setup.js | setup.js:175 |
| H-9 | High | ReactMarkdown renders HTML without sanitization | KnowledgeBaseViewer.js:169 |
| M-1 | Medium | No CSRF token protection | All state-changing routes |
| M-2 | Medium | CORS credentials with explicit origin list | server.js:111 |
| M-3 | Medium | No rate limiting on NVD API proxy | nvdLookup.js:13 |
| M-4 | Medium | Admin self-demotion check uses loose equality | users.js:118 |
| M-5 | Medium | Missing hostname format validation | compliance.js:451 |
| M-6 | Medium | Vendor field validated before trim | ivantiTodoQueue.js:8,56 |
| M-7 | Medium | Unsanitized original filename in temp JSON | compliance.js:262 |
| M-8 | Medium | Hardcoded frontend IP in CSP header | knowledgeBase.js:261 |
| M-9 | Medium | API error messages forwarded to UI | App.js:801,816,847,886 |
| M-10 | Medium | User data in window.confirm dialogs | App.js:806,891 |
| L-1 | Low | Silent ROLLBACK on transaction failure | compliance.js:167 |
| L-2 | Low | Fire-and-forget audit logging | auditLog.js:9 |
| L-3 | Low | Async temp file cleanup with no error handling | compliance.js:239+ |
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | ivantiFindings.js:385 |
| L-5 | Low | console.error exposed in production frontend | AuthContext.js, KnowledgeBaseViewer.js |
| L-6 | Low | localStorage column config lacks structural validation | ReportingPage.js:51 |
---
## Remediation Priority
### Immediate — fix before adding users
1. **C-1** — Add `requireAuth` import and router-level middleware to `ivantiFindings.js`
2. **C-2** — Remove `db` from all `requireRole(db, ...)` calls in `knowledgeBase.js`
3. **C-3** — Add `requireAuth(db)` to the finding note PUT route
4. **C-4** — Add `express-rate-limit` to the login route (20 attempts / 15 min)
5. **C-5** — Remove default credentials from `LoginForm.js`
6. **H-2** — Hard-fail on startup if `SESSION_SECRET` is not set in env
### Short-term — next maintenance window
7. **C-6** — Add `sandbox` attribute to the KB iframe
8. **H-3** — Fix `logAudit` call signatures in `archerTickets.js` and `knowledgeBase.js`
9. **H-4** — Add `requireRole('editor', 'admin')` to POST /compliance/notes
10. **H-5** — Add `requireRole('editor', 'admin')` to both POST /sync routes
11. **H-6** — Sanitize filename for `Content-Disposition` header
12. **H-7** — Move file after DB insert succeeds in KB upload
13. **H-8** — Remove hardcoded password from `setup.js`; generate random on first run
14. **H-9** — Add `rehype-sanitize` to `ReactMarkdown` usage
### Medium-term
15. **M-1** — Implement CSRF token or upgrade cookie to `SameSite: strict`
16. **M-3** — Add server-side CVE lookup cache
17. **M-5** — Add hostname format regex validation
18. **M-8** — Pull frontend origin from `CORS_ORIGINS` env var for CSP header
19. **M-9** — Replace `alert(err.message)` with user-friendly error messages
20. Remaining medium and low findings
---
## Positive Security Observations
The following were explicitly verified as secure and should be preserved:
- **SQL injection prevention** — all queries use SQLite3 parameterized statements throughout
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` are comprehensive and consistently applied
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` passes arguments as an array, not a shell string — no command injection possible
- **Python scripts** — no `eval()`, `exec()`, `pickle.load()`, or shell calls in any script
- **File size enforcement** — 10 MB limit applied via multer before route handlers execute
- **File type allowlisting** — extension + MIME prefix validation applied at upload
- **Static file serving** — `express.static` with `{ dotfiles: 'deny', index: false }` prevents directory listing
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension on compliance temp files
- **Password hashing** — bcrypt with cost factor 10 used throughout
---
*Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.*

View File

@@ -162,7 +162,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() { export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth(); const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -1012,6 +1012,11 @@ export default function App() {
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />} {currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />} {currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />} {currentPage === 'exports' && <ExportsPage />}
{currentPage === 'admin' && isAdmin() && (
<div className="space-y-6">
<UserManagement onClose={() => setCurrentPage('home')} />
</div>
)}
{/* User Management Modal */} {/* User Management Modal */}
{showUserManagement && ( {showUserManagement && (
@@ -1746,7 +1751,7 @@ export default function App() {
<span className="text-gray-500 mx-2"></span> <span className="text-gray-500 mx-2"></span>
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'} <span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
</p> </p>
{selectedDocuments.length > 0 && ( {selectedDocuments.length > 0 && canExport() && (
<button <button
onClick={exportSelectedDocuments} onClick={exportSelectedDocuments}
className="intel-button intel-button-primary flex items-center gap-2" className="intel-button intel-button-primary flex items-center gap-2"
@@ -1833,7 +1838,7 @@ export default function App() {
<span>Published: {vendorEntries[0].published_date}</span> <span>Published: {vendorEntries[0].published_date}</span>
<span className="text-intel-accent"></span> <span className="text-intel-accent"></span>
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span> <span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
{canWrite() && vendorEntries.length >= 2 && ( {isAdmin() && vendorEntries.length >= 2 && (
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }} onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1" className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
@@ -1894,7 +1899,7 @@ export default function App() {
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
)} )}
{canWrite() && ( {canDelete(cve) && (
<button <button
onClick={() => handleDeleteCVEEntry(cve)} onClick={() => handleDeleteCVEEntry(cve)}
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1" className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
@@ -2026,9 +2031,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors"> <button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -2152,9 +2159,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors"> <button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -2220,14 +2229,16 @@ export default function App() {
> >
<Filter className="w-3 h-3" /> <Filter className="w-3 h-3" />
</button> </button>
{canWrite() && (<> {canWrite() && (
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors"> <button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
)}
{canDelete(ticket) && (
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors"> <button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</>)} )}
</div> </div>
</div> </div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div> <div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
@@ -2256,6 +2267,7 @@ export default function App() {
<Activity className="w-5 h-5" /> <Activity className="w-5 h-5" />
Ivanti Workflows Ivanti Workflows
</h2> </h2>
{canWrite() && (
<button <button
onClick={syncIvantiWorkflows} onClick={syncIvantiWorkflows}
disabled={ivantiSyncing || ivantiLoading} disabled={ivantiSyncing || ivantiLoading}
@@ -2265,6 +2277,7 @@ export default function App() {
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
{ivantiSyncing ? 'Syncing…' : 'Sync'} {ivantiSyncing ? 'Syncing…' : 'Sync'}
</button> </button>
)}
</div> </div>
{/* Last synced line */} {/* Last synced line */}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react'; import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
@@ -9,7 +10,11 @@ const NAV_ITEMS = [
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
]; ];
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
const { isAdmin } = useAuth();
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -110,6 +115,60 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
</button> </button>
); );
})} })}
{/* Admin panel link — visible only to Admin group */}
{isAdmin() && (() => {
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
const active = currentPage === id;
return (
<button
key={id}
onClick={() => { onNavigate(id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.75rem 0.875rem',
borderRadius: '0.5rem',
border: active ? `1px solid ${color}50` : '1px solid transparent',
background: active ? `${color}18` : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
marginTop: '0.5rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
paddingTop: '1rem',
transition: 'background 0.15s, border-color 0.15s'
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{
width: '36px', height: '36px', flexShrink: 0,
borderRadius: '0.375rem',
background: `${color}18`,
border: `1px solid ${color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Icon style={{ width: '17px', height: '17px', color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
color: active ? color : '#CBD5E1',
textTransform: 'uppercase', letterSpacing: '0.06em'
}}>
{label}
</div>
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
{description}
</div>
</div>
{active && (
<div style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
}} />
)}
</button>
);
})()}
</nav> </nav>
{/* Footer */} {/* Footer */}

View File

@@ -4,6 +4,22 @@ import { useAuth } from '../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const GROUP_LABELS = {
Admin: 'Admin (full access)',
Standard_User: 'Standard User (create, edit, limited delete)',
Leadership: 'Leadership (read-only + exports)',
Read_Only: 'Read Only (view only)'
};
const GROUP_BADGE_STYLES = {
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
};
export default function UserManagement({ onClose }) { export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -15,7 +31,7 @@ export default function UserManagement({ onClose }) {
username: '', username: '',
email: '', email: '',
password: '', password: '',
role: 'viewer' group: 'Read_Only'
}); });
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState(''); const [formSuccess, setFormSuccess] = useState('');
@@ -39,11 +55,29 @@ export default function UserManagement({ onClose }) {
} }
}; };
const confirmGroupChange = (targetUser, newGroup) => {
let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`;
// Extra warning when downgrading an Admin user
if (targetUser.group === 'Admin' && newGroup !== 'Admin') {
message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`;
}
return window.confirm(message);
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setFormError(''); setFormError('');
setFormSuccess(''); setFormSuccess('');
// If editing and group changed, show confirmation dialog
if (editingUser && formData.group !== editingUser.group) {
if (!confirmGroupChange(editingUser, formData.group)) {
return;
}
}
try { try {
const url = editingUser const url = editingUser
? `${API_BASE}/users/${editingUser.id}` ? `${API_BASE}/users/${editingUser.id}`
@@ -75,7 +109,7 @@ export default function UserManagement({ onClose }) {
setTimeout(() => { setTimeout(() => {
setShowAddUser(false); setShowAddUser(false);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
setFormSuccess(''); setFormSuccess('');
}, 1500); }, 1500);
} catch (err) { } catch (err) {
@@ -89,7 +123,7 @@ export default function UserManagement({ onClose }) {
username: user.username, username: user.username,
email: user.email, email: user.email,
password: '', password: '',
role: user.role group: user.group
}); });
setShowAddUser(true); setShowAddUser(true);
setFormError(''); setFormError('');
@@ -140,15 +174,10 @@ export default function UserManagement({ onClose }) {
} }
}; };
const getRoleBadgeColor = (role) => { // Check if group dropdown should be disabled for self-demotion prevention
switch (role) { const isGroupDropdownDisabled = (targetUser) => {
case 'admin': if (!targetUser || !currentUser) return false;
return 'bg-red-100 text-red-800'; return targetUser.id === currentUser.id && currentUser.group === 'Admin';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
}; };
return ( return (
@@ -173,7 +202,7 @@ export default function UserManagement({ onClose }) {
onClick={() => { onClick={() => {
setShowAddUser(true); setShowAddUser(true);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
setFormError(''); setFormError('');
setFormSuccess(''); setFormSuccess('');
}} }}
@@ -253,19 +282,24 @@ export default function UserManagement({ onClose }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Role * Group *
</label> </label>
<div className="relative"> <div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" /> <Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<select <select
value={formData.role} value={formData.group}
onChange={(e) => setFormData({ ...formData, role: e.target.value })} onChange={(e) => setFormData({ ...formData, group: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent" disabled={isGroupDropdownDisabled(editingUser)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
> >
<option value="viewer">Viewer (read-only)</option> {VALID_GROUPS.map((g) => (
<option value="editor">Editor (can add CVEs, upload docs)</option> <option key={g} value={g}>{GROUP_LABELS[g]}</option>
<option value="admin">Admin (full access)</option> ))}
</select> </select>
{isGroupDropdownDisabled(editingUser) && (
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) {
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th> <th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
@@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) {
</div> </div>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}> <span
{user.role.charAt(0).toUpperCase() + user.role.slice(1)} style={{
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
display: 'inline-block'
}}
>
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span> </span>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">

View File

@@ -19,17 +19,26 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const getRoleBadgeColor = (role) => { const getGroupBadgeColor = (group) => {
switch (role) { switch (group) {
case 'admin': case 'Admin':
return 'bg-red-100 text-red-800'; return 'bg-red-100 text-red-800';
case 'editor': case 'Standard_User':
return 'bg-blue-100 text-blue-800'; return 'bg-blue-100 text-blue-800';
case 'Leadership':
return 'bg-purple-100 text-purple-800';
case 'Read_Only':
return 'bg-gray-100 text-gray-800';
default: default:
return 'bg-gray-100 text-gray-800'; return 'bg-gray-100 text-gray-800';
} }
}; };
const formatGroupName = (group) => {
if (!group) return '';
return group.replace(/_/g, ' ');
};
const handleLogout = async () => { const handleLogout = async () => {
setIsOpen(false); setIsOpen(false);
await logout(); await logout();
@@ -62,7 +71,7 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
</div> </div>
<div className="text-left hidden sm:block"> <div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p> <p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p> <p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
</div> </div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button> </button>
@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
<div className="px-4 py-3 border-b border-gray-100"> <div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p> <p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}> <span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)} {formatGroupName(user.group)}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react'; import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i; const EXC_PATTERN = /EXC-\d+/i;
@@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page // Main page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ExportsPage() { export default function ExportsPage() {
const { canExport } = useAuth();
const [loading, setLoading] = useState(null); const [loading, setLoading] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState(''); const [cveStatus, setCveStatus] = useState('');
@@ -333,6 +335,15 @@ export default function ExportsPage() {
// ---- Render ---- // ---- Render ----
if (!canExport()) {
return (
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
</div>
);
}
return ( return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>

View File

@@ -72,16 +72,26 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
}; };
// Check if user has a specific role // Check if user belongs to one of the specified groups
const hasRole = (...roles) => { const isInGroup = (...groups) => user && groups.includes(user.group);
return user && roles.includes(user.role);
// Check if user can perform write operations (Admin or Standard_User)
const canWrite = () => isInGroup('Admin', 'Standard_User');
// Check if user can delete a resource
// Admin: always true; Standard_User: only if they own the resource; others: false
const canDelete = (resource) => {
if (!user) return false;
if (isInGroup('Admin')) return true;
if (!isInGroup('Standard_User')) return false;
return resource?.created_by === user.id;
}; };
// Check if user can perform write operations (editor or admin) // Check if user can export data
const canWrite = () => hasRole('editor', 'admin'); const canExport = () => isInGroup('Admin', 'Standard_User', 'Leadership');
// Check if user is admin // Check if user is admin
const isAdmin = () => hasRole('admin'); const isAdmin = () => isInGroup('Admin');
const value = { const value = {
user, user,
@@ -90,8 +100,10 @@ export function AuthProvider({ children }) {
login, login,
logout, logout,
checkAuth, checkAuth,
hasRole, isInGroup,
canWrite, canWrite,
canDelete,
canExport,
isAdmin, isAdmin,
isAuthenticated: !!user isAuthenticated: !!user
}; };