From 0470d76a80f5a303c60c9bc63d8980820630a804 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:27:19 +0000 Subject: [PATCH 1/3] Initial plan From bec946e3f90ae3cc855e6be694c7e9fbc3956090 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:33:01 +0000 Subject: [PATCH 2/3] security: remediate appsec findings - hash passwords, fix secret key, disable debug, add auth guard --- templates/login.html | 9 ++- templates/profile.html | 2 +- templates/register.html | 4 +- tests/test_security.py | 151 ++++++++++++++++++++++++++++++++++++++++ wine.py | 35 +++++----- wineusers.db | Bin 12288 -> 12288 bytes 6 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 tests/test_security.py diff --git a/templates/login.html b/templates/login.html index 043c26c..15cb77b 100644 --- a/templates/login.html +++ b/templates/login.html @@ -93,12 +93,11 @@

Please Login:

-
-
- - +
+ + + -
Not registered? Click here

{{login_text}} diff --git a/templates/profile.html b/templates/profile.html index 1607e62..c96a68f 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -93,7 +93,7 @@

Change Password

- +
diff --git a/templates/register.html b/templates/register.html index 25a11f2..4d73edc 100644 --- a/templates/register.html +++ b/templates/register.html @@ -101,11 +101,11 @@

Please Register As A User

-
+
-
+
diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..efcdfd7 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,151 @@ +import pytest +from unittest.mock import patch, MagicMock +from werkzeug.security import generate_password_hash +from wine import app + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +class TestDebugMode: + def test_debug_mode_disabled(self): + # Debug mode must be off to prevent Werkzeug interactive debugger exposure + assert app.debug is False + + +class TestSecretKey: + def test_secret_key_is_not_hardcoded_default(self): + # Secret key must not be the known-bad default value + assert app.secret_key != b'your_secret_key' + assert app.secret_key != 'your_secret_key' + + def test_secret_key_has_sufficient_length(self): + # Secret key should be at least 16 bytes + key = app.secret_key + key_bytes = key if isinstance(key, bytes) else key.encode() + assert len(key_bytes) >= 16 + + +class TestLoginHashedPassword: + def _make_mock_account(self, username='testuser', password='TestPass1', + email='test@example.com'): + hashed = generate_password_hash(password) + return (1, username, hashed, email) + + def test_login_succeeds_with_correct_hashed_password(self, client): + # Login must succeed when the submitted password matches the stored hash + account = self._make_mock_account() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = account + + with patch('sqlite3.connect') as mock_connect: + mock_connect.return_value.cursor.return_value = mock_cursor + response = client.post('/login', data={ + 'username': 'testuser', + 'password': 'TestPass1' + }) + + assert b'Logged in successfully!' in response.data + + def test_login_fails_with_wrong_password(self, client): + # Login must fail when password does not match the stored hash + account = self._make_mock_account() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = account + + with patch('sqlite3.connect') as mock_connect: + mock_connect.return_value.cursor.return_value = mock_cursor + response = client.post('/login', data={ + 'username': 'testuser', + 'password': 'WrongPassword' + }) + + assert b'Incorrect username/password!' in response.data + + def test_login_fails_with_plaintext_password_in_db(self, client): + # If the DB somehow contains plaintext, login must still fail (not bypass hash check) + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = (1, 'testuser', 'TestPass1', 'test@example.com') + + with patch('sqlite3.connect') as mock_connect: + mock_connect.return_value.cursor.return_value = mock_cursor + response = client.post('/login', data={ + 'username': 'testuser', + 'password': 'TestPass1' + }) + + assert b'Logged in successfully!' not in response.data + + +class TestRegisterPasswordHash: + def test_register_stores_hashed_password(self, client): + # Registration must hash the password before storing it + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None # user does not exist + + inserted_values = {} + + def capture_execute(sql, params=None): + if params and 'INSERT' in sql.upper(): + inserted_values['password'] = params[1] + + mock_cursor.execute.side_effect = capture_execute + mock_cursor.connection.commit.return_value = None + + with patch('sqlite3.connect') as mock_connect: + mock_connect.return_value.cursor.return_value = mock_cursor + client.post('/register', data={ + 'username': 'newuser', + 'password': 'MySecret1', + 'confirmpw': 'MySecret1', + 'email': 'new@example.com' + }) + + assert 'password' in inserted_values + stored_pw = inserted_values['password'] + # Stored value must not be the plaintext password + assert stored_pw != 'MySecret1' + # Stored value must be a valid Werkzeug hash + from werkzeug.security import check_password_hash + assert check_password_hash(stored_pw, 'MySecret1') + + def test_register_rejects_mismatched_passwords(self, client): + # Server must reject registration when password and confirmation differ + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + + with patch('sqlite3.connect') as mock_connect: + mock_connect.return_value.cursor.return_value = mock_cursor + response = client.post('/register', data={ + 'username': 'newuser', + 'password': 'MySecret1', + 'confirmpw': 'DifferentPassword', + 'email': 'new@example.com' + }) + + assert b'Passwords do not match!' in response.data + + +class TestPredictAuthGuard: + def test_predict_get_unauthenticated_redirects_to_login(self, client): + # Unauthenticated GET /predict must redirect to login + response = client.get('/predict') + assert response.status_code == 302 + assert '/login' in response.headers['Location'] + + def test_predict_post_unauthenticated_redirects_to_login(self, client): + # Unauthenticated POST /predict must redirect to login + response = client.post('/predict', data={ + 'alcohol': '13.0', 'malic_acid': '2.0', 'ash': '2.3', + 'alcalinity_of_ash': '15.0', 'magnesium': '100.0', + 'total_phenols': '2.5', 'flavanoids': '2.5', + 'nonflavanoid_phenols': '0.3', 'proanthocyanins': '1.5', + 'color_intensity': '5.0', 'hue': '1.0', + 'od280_od315_of_diluted_wines': '3.0', 'proline': '700.0' + }) + assert response.status_code == 302 + assert '/login' in response.headers['Location'] diff --git a/wine.py b/wine.py index 0e356f0..9c02514 100644 --- a/wine.py +++ b/wine.py @@ -15,6 +15,8 @@ import pickle import signal import re +import os +from werkzeug.security import generate_password_hash, check_password_hash # Create a signal handler to shut down the server gracefully on shutdown def shutdown(signal_number, frame): @@ -26,7 +28,7 @@ def shutdown(signal_number, frame): # Load the model from the pickle file app = Flask(__name__) model = pickle.load(open('model.pkl', 'rb')) -app.secret_key = 'your_secret_key' +app.secret_key = os.environ.get('FLASK_SECRET_KEY', os.urandom(32)) # # Configure MySQL service # app.config['MYSQL_HOST'] = 'localhost' @@ -37,7 +39,7 @@ def shutdown(signal_number, frame): # mysql = MySQL(app) # Turn debugging mode off for production -app.debug = True +app.debug = False # create a routine to notify test scipts that the server is up and running @app.route("/health") @@ -60,10 +62,10 @@ def login(): password = request.form['password'] cursor = sqlite3.connect('wineusers.db').cursor() if cursor: - cursor.execute('SELECT * FROM accounts WHERE username = ? AND password = ?', (username, password)) + cursor.execute('SELECT * FROM accounts WHERE username = ?', (username,)) account = cursor.fetchone() cursor.close() - if account: + if account and check_password_hash(account[2], password): session['loggedin'] = True session['id'] = account[0] session['username'] = account[1] @@ -101,13 +103,11 @@ def register(): if request.method == 'POST' and 'username' in request.form and 'password' in request.form and 'email' in request.form : username = request.form['username'] password = request.form['password'] + confirmpw = request.form.get('confirmpw', '') email = request.form['email'] cursor = sqlite3.connect('wineusers.db').cursor() cursor.execute('SELECT * FROM accounts WHERE username = ?', (username, )) account = cursor.fetchone() - # cursor = mysql.connection.cursor() - # cursor.execute('SELECT * FROM accounts WHERE username = % s', (username, )) - # account = cursor.fetchone() if account: msg = 'Account already exists!' elif not re.match(r'[^@]+@[^@]+\.[^@]+', email): @@ -116,12 +116,13 @@ def register(): msg = 'Username must contain only characters and numbers!' elif not username or not password or not email: msg = 'Please fill out the form!' + elif password != confirmpw: + msg = 'Passwords do not match!' else: - cursor.execute('INSERT INTO accounts VALUES (NULL, ?, ?, ?)', (username, password, email, )) + hashed_password = generate_password_hash(password) + cursor.execute('INSERT INTO accounts VALUES (NULL, ?, ?, ?)', (username, hashed_password, email, )) cursor.connection.commit() cursor.close() - # cursor.execute('INSERT INTO accounts VALUES (NULL, % s, % s, % s)', (username, password, email, )) - # mysql.connection.commit() msg = 'You have successfully registered!' elif request.method == 'POST': msg = 'Please fill out the form!' @@ -143,6 +144,8 @@ def contact(): # predict uses the machine learning model to predict the wine type based on the user's input @app.route("/predict", methods=['GET', 'POST']) def predict(): + if 'username' not in session: + return redirect(url_for('login')) if request.method == 'GET': return render_template('wine.html') else: @@ -177,14 +180,12 @@ def change_password(): return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='New passwords do not match!') cursor = sqlite3.connect('wineusers.db').cursor() - cursor.execute('SELECT * FROM accounts WHERE username = ? AND password = ?', (session['username'], current_password)) + cursor.execute('SELECT * FROM accounts WHERE username = ?', (session['username'],)) account = cursor.fetchone() - # cursor = mysql.connection.cursor() - # cursor.execute('SELECT * FROM accounts WHERE username = %s AND password = %s', (session['username'], current_password)) - # account = cursor.fetchone() - if account: - cursor.execute('UPDATE accounts SET password = ? WHERE username = ?', (new_password, session['username'])) + if account and check_password_hash(account[2], current_password): + hashed_new_password = generate_password_hash(new_password) + cursor.execute('UPDATE accounts SET password = ? WHERE username = ?', (hashed_new_password, session['username'])) cursor.connection.commit() cursor.close() # cursor.execute('UPDATE accounts SET password = %s WHERE username = %s', (new_password, session['username'])) @@ -192,7 +193,7 @@ def change_password(): return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Password changed successfully!') else: cursor.close() - return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), current_password=current_password, profile_text='Current password is incorrect!') + return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Current password is incorrect!') else: return redirect(url_for('login')) diff --git a/wineusers.db b/wineusers.db index 9eb82801f5e74ebf35a0a6460767ad58c25a3ef8..c881ca75ad269c3edf4356e481f38845097c197b 100644 GIT binary patch delta 239 zcmWN^y-Gtd6ae7ViknS4IViXkdMC;6$;ovRaT7`x1*e`QCxVKWD&lpqy16MnN*ABT z-M7$h`&RSS{A0J*|J*ws^~J&C`?e5bLpDM_%Vs?qeh2vm)FzYmYBTF z4zIXYUUs~o?w0TKvRgc~)&#&7TRlFzJD;kPr|WKZ<1UW6%dvOP2hbUkg}|t4RS{Du zJ`-lNF|fg8xR4DHiZz;J(K#z$bwHD_BKQh9TA<>Ilmef8i6(kQW@GwWu<+Ah9wpH6*pT1cW!glouBO06a|>5C8xG From a7c8df905d42c55233bc6ef1b09b749398704e21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:34:38 +0000 Subject: [PATCH 3/3] security: address code review - close db connections, document secret key, fix template indentation --- templates/login.html | 6 +++--- wine.py | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/templates/login.html b/templates/login.html index 15cb77b..765c41a 100644 --- a/templates/login.html +++ b/templates/login.html @@ -94,9 +94,9 @@

Please Login:


-
-
- + + +
Not registered? Click here

diff --git a/wine.py b/wine.py index 9c02514..9da1340 100644 --- a/wine.py +++ b/wine.py @@ -28,6 +28,8 @@ def shutdown(signal_number, frame): # Load the model from the pickle file app = Flask(__name__) model = pickle.load(open('model.pkl', 'rb')) +# Set FLASK_SECRET_KEY env var in production. The os.urandom fallback generates a new +# key on every restart (invalidating all sessions) and is only suitable for development. app.secret_key = os.environ.get('FLASK_SECRET_KEY', os.urandom(32)) # # Configure MySQL service @@ -64,7 +66,9 @@ def login(): if cursor: cursor.execute('SELECT * FROM accounts WHERE username = ?', (username,)) account = cursor.fetchone() + conn = cursor.connection cursor.close() + conn.close() if account and check_password_hash(account[2], password): session['loggedin'] = True session['id'] = account[0] @@ -187,12 +191,16 @@ def change_password(): hashed_new_password = generate_password_hash(new_password) cursor.execute('UPDATE accounts SET password = ? WHERE username = ?', (hashed_new_password, session['username'])) cursor.connection.commit() + conn = cursor.connection cursor.close() + conn.close() # cursor.execute('UPDATE accounts SET password = %s WHERE username = %s', (new_password, session['username'])) # mysql.connection.commit() return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Password changed successfully!') else: + conn = cursor.connection cursor.close() + conn.close() return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Current password is incorrect!') else: return redirect(url_for('login'))