196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
import base64
|
|
import builtins
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import pandas as pd
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP_DIR = ROOT / 'app'
|
|
sys.path.insert(0, str(APP_DIR))
|
|
|
|
import dashboard
|
|
import scanner_db
|
|
from keycheckers.gcp import gcpKeycheck
|
|
|
|
|
|
def der_tlv(tag, value):
|
|
length = len(value)
|
|
if length < 0x80:
|
|
encoded_length = bytes([length])
|
|
else:
|
|
length_bytes = length.to_bytes((length.bit_length() + 7) // 8, 'big')
|
|
encoded_length = bytes([0x80 | len(length_bytes)]) + length_bytes
|
|
return bytes([tag]) + encoded_length + value
|
|
|
|
|
|
def der_integer(value):
|
|
value = value or b'\x00'
|
|
if value[0] & 0x80:
|
|
value = b'\x00' + value
|
|
return der_tlv(0x02, value)
|
|
|
|
|
|
def rsa_sequence(bits=2048, modulus=None, private_exponent=b'\x01'):
|
|
modulus = modulus or (b'\x80' + b'\x01' * (bits // 8 - 1))
|
|
values = [
|
|
der_integer(b'\x00'),
|
|
der_integer(modulus),
|
|
der_integer(b'\x01\x00\x01'),
|
|
der_integer(private_exponent),
|
|
]
|
|
values.extend(der_integer(b'\x03') for _ in range(5))
|
|
return der_tlv(0x30, b''.join(values))
|
|
|
|
|
|
def private_key_pem(bits=2048, pkcs8=False, modulus=None, private_exponent=b'\x01'):
|
|
rsa_der = rsa_sequence(bits, modulus, private_exponent)
|
|
if pkcs8:
|
|
algorithm = der_tlv(
|
|
0x30,
|
|
der_tlv(0x06, gcpKeycheck.RSA_ENCRYPTION_OID) + der_tlv(0x05, b''),
|
|
)
|
|
der = der_tlv(0x30, der_integer(b'\x00') + algorithm + der_tlv(0x04, rsa_der))
|
|
label = 'PRIVATE KEY'
|
|
else:
|
|
der = rsa_der
|
|
label = 'RSA PRIVATE KEY'
|
|
body = base64.b64encode(der).decode('ascii')
|
|
return f'-----BEGIN {label}-----\n{body}\n-----END {label}-----\n'
|
|
|
|
|
|
def credentials(pem):
|
|
return {
|
|
'client_email': 'fixture@example.invalid',
|
|
'private_key_id': 'fixture-key-id',
|
|
'private_key': pem,
|
|
'project_id': 'fixture-project',
|
|
'token_uri': gcpKeycheck.GOOGLE_TOKEN_URL,
|
|
}
|
|
|
|
|
|
class GCPRSABoundsTests(unittest.TestCase):
|
|
def assert_rejected_without_cpu_or_network(self, pem):
|
|
with mock.patch.object(builtins, 'pow') as rsa_pow, \
|
|
mock.patch.object(gcpKeycheck.requests, 'post') as post:
|
|
result = gcpKeycheck.check_service_account(credentials(pem), None, 5)
|
|
self.assertEqual(result['status'], 'DEAD')
|
|
self.assertEqual(result['classification'], 'invalid_private_key')
|
|
rsa_pow.assert_not_called()
|
|
post.assert_not_called()
|
|
|
|
def test_oversized_modulus_exponent_and_der_are_terminal_before_work(self):
|
|
oversized_integer = b'\x01' * (gcpKeycheck.MAX_RSA_INTEGER_BYTES + 1)
|
|
cases = {
|
|
'modulus': private_key_pem(modulus=oversized_integer),
|
|
'private_exponent': private_key_pem(private_exponent=oversized_integer),
|
|
'der': (
|
|
'-----BEGIN PRIVATE KEY-----\n'
|
|
+ base64.b64encode(b'\x00' * (gcpKeycheck.MAX_DER_BYTES + 1)).decode('ascii')
|
|
+ '\n-----END PRIVATE KEY-----\n'
|
|
),
|
|
}
|
|
for name, pem in cases.items():
|
|
with self.subTest(name=name):
|
|
self.assert_rejected_without_cpu_or_network(pem)
|
|
|
|
def test_malformed_der_lengths_and_trailing_data_are_rejected(self):
|
|
prefix = der_tlv(0x02, b'\x01' * 200)
|
|
malformed = (
|
|
der_tlv(0x30, prefix + b'\x30\x80'),
|
|
der_tlv(0x30, prefix + b'\x30\x82\x01'),
|
|
der_tlv(0x30, prefix + b'\x30\x83\x00\x00\x01'),
|
|
der_tlv(0x30, prefix + b'\x30\x82\x40\x01'),
|
|
rsa_sequence() + b'\x00',
|
|
)
|
|
for der in malformed:
|
|
label = 'RSA PRIVATE KEY'
|
|
pem = (
|
|
f'-----BEGIN {label}-----\n'
|
|
+ base64.b64encode(der).decode('ascii')
|
|
+ f'\n-----END {label}-----\n'
|
|
)
|
|
with self.subTest(der=der[:8]):
|
|
self.assert_rejected_without_cpu_or_network(pem)
|
|
|
|
def test_normal_pkcs1_2048_and_pkcs8_4096_reach_token_request(self):
|
|
response = mock.Mock(status_code=400)
|
|
response.json.return_value = {'error': 'invalid_grant'}
|
|
response.text = 'invalid_grant'
|
|
for bits, pkcs8 in ((2048, False), (4096, True)):
|
|
with self.subTest(bits=bits, pkcs8=pkcs8), \
|
|
mock.patch.object(builtins, 'pow', return_value=1) as rsa_pow, \
|
|
mock.patch.object(gcpKeycheck.requests, 'post', return_value=response) as post:
|
|
result = gcpKeycheck.check_service_account(
|
|
credentials(private_key_pem(bits=bits, pkcs8=pkcs8)), None, 5
|
|
)
|
|
self.assertEqual(result['status'], 'DEAD')
|
|
rsa_pow.assert_called_once()
|
|
post.assert_called_once()
|
|
|
|
|
|
class ADCResourceRedactionTests(unittest.TestCase):
|
|
def test_enrichment_keeps_refresh_token_out_of_resource(self):
|
|
sentinel = 'ADC-REFRESH-TOKEN-SENTINEL'
|
|
finding = {
|
|
'DetectorName': 'GCPApplicationDefaultCredentials',
|
|
'Raw': sentinel,
|
|
'ScannerContext': {'nearby': json.dumps({
|
|
'type': 'authorized_user',
|
|
'client_id': 'public-client-id',
|
|
'client_secret': 'client-secret',
|
|
'refresh_token': sentinel,
|
|
'quota_project_id': 'quota-project',
|
|
})},
|
|
}
|
|
enriched = scanner_db.enrich_finding(finding)
|
|
self.assertEqual(enriched['project_id'], 'quota-project')
|
|
self.assertEqual(enriched['resource'], '')
|
|
self.assertNotIn(sentinel, json.dumps(enriched))
|
|
|
|
finding['PostmanContext'] = {
|
|
'credential_kind': 'application_default_credentials',
|
|
'json_path': sentinel,
|
|
}
|
|
self.assertEqual(scanner_db.enrich_finding(finding)['resource'], '')
|
|
|
|
def test_render_redacts_only_adc_resource_rows(self):
|
|
sentinel = 'HISTORICAL-ADC-REFRESH-TOKEN'
|
|
frame = pd.DataFrame([
|
|
{
|
|
'detector_name': 'GCPApplicationDefaultCredentials',
|
|
'credential_kind': '',
|
|
'resource': sentinel,
|
|
},
|
|
{
|
|
'detector_name': 'ImportedDetector',
|
|
'credential_kind': 'application_default_credentials',
|
|
'resource': sentinel + '-IMPORTED',
|
|
},
|
|
{
|
|
'detector_name': 'AzureOpenAI',
|
|
'credential_kind': 'azure_openai_key',
|
|
'resource': 'azure-resource-name',
|
|
},
|
|
{
|
|
'detector_name': 'GCP',
|
|
'credential_kind': 'service_account',
|
|
'resource': 'nonsecret-private-key-id',
|
|
},
|
|
])
|
|
with mock.patch.object(dashboard.st, 'dataframe') as render:
|
|
dashboard.display_df(frame)
|
|
rendered = render.call_args.args[0]
|
|
rendered_text = rendered.to_string()
|
|
self.assertNotIn(sentinel, rendered_text)
|
|
self.assertIn('azure-resource-name', rendered_text)
|
|
self.assertIn('nonsecret-private-key-id', rendered_text)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|