Support for filtering on applicable_to field for visibility mode and detection graph.
parent
b3a8ba2a4f
commit
6da47fe9fb
15
dettact.py
15
dettact.py
|
@ -49,6 +49,10 @@ def init_menu():
|
||||||
'score the level of visibility)', required=True)
|
'score the level of visibility)', required=True)
|
||||||
parser_visibility.add_argument('-fd', '--file-ds', help='path to the data source administration YAML file (used to '
|
parser_visibility.add_argument('-fd', '--file-ds', help='path to the data source administration YAML file (used to '
|
||||||
'add metadata on the involved data sources)')
|
'add metadata on the involved data sources)')
|
||||||
|
parser_visibility.add_argument('-a', '--applicable', help='filter techniques based on the applicable_to field in '
|
||||||
|
'the technique administration YAML. Not supported for '
|
||||||
|
'Excel output.'
|
||||||
|
'file', default='all')
|
||||||
parser_visibility.add_argument('-l', '--layer', help='generate a visibility layer for the ATT&CK navigator',
|
parser_visibility.add_argument('-l', '--layer', help='generate a visibility layer for the ATT&CK navigator',
|
||||||
action='store_true')
|
action='store_true')
|
||||||
parser_visibility.add_argument('-e', '--excel', help='generate an Excel sheet with all administrated techniques',
|
parser_visibility.add_argument('-e', '--excel', help='generate an Excel sheet with all administrated techniques',
|
||||||
|
@ -67,8 +71,9 @@ def init_menu():
|
||||||
parser_detection.add_argument('-fd', '--file-ds', help='path to the data source administration YAML file (used in '
|
parser_detection.add_argument('-fd', '--file-ds', help='path to the data source administration YAML file (used in '
|
||||||
'the overlay with visibility to add metadata on the '
|
'the overlay with visibility to add metadata on the '
|
||||||
'involved data sources)')
|
'involved data sources)')
|
||||||
parser_detection.add_argument('-a', '--applicable', help='filter techniques in the layer file based on the'
|
parser_detection.add_argument('-a', '--applicable', help='filter techniques based on the applicable_to field in '
|
||||||
'applicable_to field in the technique administration YAML'
|
'the technique administration YAML. Not supported for '
|
||||||
|
'Excel output.'
|
||||||
'file', default='all')
|
'file', default='all')
|
||||||
parser_detection.add_argument('-l', '--layer', help='generate detection layer for the ATT&CK navigator',
|
parser_detection.add_argument('-l', '--layer', help='generate detection layer for the ATT&CK navigator',
|
||||||
action='store_true')
|
action='store_true')
|
||||||
|
@ -157,9 +162,9 @@ def menu(menu_parser):
|
||||||
if check_file_type(args.file_tech, FILE_TYPE_TECHNIQUE_ADMINISTRATION) and \
|
if check_file_type(args.file_tech, FILE_TYPE_TECHNIQUE_ADMINISTRATION) and \
|
||||||
check_file_type(args.file_ds, FILE_TYPE_DATA_SOURCE_ADMINISTRATION):
|
check_file_type(args.file_ds, FILE_TYPE_DATA_SOURCE_ADMINISTRATION):
|
||||||
if args.layer:
|
if args.layer:
|
||||||
generate_visibility_layer(args.file_tech, args.file_ds, False)
|
generate_visibility_layer(args.file_tech, args.file_ds, False, args.applicable)
|
||||||
if args.overlay:
|
if args.overlay:
|
||||||
generate_visibility_layer(args.file_tech, args.file_ds, True)
|
generate_visibility_layer(args.file_tech, args.file_ds, True, args.applicable)
|
||||||
|
|
||||||
if args.excel and check_file_type(args.file_tech, FILE_TYPE_TECHNIQUE_ADMINISTRATION):
|
if args.excel and check_file_type(args.file_tech, FILE_TYPE_TECHNIQUE_ADMINISTRATION):
|
||||||
export_techniques_list_to_excel(args.file_tech)
|
export_techniques_list_to_excel(args.file_tech)
|
||||||
|
@ -181,7 +186,7 @@ def menu(menu_parser):
|
||||||
if args.overlay and check_file_type(args.file_ds, FILE_TYPE_DATA_SOURCE_ADMINISTRATION):
|
if args.overlay and check_file_type(args.file_ds, FILE_TYPE_DATA_SOURCE_ADMINISTRATION):
|
||||||
generate_detection_layer(args.file_tech, args.file_ds, True, args.applicable)
|
generate_detection_layer(args.file_tech, args.file_ds, True, args.applicable)
|
||||||
if args.graph:
|
if args.graph:
|
||||||
plot_detection_graph(args.file_tech)
|
plot_detection_graph(args.file_tech, args.applicable)
|
||||||
if args.excel:
|
if args.excel:
|
||||||
export_techniques_list_to_excel(args.file_tech)
|
export_techniques_list_to_excel(args.file_tech)
|
||||||
|
|
||||||
|
|
|
@ -259,7 +259,7 @@ def menu_detection(filename_t):
|
||||||
print('Selected techniques YAML file: %s' % filename_t)
|
print('Selected techniques YAML file: %s' % filename_t)
|
||||||
print('')
|
print('')
|
||||||
print('Options:')
|
print('Options:')
|
||||||
print('1. Filter techniques in the layer file based on the applicable_to field in the technique administration YAML file: %s' % filter_applicable_to)
|
print('1. Filter techniques based on the applicable_to field in the technique administration YAML file (not for Excel output): %s' % filter_applicable_to)
|
||||||
print('')
|
print('')
|
||||||
print('Select what you want to do:')
|
print('Select what you want to do:')
|
||||||
print('2. Generate a layer for detection coverage for the ATT&CK Navigator.')
|
print('2. Generate a layer for detection coverage for the ATT&CK Navigator.')
|
||||||
|
@ -302,27 +302,34 @@ def menu_visibility(filename_t, filename_ds):
|
||||||
:param filename_ds:
|
:param filename_ds:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
global filter_applicable_to
|
||||||
clear()
|
clear()
|
||||||
print('Menu: %s' % MENU_NAME_VISIBILITY_MAPPING)
|
print('Menu: %s' % MENU_NAME_VISIBILITY_MAPPING)
|
||||||
print('')
|
print('')
|
||||||
print('Selected techniques YAML file: %s' % filename_t)
|
print('Selected techniques YAML file: %s' % filename_t)
|
||||||
print('Selected data source YAML file: %s' % filename_ds)
|
print('Selected data source YAML file: %s' % filename_ds)
|
||||||
print('')
|
print('')
|
||||||
|
print('Options:')
|
||||||
|
print('1. Filter techniques based on the applicable_to field in the technique administration YAML file (not for Excel output): %s' % filter_applicable_to)
|
||||||
|
print('')
|
||||||
print('Select what you want to do:')
|
print('Select what you want to do:')
|
||||||
print('1. Generate a layer for visibility for the ATT&CK Navigator.')
|
print('2. Generate a layer for visibility for the ATT&CK Navigator.')
|
||||||
print('2. Generate a layers for visibility overlayed with detection coverage for the ATT&CK Navigator.')
|
print('3. Generate a layer for visibility overlayed with detection coverage for the ATT&CK Navigator.')
|
||||||
print('3. Generate an Excel sheet with all administrated techniques.')
|
print('4. Generate an Excel sheet with all administrated techniques.')
|
||||||
print('9. Back to main menu.')
|
print('9. Back to main menu.')
|
||||||
choice = ask_input()
|
choice = ask_input()
|
||||||
if choice == '1':
|
if choice == '1':
|
||||||
print('Writing visibility coverage layer...')
|
print('Specify your filter for the applicable_to field:')
|
||||||
generate_visibility_layer(filename_t, filename_ds, False)
|
filter_applicable_to = ask_input().lower()
|
||||||
wait()
|
|
||||||
elif choice == '2':
|
elif choice == '2':
|
||||||
print('Writing visibility coverage layers overlayed with detections...')
|
print('Writing visibility coverage layer...')
|
||||||
generate_visibility_layer(filename_t, filename_ds, True)
|
generate_visibility_layer(filename_t, filename_ds, False, filter_applicable_to)
|
||||||
wait()
|
wait()
|
||||||
elif choice == '3':
|
elif choice == '3':
|
||||||
|
print('Writing visibility coverage layer overlayed with detections...')
|
||||||
|
generate_visibility_layer(filename_t, filename_ds, True, filter_applicable_to)
|
||||||
|
wait()
|
||||||
|
elif choice == '4':
|
||||||
print('Generating Excel file...')
|
print('Generating Excel file...')
|
||||||
export_techniques_list_to_excel(filename_t)
|
export_techniques_list_to_excel(filename_t)
|
||||||
wait()
|
wait()
|
||||||
|
|
|
@ -12,7 +12,7 @@ def generate_detection_layer(filename_techniques, filename_data_sources, overlay
|
||||||
:param overlay: boolean value to specify if an overlay between detection and visibility should be generated
|
:param overlay: boolean value to specify if an overlay between detection and visibility should be generated
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
my_techniques, name, platform = _load_detections(filename_techniques, filter_applicable_to)
|
my_techniques, name, platform = _load_detections(filename_techniques, 'detection', filter_applicable_to)
|
||||||
|
|
||||||
if not overlay:
|
if not overlay:
|
||||||
mapped_techniques_detection = _map_and_colorize_techniques_for_detections(my_techniques)
|
mapped_techniques_detection = _map_and_colorize_techniques_for_detections(my_techniques)
|
||||||
|
@ -25,7 +25,7 @@ def generate_detection_layer(filename_techniques, filename_data_sources, overlay
|
||||||
_write_layer(layer_both, mapped_techniques_both, 'visibility_and_detection', filter_applicable_to, name)
|
_write_layer(layer_both, mapped_techniques_both, 'visibility_and_detection', filter_applicable_to, name)
|
||||||
|
|
||||||
|
|
||||||
def generate_visibility_layer(filename_techniques, filename_data_sources, overlay):
|
def generate_visibility_layer(filename_techniques, filename_data_sources, overlay, filter_applicable_to):
|
||||||
"""
|
"""
|
||||||
Generates layer for visibility coverage and optionally an overlayed version with detection coverage.
|
Generates layer for visibility coverage and optionally an overlayed version with detection coverage.
|
||||||
:param filename_techniques: the filename of the yaml file containing the techniques administration
|
:param filename_techniques: the filename of the yaml file containing the techniques administration
|
||||||
|
@ -33,26 +33,26 @@ def generate_visibility_layer(filename_techniques, filename_data_sources, overla
|
||||||
:param overlay: boolean value to specify if an overlay between detection and visibility should be generated
|
:param overlay: boolean value to specify if an overlay between detection and visibility should be generated
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
my_techniques, name, platform = _load_detections(filename_techniques)
|
my_techniques, name, platform = _load_detections(filename_techniques, 'visibility', filter_applicable_to)
|
||||||
my_data_sources = _load_data_sources(filename_data_sources)
|
my_data_sources = _load_data_sources(filename_data_sources)
|
||||||
|
|
||||||
if not overlay:
|
if not overlay:
|
||||||
mapped_techniques_visibility = _map_and_colorize_techniques_for_visibility(my_techniques, my_data_sources)
|
mapped_techniques_visibility = _map_and_colorize_techniques_for_visibility(my_techniques, my_data_sources)
|
||||||
layer_visibility = get_layer_template_visibility('Visibility ' + name, 'description', 'attack', platform)
|
layer_visibility = get_layer_template_visibility('Visibility ' + name + ' ' + filter_applicable_to, 'description', 'attack', platform)
|
||||||
_write_layer(layer_visibility, mapped_techniques_visibility, 'visibility', '', name)
|
_write_layer(layer_visibility, mapped_techniques_visibility, 'visibility', filter_applicable_to, name)
|
||||||
else:
|
else:
|
||||||
mapped_techniques_both = _map_and_colorize_techniques_for_overlayed(my_techniques, my_data_sources)
|
mapped_techniques_both = _map_and_colorize_techniques_for_overlayed(my_techniques, my_data_sources)
|
||||||
layer_both = get_layer_template_layered('Visibility and Detection ' + name, 'description', 'attack', platform)
|
layer_both = get_layer_template_layered('Visibility and Detection ' + name + ' ' + filter_applicable_to, 'description', 'attack', platform)
|
||||||
_write_layer(layer_both, mapped_techniques_both, 'visibility_and_detection', '', name)
|
_write_layer(layer_both, mapped_techniques_both, 'visibility_and_detection', filter_applicable_to, name)
|
||||||
|
|
||||||
|
|
||||||
def plot_detection_graph(filename):
|
def plot_detection_graph(filename, filter_applicable_to):
|
||||||
"""
|
"""
|
||||||
Generates a line graph which shows the improvements on detections through the time.
|
Generates a line graph which shows the improvements on detections through the time.
|
||||||
:param filename: the filename of the yaml file containing the techniques administration
|
:param filename: the filename of the yaml file containing the techniques administration
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
my_techniques, name, platform = _load_detections(filename)
|
my_techniques, name, platform = _load_detections(filename, 'detection', filter_applicable_to)
|
||||||
|
|
||||||
graph_values = []
|
graph_values = []
|
||||||
for t in my_techniques.values():
|
for t in my_techniques.values():
|
||||||
|
@ -64,18 +64,18 @@ def plot_detection_graph(filename):
|
||||||
df = pd.DataFrame(graph_values).groupby('date', as_index=False)[['count']].sum()
|
df = pd.DataFrame(graph_values).groupby('date', as_index=False)[['count']].sum()
|
||||||
df['cumcount'] = df.ix[::1, 'count'].cumsum()[::1]
|
df['cumcount'] = df.ix[::1, 'count'].cumsum()[::1]
|
||||||
|
|
||||||
output_filename = 'output/graph_detection.html'
|
output_filename = 'output/graph_detection_%s.html' % filter_applicable_to
|
||||||
import plotly
|
import plotly
|
||||||
import plotly.graph_objs as go
|
import plotly.graph_objs as go
|
||||||
plotly.offline.plot(
|
plotly.offline.plot(
|
||||||
{'data': [go.Scatter(x=df['date'], y=df['cumcount'])],
|
{'data': [go.Scatter(x=df['date'], y=df['cumcount'])],
|
||||||
'layout': go.Layout(title="# of detections for " + name)},
|
'layout': go.Layout(title="# of detections for %s %s" % (name, filter_applicable_to))},
|
||||||
filename=output_filename, auto_open=False
|
filename=output_filename, auto_open=False
|
||||||
)
|
)
|
||||||
print("File written: " + output_filename)
|
print("File written: " + output_filename)
|
||||||
|
|
||||||
|
|
||||||
def _load_detections(filename, filter_applicable_to=''):
|
def _load_detections(filename, detection_or_visibility, filter_applicable_to='all'):
|
||||||
"""
|
"""
|
||||||
Loads the techniques (including detection and visibility properties) from the given yaml file.
|
Loads the techniques (including detection and visibility properties) from the given yaml file.
|
||||||
:param filename: the filename of the yaml file containing the techniques administration
|
:param filename: the filename of the yaml file containing the techniques administration
|
||||||
|
@ -86,12 +86,15 @@ def _load_detections(filename, filter_applicable_to=''):
|
||||||
with open(filename, 'r') as yaml_file:
|
with open(filename, 'r') as yaml_file:
|
||||||
yaml_content = yaml.load(yaml_file, Loader=yaml.FullLoader)
|
yaml_content = yaml.load(yaml_file, Loader=yaml.FullLoader)
|
||||||
for d in yaml_content['techniques']:
|
for d in yaml_content['techniques']:
|
||||||
applicable_to = True
|
if filter_applicable_to == 'all' or filter_applicable_to in d[detection_or_visibility]['applicable_to'] or 'all' in d[detection_or_visibility]['applicable_to']:
|
||||||
if 'applicable_to' in d['detection'].keys():
|
|
||||||
if filter_applicable_to != 'all' and filter_applicable_to not in d['detection']['applicable_to'] and 'all' not in d['detection']['applicable_to']:
|
|
||||||
applicable_to = False
|
|
||||||
if applicable_to:
|
|
||||||
my_techniques[d['technique_id']] = d
|
my_techniques[d['technique_id']] = d
|
||||||
|
|
||||||
|
# Backwards compatibility: adding columns if not present
|
||||||
|
if 'applicable_to' not in d['detection'].keys():
|
||||||
|
d['detection']['applicable_to'] = ['all']
|
||||||
|
if 'applicable_to' not in d['visibility'].keys():
|
||||||
|
d['visibility']['applicable_to'] = ['all']
|
||||||
|
|
||||||
name = yaml_content['name']
|
name = yaml_content['name']
|
||||||
platform = yaml_content['platform']
|
platform = yaml_content['platform']
|
||||||
return my_techniques, name, platform
|
return my_techniques, name, platform
|
||||||
|
@ -157,10 +160,7 @@ def _map_and_colorize_techniques_for_detections(my_techniques):
|
||||||
for tactic in technique['tactic']:
|
for tactic in technique['tactic']:
|
||||||
location = ', '.join(c['detection']['location']) if 'detection' in c.keys() else '-'
|
location = ', '.join(c['detection']['location']) if 'detection' in c.keys() else '-'
|
||||||
location = location if location != '' else '-'
|
location = location if location != '' else '-'
|
||||||
if 'applicable_to' in c['detection'].keys():
|
|
||||||
applicable_to = ', '.join(c['detection']['applicable_to']) if 'detection' in c.keys() else '-'
|
applicable_to = ', '.join(c['detection']['applicable_to']) if 'detection' in c.keys() else '-'
|
||||||
else:
|
|
||||||
applicable_to = '-'
|
|
||||||
x = {}
|
x = {}
|
||||||
x['techniqueID'] = d
|
x['techniqueID'] = d
|
||||||
x['color'] = color
|
x['color'] = color
|
||||||
|
@ -303,7 +303,7 @@ def export_techniques_list_to_excel(filename):
|
||||||
:param filename: the filename of the yaml file containing the techniques administration
|
:param filename: the filename of the yaml file containing the techniques administration
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
my_techniques, name, platform = _load_detections(filename)
|
my_techniques, name, platform = _load_detections(filename, 'detection')
|
||||||
my_techniques = dict(sorted(my_techniques.items(), key=lambda kv: kv[0], reverse=False))
|
my_techniques = dict(sorted(my_techniques.items(), key=lambda kv: kv[0], reverse=False))
|
||||||
mitre_techniques = load_attack_data(DATATYPE_ALL_TECH)
|
mitre_techniques = load_attack_data(DATATYPE_ALL_TECH)
|
||||||
|
|
||||||
|
@ -324,30 +324,34 @@ def export_techniques_list_to_excel(filename):
|
||||||
|
|
||||||
# Header columns
|
# Header columns
|
||||||
worksheet.merge_range(2, 0, 2, 2, 'Technique', format_bold_center_bggrey)
|
worksheet.merge_range(2, 0, 2, 2, 'Technique', format_bold_center_bggrey)
|
||||||
worksheet.merge_range(2, 3, 2, 7, 'Detection', format_bold_center_bgreen)
|
worksheet.merge_range(2, 3, 2, 8, 'Detection', format_bold_center_bgreen)
|
||||||
worksheet.merge_range(2, 8, 2, 9, 'Visibility', format_bold_center_bgblue)
|
worksheet.merge_range(2, 9, 2, 11, 'Visibility', format_bold_center_bgblue)
|
||||||
y = 3
|
y = 3
|
||||||
worksheet.write(y, 0, 'ID', format_bold_left)
|
worksheet.write(y, 0, 'ID', format_bold_left)
|
||||||
worksheet.write(y, 1, 'Tactic', format_bold_left)
|
worksheet.write(y, 1, 'Tactic', format_bold_left)
|
||||||
worksheet.write(y, 2, 'Description', format_bold_left)
|
worksheet.write(y, 2, 'Description', format_bold_left)
|
||||||
worksheet.write(y, 3, 'Date registered', format_bold_left)
|
worksheet.write(y, 3, 'Applicable to', format_bold_left)
|
||||||
worksheet.write(y, 4, 'Date implemented', format_bold_left)
|
worksheet.write(y, 4, 'Date registered', format_bold_left)
|
||||||
worksheet.write(y, 5, 'Score', format_bold_left)
|
worksheet.write(y, 5, 'Date implemented', format_bold_left)
|
||||||
worksheet.write(y, 6, 'Location', format_bold_left)
|
worksheet.write(y, 6, 'Score', format_bold_left)
|
||||||
worksheet.write(y, 7, 'Comment', format_bold_left)
|
worksheet.write(y, 7, 'Location', format_bold_left)
|
||||||
worksheet.write(y, 8, 'Score', format_bold_left)
|
worksheet.write(y, 8, 'Comment', format_bold_left)
|
||||||
worksheet.write(y, 9, 'Comment', format_bold_left)
|
worksheet.write(y, 9, 'Applicable to', format_bold_left)
|
||||||
|
worksheet.write(y, 10, 'Score', format_bold_left)
|
||||||
|
worksheet.write(y, 11, 'Comment', format_bold_left)
|
||||||
|
|
||||||
worksheet.set_column(0, 0, 14)
|
worksheet.set_column(0, 0, 14)
|
||||||
worksheet.set_column(1, 1, 50)
|
worksheet.set_column(1, 1, 50)
|
||||||
worksheet.set_column(2, 2, 40)
|
worksheet.set_column(2, 2, 40)
|
||||||
worksheet.set_column(3, 3, 15)
|
worksheet.set_column(3, 3, 18)
|
||||||
worksheet.set_column(4, 4, 18)
|
worksheet.set_column(4, 4, 15)
|
||||||
worksheet.set_column(5, 5, 8)
|
worksheet.set_column(5, 5, 18)
|
||||||
worksheet.set_column(6, 6, 25)
|
worksheet.set_column(6, 6, 8)
|
||||||
worksheet.set_column(7, 7, 40)
|
worksheet.set_column(7, 7, 25)
|
||||||
worksheet.set_column(8, 8, 8)
|
worksheet.set_column(8, 8, 40)
|
||||||
worksheet.set_column(9, 9, 40)
|
worksheet.set_column(9, 9, 18)
|
||||||
|
worksheet.set_column(10, 10, 8)
|
||||||
|
worksheet.set_column(11, 11, 40)
|
||||||
|
|
||||||
# Putting the techniques:
|
# Putting the techniques:
|
||||||
y = 4
|
y = 4
|
||||||
|
@ -355,16 +359,18 @@ def export_techniques_list_to_excel(filename):
|
||||||
worksheet.write(y, 0, d)
|
worksheet.write(y, 0, d)
|
||||||
worksheet.write(y, 1, ', '.join(t.capitalize() for t in get_technique(mitre_techniques, d)['tactic']))
|
worksheet.write(y, 1, ', '.join(t.capitalize() for t in get_technique(mitre_techniques, d)['tactic']))
|
||||||
worksheet.write(y, 2, get_technique(mitre_techniques, d)['technique'])
|
worksheet.write(y, 2, get_technique(mitre_techniques, d)['technique'])
|
||||||
worksheet.write(y, 3, str(c['detection']['date_registered']).replace('None', ''))
|
worksheet.write(y, 3, ', '.join(c['detection']['applicable_to']))
|
||||||
worksheet.write(y, 4, str(c['detection']['date_implemented']).replace('None', ''))
|
worksheet.write(y, 4, str(c['detection']['date_registered']).replace('None', ''))
|
||||||
worksheet.write(y, 5, c['detection']['score'], format_left)
|
worksheet.write(y, 5, str(c['detection']['date_implemented']).replace('None', ''))
|
||||||
worksheet.write(y, 6, ','.join(c['detection']['location']))
|
worksheet.write(y, 6, c['detection']['score'], format_left)
|
||||||
worksheet.write(y, 7, c['detection']['comment'])
|
worksheet.write(y, 7, '\n'.join(c['detection']['location']))
|
||||||
worksheet.write(y, 8, c['visibility']['score'], format_left)
|
worksheet.write(y, 8, c['detection']['comment'])
|
||||||
worksheet.write(y, 9, c['visibility']['comment'])
|
worksheet.write(y, 9, ', '.join(c['visibility']['applicable_to']))
|
||||||
|
worksheet.write(y, 10, c['visibility']['score'], format_left)
|
||||||
|
worksheet.write(y, 11, c['visibility']['comment'])
|
||||||
y += 1
|
y += 1
|
||||||
|
|
||||||
worksheet.autofilter(3, 0, 3, 9)
|
worksheet.autofilter(3, 0, 3, 11)
|
||||||
worksheet.freeze_panes(4, 0)
|
worksheet.freeze_panes(4, 0)
|
||||||
try:
|
try:
|
||||||
workbook.close()
|
workbook.close()
|
||||||
|
|
Loading…
Reference in New Issue