Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
README.md
.env
.env.example
.env.test
node_modules
package-lock.json
__pycache__
*.pyc
*.pyo
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [ '**' ] # Run on all pull requests

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -40,17 +43,17 @@ jobs:
run: |
npm install
npm run build

- name: Run tests with pytest
env:
SECRET_KEY: test-secret-key-for-github-actions
run: |
pytest -m "not real_media" --cov=pixelprobe --cov-report=xml --cov-report=term

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
fail_ci_if_error: false
22 changes: 22 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).

## [2.6.40] - 2026-04-20

### Security

- **Resolve open CodeQL and Dependabot findings**. No external behavior changes; exception details that previously leaked into HTTP response bodies are now logged server-side only.
- **Stack trace exposure (49 sites)**: Exception detail (`str(e)`, `traceback`, `details`) no longer appears in API error responses. The `handle_errors` decorator and every route-level `except` across the API blueprints now returns a generic error message; the exception is logged server-side with `exc_info=True` for operators.
- **Path injection (7 sites)**: `validate_file_path` and `validate_directory_path` resolve symlinks via `os.path.realpath` and validate with `os.path.commonpath` against the configured allowlist, defeating symlink-based escapes. **Behavior change**: `validate_directory_path` now enforces the configured scan-path allowlist by default; `POST /api/scan`, `POST /api/scan-files-parallel`, and `POST /api/parallel/scan` will reject directories outside `SCAN_PATHS` or the active `ScanConfiguration` entries (previously only `..` / `~` tokens were rejected). `POST /api/configurations` is exempt because it is defining a new allowlist entry. The unused `validate_path_exists` decorator was removed.
- **Clear-text logging of sensitive data (3 sites)**: `tools/migrate_to_postgres.py` no longer holds the DB password in the `pg_config` dict while logging connection details; the password is stored in a dedicated parameter. Trusted-host config logging in `security.py` demoted from `info` to `debug`.
- **GitHub Actions workflow permissions**: `.github/workflows/test.yml` declares `permissions: contents: read` at the workflow level.
- **Reflective XSS (17 alerts)**: Reviewed; every flagged site returns JSON via `jsonify()` with no HTML sink. Dismissed as false positives; no code change required.

### Changed

- **Dependency bumps covering 6 CVEs**:
- `Pillow` 12.1.1 -> 12.2.0 (FITS GZIP decompression bomb).
- `requests` 2.32.5 -> 2.33.0 (`extract_zipped_paths` tempfile reuse).
- `Flask-CORS` 5.0.1 -> 6.0.0 (path-matching CVEs; verified wildcard config is unaffected by the v6 specificity and case-sensitivity changes).
- `pytest` 8.3.5 -> 9.0.3 (tmpdir handling CVE).
- `black` removed from `requirements-test.txt`; it was unused dev tooling (no CI gate, no `pyproject.toml`, no pre-commit hook).

---

## [2.6.39] - 2026-04-20

### Fixed
Expand Down
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,11 @@ RUN npm install

COPY . .

# Build frontend assets
RUN npm run build
# Build frontend assets, then drop the node toolchain. Webpack and its
# transitive dev-only dependencies (picomatch, serialize-javascript, svgo,
# etc.) are not needed at runtime and otherwise ship as CVEs in the image.
RUN npm run build && \
rm -rf node_modules package-lock.json

# Ensure the pixelprobe package is properly installed
RUN mkdir -p /app/instance && \
Expand Down
45 changes: 23 additions & 22 deletions pixelprobe/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ def mark_as_good():
}

except Exception as e:
logger.error(f"Error marking files as good: {str(e)}")
logger.error(f"Error marking files as good: {str(e)}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/ignored-patterns')
@auth_required
Expand Down Expand Up @@ -172,9 +172,9 @@ def add_ignored_pattern():
'message': 'Pattern added successfully'
}, 201
except Exception as e:
logger.error(f"Error adding ignored pattern: {e}")
logger.error(f"Error adding ignored pattern: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/ignored-patterns/<int:pattern_id>', methods=['DELETE'])
@auth_required
Expand All @@ -193,9 +193,9 @@ def delete_ignored_pattern(pattern_id):

return {'message': 'Pattern deleted successfully'}
except Exception as e:
logger.error(f"Error deleting ignored pattern: {e}")
logger.error(f"Error deleting ignored pattern: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/configurations')
@auth_required
Expand All @@ -219,9 +219,10 @@ def add_configuration():
data = request.get_json()
path = data.get('path')

# Validate and normalize path
# Admin is defining a new allowlist entry, so skip the allowlist check.
# Traversal tokens and symlink resolution still run.
try:
path = validate_directory_path(path)
path = validate_directory_path(path, allowed_paths=[])
AuditLogger.log_action('add_configuration', {'path': path})
except Exception as e:
AuditLogger.log_security_event('invalid_directory_path', str(e), 'warning')
Expand Down Expand Up @@ -256,9 +257,9 @@ def add_configuration():
'message': message
}
except Exception as e:
logger.error(f"Error adding configuration: {e}")
logger.error(f"Error adding configuration: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/schedules', methods=['GET'])
@auth_required
Expand Down Expand Up @@ -312,9 +313,9 @@ def create_schedule():

return schedule.to_dict(), 201
except Exception as e:
logger.error(f"Error creating schedule: {e}")
logger.error(f"Error creating schedule: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/schedules/<int:schedule_id>', methods=['PUT'])
@auth_required
Expand Down Expand Up @@ -362,9 +363,9 @@ def update_schedule(schedule_id):

return schedule.to_dict()
except Exception as e:
logger.error(f"Error updating schedule: {e}")
logger.error(f"Error updating schedule: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
@auth_required
Expand All @@ -386,9 +387,9 @@ def delete_schedule(schedule_id):

return '', 204
except Exception as e:
logger.error(f"Error deleting schedule: {e}")
logger.error(f"Error deleting schedule: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/exclusions', methods=['GET'])
@auth_required
Expand Down Expand Up @@ -444,9 +445,9 @@ def update_exclusions():
db.session.commit()
return {'message': 'Exclusions updated successfully'}
except Exception as e:
logger.error(f"Error updating exclusions: {e}")
logger.error(f"Error updating exclusions: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/exclusions/<exclusion_type>', methods=['POST'])
@auth_required
Expand Down Expand Up @@ -489,9 +490,9 @@ def add_exclusion(exclusion_type):
return {'message': f'{exclusion_type.capitalize()} added successfully'}

except Exception as e:
logger.error(f"Error adding exclusion: {e}")
logger.error(f"Error adding exclusion: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500

@admin_bp.route('/exclusions/<exclusion_type>', methods=['DELETE'])
@auth_required
Expand Down Expand Up @@ -529,6 +530,6 @@ def remove_exclusion(exclusion_type):
return {'message': f'{exclusion_type.capitalize()} removed successfully'}

except Exception as e:
logger.error(f"Error removing exclusion: {e}")
logger.error(f"Error removing exclusion: {e}", exc_info=True)
db.session.rollback()
return {'error': str(e)}, 500
return {'error': 'Internal server error'}, 500
3 changes: 2 additions & 1 deletion pixelprobe/api/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def first_run_setup():

admin, error = create_initial_admin(password)
if error:
return jsonify({'error': error}), 500
logger.error("create_initial_admin failed: %s", error)
return jsonify({'error': 'Failed to create admin user'}), 500

# Log the admin user in automatically
login_user(admin, remember=True)
Expand Down
4 changes: 2 additions & 2 deletions pixelprobe/api/export_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,5 +471,5 @@ def export_scan_results():
)

except Exception as e:
logger.error(f"Error exporting: {str(e)}")
return {'error': f'Export failed: {str(e)}'}, 500
logger.error(f"Error exporting: {str(e)}", exc_info=True)
return {'error': 'Export failed'}, 500
4 changes: 2 additions & 2 deletions pixelprobe/api/healthcheck_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,5 +334,5 @@ def test_healthcheck(config_id):
}), 200

except Exception as e:
logger.error(f"Error testing healthcheck config {config_id}: {e}")
return jsonify({'error': f'Failed to test healthcheck: {str(e)}'}), 500
logger.error(f"Error testing healthcheck config {config_id}: {e}", exc_info=True)
return jsonify({'error': 'Failed to test healthcheck'}), 500
36 changes: 18 additions & 18 deletions pixelprobe/api/maintenance_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ def get_cleanup_status():
return response

except Exception as e:
logger.error(f"Error getting cleanup status: {str(e)}")
logger.error(f"Error getting cleanup status: {str(e)}", exc_info=True)
return {
'is_running': False,
'phase': 'error',
'error': str(e)
'error': 'Failed to get cleanup status',
}

@maintenance_bp.route('/file-changes-status')
Expand Down Expand Up @@ -225,11 +225,11 @@ def get_file_changes_status():
return response

except Exception as e:
logger.error(f"Error getting file changes status: {str(e)}")
logger.error(f"Error getting file changes status: {str(e)}", exc_info=True)
return {
'is_running': False,
'phase': 'error',
'error': str(e)
'error': 'Failed to get file changes status',
}

@maintenance_bp.route('/cancel-cleanup', methods=['POST'])
Expand All @@ -255,8 +255,8 @@ def cancel_cleanup():
return {'error': 'No active cleanup operation to cancel'}, 400

except Exception as e:
logger.error(f"Error cancelling cleanup: {str(e)}")
return {'error': str(e)}, 500
logger.error(f"Error cancelling cleanup: {str(e)}", exc_info=True)
return {'error': 'Internal server error'}, 500

@maintenance_bp.route('/reset-cleanup-state', methods=['POST'])
@auth_required
Expand Down Expand Up @@ -292,8 +292,8 @@ def reset_cleanup_state():
return {'message': 'Cleanup state reset successfully'}

except Exception as e:
logger.error(f"Error resetting cleanup state: {str(e)}")
return {'error': str(e)}, 500
logger.error(f"Error resetting cleanup state: {str(e)}", exc_info=True)
return {'error': 'Internal server error'}, 500

@maintenance_bp.route('/cancel-file-changes', methods=['POST'])
@auth_required
Expand All @@ -318,8 +318,8 @@ def cancel_file_changes():
return {'error': 'No active file changes check to cancel'}, 400

except Exception as e:
logger.error(f"Error cancelling file changes check: {str(e)}")
return {'error': str(e)}, 500
logger.error(f"Error cancelling file changes check: {str(e)}", exc_info=True)
return {'error': 'Internal server error'}, 500

@maintenance_bp.route('/reset-file-changes-state', methods=['POST'])
@auth_required
Expand Down Expand Up @@ -356,8 +356,8 @@ def reset_file_changes_state():
return {'message': 'File changes state reset successfully'}

except Exception as e:
logger.error(f"Error resetting file changes state: {str(e)}")
return {'error': str(e)}, 500
logger.error(f"Error resetting file changes state: {str(e)}", exc_info=True)
return {'error': 'Internal server error'}, 500

@maintenance_bp.route('/cleanup-orphaned', methods=['POST'])
@auth_required
Expand Down Expand Up @@ -535,13 +535,13 @@ def cleanup_orphaned_async(app, cleanup_id, file_paths=None, schedule_id=None):
maintenance_service._run_cleanup(cleanup_record.id, file_paths=file_paths, schedule_id=schedule_id)

except Exception as e:
logger.error(f"Error in cleanup_orphaned_async: {str(e)}")
logger.error(f"Error in cleanup_orphaned_async: {str(e)}", exc_info=True)
try:
with app.app_context():
cleanup_record = db.session.get(CleanupState, cleanup_id)
if cleanup_record:
cleanup_record.phase = 'error'
cleanup_record.progress_message = f'Error: {str(e)}'
cleanup_record.progress_message = 'Cleanup failed'
cleanup_record.is_active = False
cleanup_record.end_time = datetime.now(timezone.utc)
db.session.commit()
Expand Down Expand Up @@ -575,13 +575,13 @@ def check_file_changes_async(app, check_id, file_paths=None, schedule_id=None):
maintenance_service._run_file_changes_check(check_record.check_id, file_paths=file_paths, schedule_id=schedule_id)

except Exception as e:
logger.error(f"Error in check_file_changes_async: {str(e)}")
logger.error(f"Error in check_file_changes_async: {str(e)}", exc_info=True)
try:
with app.app_context():
check_record = FileChangesState.query.filter_by(check_id=check_id).first()
if check_record:
check_record.phase = 'error'
check_record.progress_message = f'Error: {str(e)}'
check_record.progress_message = 'File changes check failed'
check_record.is_active = False
check_record.end_time = datetime.now(timezone.utc)
db.session.commit()
Expand Down Expand Up @@ -629,5 +629,5 @@ def vacuum_database():
}

except Exception as e:
logger.error(f"Error vacuuming database: {str(e)}")
return {'error': f'Failed to vacuum database: {str(e)}'}, 500
logger.error(f"Error vacuuming database: {str(e)}", exc_info=True)
return {'error': 'Failed to vacuum database'}, 500
6 changes: 3 additions & 3 deletions pixelprobe/api/notification_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,11 @@ def test_provider(provider_id):

if success:
return jsonify({'success': True, 'message': 'Test notification sent successfully'}), 200
else:
return jsonify({'success': False, 'error': error}), 400
logger.warning("Notification provider %s test failed: %s", provider_id, error)
return jsonify({'success': False, 'error': 'Test notification failed'}), 400

except Exception as e:
logger.error(f"Error testing provider {provider_id}: {e}")
logger.error(f"Error testing provider {provider_id}: {e}", exc_info=True)
return jsonify({'error': 'Failed to test provider'}), 500


Expand Down
Loading
Loading