sophos-central-mcp-server 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config/config.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SophosConfig {
|
|
|
12
12
|
export declare function loadConfig(): SophosConfig;
|
|
13
13
|
export declare const SOPHOS_AUTH_URL = "https://id.sophos.com/api/v2/oauth2/token";
|
|
14
14
|
export declare const SOPHOS_GLOBAL_API = "https://api.central.sophos.com";
|
|
15
|
-
export declare const CHARACTER_LIMIT
|
|
15
|
+
export declare const CHARACTER_LIMIT: number;
|
|
16
16
|
export declare const DEFAULT_PAGE_SIZE = 50;
|
|
17
17
|
export declare const MAX_PAGE_SIZE = 100;
|
|
18
18
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC7B;AAED,wBAAgB,UAAU,IAAI,YAAY,CAwBzC;AAGD,eAAO,MAAM,eAAe,8CAA8C,CAAC;AAC3E,eAAO,MAAM,iBAAiB,mCAAmC,CAAC;AAGlE,eAAO,MAAM,eAAe,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC7B;AAED,wBAAgB,UAAU,IAAI,YAAY,CAwBzC;AAGD,eAAO,MAAM,eAAe,8CAA8C,CAAC;AAC3E,eAAO,MAAM,iBAAiB,mCAAmC,CAAC;AAGlE,eAAO,MAAM,eAAe,QAG3B,CAAC;AACF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,aAAa,MAAM,CAAC"}
|
package/dist/config/config.js
CHANGED
|
@@ -23,8 +23,8 @@ export function loadConfig() {
|
|
|
23
23
|
// Sophos Central global API endpoints
|
|
24
24
|
export const SOPHOS_AUTH_URL = "https://id.sophos.com/api/v2/oauth2/token";
|
|
25
25
|
export const SOPHOS_GLOBAL_API = "https://api.central.sophos.com";
|
|
26
|
-
// Response size limits
|
|
27
|
-
export const CHARACTER_LIMIT =
|
|
26
|
+
// Response size limits (CHARACTER_LIMIT configurable via env var)
|
|
27
|
+
export const CHARACTER_LIMIT = Math.max(10000, parseInt(process.env.CHARACTER_LIMIT || "50000", 10) || 50000);
|
|
28
28
|
export const DEFAULT_PAGE_SIZE = 50;
|
|
29
29
|
export const MAX_PAGE_SIZE = 100;
|
|
30
30
|
//# sourceMappingURL=config.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAUH,MAAM,UAAU,UAAU;IACxB,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;IAC9C,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC;IAEtD,IAAI,CAAC,QAAQ,IAAI,CAAC,YAAY,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,gGAAgG,CACjG,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;IACzD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC,IAAI,OAAO,GAAG,KAAK,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CACb,uBAAuB,OAAO,CAAC,GAAG,CAAC,IAAI,4CAA4C,CACpF,CAAC;IACJ,CAAC;IAED,OAAO;QACL,QAAQ;QACR,YAAY;QACZ,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,SAAS;QACnD,IAAI,EAAE,OAAO;QACb,SAAS,EAAG,OAAO,CAAC,GAAG,CAAC,SAA8B,IAAI,MAAM;KACjE,CAAC;AACJ,CAAC;AAED,sCAAsC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,2CAA2C,CAAC;AAC3E,MAAM,CAAC,MAAM,iBAAiB,GAAG,gCAAgC,CAAC;AAElE,
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAUH,MAAM,UAAU,UAAU;IACxB,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;IAC9C,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC;IAEtD,IAAI,CAAC,QAAQ,IAAI,CAAC,YAAY,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,gGAAgG,CACjG,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;IACzD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC,IAAI,OAAO,GAAG,KAAK,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CACb,uBAAuB,OAAO,CAAC,GAAG,CAAC,IAAI,4CAA4C,CACpF,CAAC;IACJ,CAAC;IAED,OAAO;QACL,QAAQ;QACR,YAAY;QACZ,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,SAAS;QACnD,IAAI,EAAE,OAAO;QACb,SAAS,EAAG,OAAO,CAAC,GAAG,CAAC,SAA8B,IAAI,MAAM;KACjE,CAAC;AACJ,CAAC;AAED,sCAAsC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,2CAA2C,CAAC;AAC3E,MAAM,CAAC,MAAM,iBAAiB,GAAG,gCAAgC,CAAC;AAElE,kEAAkE;AAClE,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,CACrC,KAAK,EACL,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,OAAO,EAAE,EAAE,CAAC,IAAI,KAAK,CAC9D,CAAC;AACF,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AACpC,MAAM,CAAC,MAAM,aAAa,GAAG,GAAG,CAAC"}
|
|
@@ -5,7 +5,7 @@ sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
|
|
5
5
|
from fpdf import FPDF
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
|
-
#
|
|
8
|
+
# -- Sophos brand colours --
|
|
9
9
|
SOPHOS_BLUE = (0, 53, 128)
|
|
10
10
|
SOPHOS_DARK = (34, 34, 34)
|
|
11
11
|
SOPHOS_RED = (200, 30, 30)
|
|
@@ -14,192 +14,277 @@ SOPHOS_GREEN = (40, 140, 60)
|
|
|
14
14
|
SOPHOS_GREY = (100, 100, 100)
|
|
15
15
|
WHITE = (255, 255, 255)
|
|
16
16
|
LIGHT_BG = (245, 247, 250)
|
|
17
|
-
ROW_ALT = (
|
|
17
|
+
ROW_ALT = (232, 238, 248)
|
|
18
|
+
|
|
19
|
+
PAGE_W = 190 # usable width (A4 210mm - 10mm margins each side)
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
class PDF(FPDF):
|
|
23
|
+
"""Custom PDF with Sophos-branded header/footer."""
|
|
24
|
+
|
|
25
|
+
_skip_header = False # flag for cover page
|
|
26
|
+
|
|
20
27
|
def header(self):
|
|
21
|
-
self.
|
|
28
|
+
if self._skip_header:
|
|
29
|
+
return
|
|
30
|
+
self.set_font("Helvetica", "B", 8)
|
|
22
31
|
self.set_text_color(*SOPHOS_GREY)
|
|
23
|
-
self.cell(0,
|
|
24
|
-
self.ln(
|
|
32
|
+
self.cell(0, 5, "Rodin Business Solutions | Sophos Central Estate Health Report | March 2026", align="L")
|
|
33
|
+
self.ln(3)
|
|
25
34
|
self.set_draw_color(*SOPHOS_BLUE)
|
|
26
|
-
self.set_line_width(0.
|
|
35
|
+
self.set_line_width(0.5)
|
|
27
36
|
self.line(10, self.get_y(), 200, self.get_y())
|
|
28
|
-
self.ln(
|
|
37
|
+
self.ln(5)
|
|
29
38
|
|
|
30
39
|
def footer(self):
|
|
31
40
|
self.set_y(-15)
|
|
32
41
|
self.set_font("Helvetica", "", 8)
|
|
33
42
|
self.set_text_color(*SOPHOS_GREY)
|
|
34
|
-
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}
|
|
43
|
+
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C")
|
|
44
|
+
|
|
45
|
+
# -- Helpers ----------------------------------------------------------
|
|
35
46
|
|
|
36
47
|
def section_title(self, title, colour=SOPHOS_BLUE):
|
|
37
48
|
self.set_font("Helvetica", "B", 14)
|
|
38
49
|
self.set_text_color(*colour)
|
|
39
50
|
self.cell(0, 10, title)
|
|
40
|
-
self.ln(
|
|
51
|
+
self.ln(9)
|
|
41
52
|
self.set_text_color(*SOPHOS_DARK)
|
|
42
53
|
|
|
43
54
|
def sub_title(self, title, colour=SOPHOS_DARK):
|
|
44
55
|
self.set_font("Helvetica", "B", 11)
|
|
45
56
|
self.set_text_color(*colour)
|
|
46
57
|
self.cell(0, 8, title)
|
|
47
|
-
self.ln(
|
|
58
|
+
self.ln(7)
|
|
48
59
|
self.set_text_color(*SOPHOS_DARK)
|
|
49
60
|
|
|
50
|
-
def body_text(self, text):
|
|
51
|
-
self.set_font("Helvetica", "",
|
|
61
|
+
def body_text(self, text, size=10):
|
|
62
|
+
self.set_font("Helvetica", "", size)
|
|
52
63
|
self.set_text_color(*SOPHOS_DARK)
|
|
53
64
|
self.multi_cell(0, 5.5, text)
|
|
54
65
|
self.ln(2)
|
|
55
66
|
|
|
56
67
|
def table_header(self, cols):
|
|
57
|
-
|
|
68
|
+
"""Draw a header row. cols = [(label, width), ...]"""
|
|
69
|
+
self.set_font("Helvetica", "B", 8)
|
|
58
70
|
self.set_fill_color(*SOPHOS_BLUE)
|
|
59
71
|
self.set_text_color(*WHITE)
|
|
60
72
|
for label, w in cols:
|
|
61
|
-
self.cell(w, 7, label, border=0, fill=True, align="
|
|
73
|
+
self.cell(w, 7, f" {label}", border=0, fill=True, align="L")
|
|
62
74
|
self.ln()
|
|
63
75
|
self.set_text_color(*SOPHOS_DARK)
|
|
64
76
|
|
|
65
|
-
def
|
|
66
|
-
|
|
77
|
+
def _row_height(self, cols, font_size=8):
|
|
78
|
+
"""Calculate the row height needed for the tallest multi-cell."""
|
|
79
|
+
self.set_font("Helvetica", "", font_size)
|
|
80
|
+
max_lines = 1
|
|
81
|
+
for val, w, _align in cols:
|
|
82
|
+
text = str(val)
|
|
83
|
+
# Estimate number of lines: char width ~2.1mm at size 8
|
|
84
|
+
char_w = font_size * 0.23
|
|
85
|
+
chars_per_line = max(int(w / char_w), 1)
|
|
86
|
+
lines = 1
|
|
87
|
+
for paragraph in text.split("\n"):
|
|
88
|
+
lines += max(0, (len(paragraph) - 1) // chars_per_line)
|
|
89
|
+
if len(paragraph) > 0:
|
|
90
|
+
pass
|
|
91
|
+
lines += 0 # already counted
|
|
92
|
+
# Simpler: use fpdf's internal string width
|
|
93
|
+
str_w = self.get_string_width(text)
|
|
94
|
+
est_lines = max(1, int(str_w / (w - 2)) + 1)
|
|
95
|
+
max_lines = max(max_lines, est_lines)
|
|
96
|
+
return max(6.5, max_lines * 4.2)
|
|
97
|
+
|
|
98
|
+
def table_row(self, cols, row_idx=0, font_size=8):
|
|
99
|
+
"""Draw a data row with alternating colours. cols = [(val, width, align), ...]"""
|
|
100
|
+
self.set_font("Helvetica", "", font_size)
|
|
67
101
|
if row_idx % 2 == 1:
|
|
68
102
|
self.set_fill_color(*ROW_ALT)
|
|
69
|
-
fill = True
|
|
70
103
|
else:
|
|
71
104
|
self.set_fill_color(*WHITE)
|
|
72
|
-
|
|
105
|
+
|
|
106
|
+
# Check if we need a page break (leave room for row + footer)
|
|
107
|
+
row_h = 6.5
|
|
108
|
+
if self.get_y() + row_h > self.h - self.b_margin - 5:
|
|
109
|
+
self.add_page()
|
|
110
|
+
|
|
73
111
|
for val, w, align in cols:
|
|
74
|
-
self.cell(w,
|
|
112
|
+
self.cell(w, row_h, f" {str(val)}", border=0, fill=True, align=align)
|
|
75
113
|
self.ln()
|
|
76
114
|
|
|
115
|
+
def table_row_multi(self, cols, row_idx=0, row_h=6.5, font_size=8):
|
|
116
|
+
"""Draw a row that may wrap text in the last column."""
|
|
117
|
+
self.set_font("Helvetica", "", font_size)
|
|
118
|
+
if row_idx % 2 == 1:
|
|
119
|
+
self.set_fill_color(*ROW_ALT)
|
|
120
|
+
else:
|
|
121
|
+
self.set_fill_color(*WHITE)
|
|
122
|
+
|
|
123
|
+
if self.get_y() + row_h > self.h - self.b_margin - 5:
|
|
124
|
+
self.add_page()
|
|
125
|
+
|
|
126
|
+
x_start = self.get_x()
|
|
127
|
+
y_start = self.get_y()
|
|
128
|
+
|
|
129
|
+
# Draw fixed cells
|
|
130
|
+
for val, w, align in cols[:-1]:
|
|
131
|
+
self.cell(w, row_h, f" {str(val)}", border=0, fill=True, align=align)
|
|
132
|
+
|
|
133
|
+
# Draw last cell as multi_cell for wrapping
|
|
134
|
+
last_val, last_w, last_align = cols[-1]
|
|
135
|
+
x_last = self.get_x()
|
|
136
|
+
self.multi_cell(last_w, row_h / max(1, len(str(last_val)) // 40 + 1),
|
|
137
|
+
f" {str(last_val)}", border=0, fill=True, align=last_align)
|
|
77
138
|
|
|
139
|
+
y_end = self.get_y()
|
|
140
|
+
actual_h = y_end - y_start
|
|
141
|
+
if actual_h < row_h:
|
|
142
|
+
self.set_y(y_start + row_h)
|
|
143
|
+
|
|
144
|
+
def check_space(self, needed_mm=30):
|
|
145
|
+
"""Add a page if less than needed_mm space remains."""
|
|
146
|
+
if self.get_y() + needed_mm > self.h - self.b_margin:
|
|
147
|
+
self.add_page()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ===========================================================================
|
|
151
|
+
# BUILD THE PDF
|
|
152
|
+
# ===========================================================================
|
|
78
153
|
pdf = PDF(orientation="P", unit="mm", format="A4")
|
|
79
154
|
pdf.alias_nb_pages()
|
|
80
155
|
pdf.set_auto_page_break(auto=True, margin=20)
|
|
81
156
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
# ═══════════════════════════════════════════════════════════════════════════════
|
|
157
|
+
# ===== COVER PAGE ==========================================================
|
|
158
|
+
pdf._skip_header = True
|
|
85
159
|
pdf.add_page()
|
|
86
|
-
pdf.ln(
|
|
87
|
-
|
|
160
|
+
pdf.ln(30)
|
|
161
|
+
|
|
162
|
+
# Title block
|
|
163
|
+
pdf.set_font("Helvetica", "B", 30)
|
|
88
164
|
pdf.set_text_color(*SOPHOS_BLUE)
|
|
89
165
|
pdf.cell(0, 14, "Sophos Central", align="C")
|
|
90
|
-
pdf.ln(14)
|
|
91
|
-
pdf.set_font("Helvetica", "B", 22)
|
|
92
|
-
pdf.cell(0, 12, "Estate Health & Opportunity Report", align="C")
|
|
93
166
|
pdf.ln(16)
|
|
94
|
-
pdf.set_font("Helvetica", "",
|
|
95
|
-
pdf.
|
|
96
|
-
pdf.
|
|
97
|
-
pdf.ln(8)
|
|
98
|
-
pdf.cell(0, 8, "Prepared by Sophos Central MCP Automation", align="C")
|
|
99
|
-
pdf.ln(20)
|
|
167
|
+
pdf.set_font("Helvetica", "B", 20)
|
|
168
|
+
pdf.cell(0, 12, "Estate Health & Opportunity Report", align="C")
|
|
169
|
+
pdf.ln(18)
|
|
100
170
|
|
|
101
|
-
#
|
|
171
|
+
# Decorative line
|
|
102
172
|
pdf.set_draw_color(*SOPHOS_BLUE)
|
|
103
|
-
pdf.set_line_width(0
|
|
173
|
+
pdf.set_line_width(1.0)
|
|
174
|
+
pdf.line(60, pdf.get_y(), 150, pdf.get_y())
|
|
175
|
+
pdf.ln(12)
|
|
176
|
+
|
|
177
|
+
pdf.set_font("Helvetica", "", 14)
|
|
178
|
+
pdf.set_text_color(*SOPHOS_GREY)
|
|
179
|
+
pdf.cell(0, 8, "Rodin Business Solutions | March 2026", align="C")
|
|
180
|
+
pdf.ln(30)
|
|
104
181
|
|
|
182
|
+
# Summary metric boxes
|
|
105
183
|
metrics = [
|
|
106
184
|
("153", "Total Tenants"),
|
|
107
185
|
("115", "Healthy (75%)"),
|
|
108
186
|
("34", "With Gaps (22%)"),
|
|
109
|
-
("$38,923", "Monthly AUD"),
|
|
187
|
+
("$38,923/mo", "Monthly AUD"),
|
|
110
188
|
]
|
|
111
189
|
box_w = 42
|
|
112
|
-
|
|
190
|
+
gap = 4
|
|
191
|
+
total_w = len(metrics) * box_w + (len(metrics) - 1) * gap
|
|
192
|
+
start_x = (210 - total_w) / 2
|
|
193
|
+
y_boxes = pdf.get_y()
|
|
194
|
+
|
|
113
195
|
for i, (val, label) in enumerate(metrics):
|
|
114
|
-
x = start_x + i * (box_w +
|
|
115
|
-
pdf.set_xy(x, pdf.get_y())
|
|
196
|
+
x = start_x + i * (box_w + gap)
|
|
116
197
|
pdf.set_fill_color(*LIGHT_BG)
|
|
117
|
-
pdf.
|
|
118
|
-
pdf.
|
|
119
|
-
pdf.
|
|
198
|
+
pdf.set_draw_color(*SOPHOS_BLUE)
|
|
199
|
+
pdf.set_line_width(0.4)
|
|
200
|
+
pdf.rect(x, y_boxes, box_w, 26, style="FD")
|
|
201
|
+
pdf.set_xy(x, y_boxes + 4)
|
|
202
|
+
pdf.set_font("Helvetica", "B", 20)
|
|
120
203
|
pdf.set_text_color(*SOPHOS_BLUE)
|
|
121
204
|
pdf.cell(box_w, 10, val, align="C")
|
|
122
|
-
pdf.set_xy(x,
|
|
205
|
+
pdf.set_xy(x, y_boxes + 15)
|
|
123
206
|
pdf.set_font("Helvetica", "", 9)
|
|
124
207
|
pdf.set_text_color(*SOPHOS_GREY)
|
|
125
208
|
pdf.cell(box_w, 6, label, align="C")
|
|
126
209
|
|
|
127
|
-
pdf.
|
|
210
|
+
pdf.set_y(y_boxes + 36)
|
|
128
211
|
|
|
212
|
+
# Executive summary
|
|
129
213
|
pdf.set_text_color(*SOPHOS_DARK)
|
|
130
214
|
pdf.section_title("Executive Summary")
|
|
131
215
|
pdf.body_text(
|
|
132
216
|
"This report provides a comprehensive health assessment of the Rodin Business Solutions "
|
|
133
|
-
"managed Sophos Central estate for March 2026.
|
|
134
|
-
"
|
|
135
|
-
"
|
|
217
|
+
"managed Sophos Central estate for March 2026. Across 153 managed tenants, 75% are fully "
|
|
218
|
+
"healthy with no configuration gaps detected. 22% have identifiable security or licensing "
|
|
219
|
+
"gaps that require remediation. The remaining tenants represent commercial upsell opportunities."
|
|
136
220
|
)
|
|
137
221
|
pdf.body_text(
|
|
138
222
|
"Key findings:\n"
|
|
139
|
-
"- 4 tenants have tamper protection completely disabled (0%)
|
|
140
|
-
"- 1 tenant has server protection at 0% despite having enrolled servers\n"
|
|
141
|
-
"-
|
|
142
|
-
"-
|
|
143
|
-
"-
|
|
223
|
+
" - 4 tenants have tamper protection completely disabled (0%) representing critical risk\n"
|
|
224
|
+
" - 1 tenant has server protection at 0% despite having enrolled servers\n"
|
|
225
|
+
" - 6 tenants have high-priority partial tamper protection gaps on servers\n"
|
|
226
|
+
" - 10 tenants have moderate gaps (92-99% TP) likely from stale devices\n"
|
|
227
|
+
" - 14 tenants have no server protection enrolled\n"
|
|
228
|
+
" - Multiple customers lack MDR or Sophos Firewall coverage (detailed in sections 5-7)"
|
|
144
229
|
)
|
|
145
230
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
#
|
|
231
|
+
pdf._skip_header = False
|
|
232
|
+
|
|
233
|
+
# ===== SECTION 2: CRITICAL HEALTH GAPS ====================================
|
|
149
234
|
pdf.add_page()
|
|
150
235
|
pdf.section_title("1. Critical Health Gaps", SOPHOS_RED)
|
|
151
236
|
pdf.body_text(
|
|
152
237
|
"The following tenants have tamper protection at 0% or server protection failures. "
|
|
153
|
-
"These represent the highest security risk and
|
|
154
|
-
"Without tamper protection, a threat actor who gains access can disable
|
|
155
|
-
"protection entirely."
|
|
238
|
+
"These represent the highest security risk and must be remediated immediately. "
|
|
239
|
+
"Without tamper protection, a threat actor who gains endpoint access can disable "
|
|
240
|
+
"Sophos protection entirely."
|
|
156
241
|
)
|
|
157
242
|
|
|
158
243
|
critical = [
|
|
159
|
-
("Marco Polo Aged Care Services", "TP 0% computers AND servers", "Re-enable global TP immediately
|
|
160
|
-
("Unibuild", "TP 0% computers", "Re-enable global TP
|
|
244
|
+
("Marco Polo Aged Care Services", "TP 0% computers AND servers", "Re-enable global TP immediately"),
|
|
245
|
+
("Unibuild", "TP 0% computers", "Re-enable global TP"),
|
|
161
246
|
("MasterCraft Windows and Doors", "TP 0% computers", "Re-enable global TP"),
|
|
162
|
-
("Martin Morris & Jones Sydney", "TP 0% computers", "Re-enable global TP
|
|
163
|
-
("WIN Sports & Entertainment Centres", "Server protection 0% (2 servers)", "Investigate
|
|
247
|
+
("Martin Morris & Jones Sydney", "TP 0% computers", "Re-enable global TP"),
|
|
248
|
+
("WIN Sports & Entertainment Centres", "Server protection 0% (2 servers)", "Investigate enrolled servers"),
|
|
164
249
|
]
|
|
165
250
|
|
|
166
|
-
|
|
167
|
-
pdf.table_header(
|
|
251
|
+
cols_def = [("Customer", 58), ("Issue", 58), ("Recommended Action", 74)]
|
|
252
|
+
pdf.table_header(cols_def)
|
|
168
253
|
for i, (cust, issue, action) in enumerate(critical):
|
|
169
|
-
pdf.table_row([(cust,
|
|
254
|
+
pdf.table_row([(cust, 58, "L"), (issue, 58, "L"), (action, 74, "L")], i)
|
|
170
255
|
|
|
171
|
-
pdf.ln(
|
|
256
|
+
pdf.ln(8)
|
|
172
257
|
|
|
173
|
-
#
|
|
174
|
-
# SECTION 3: HIGH & MODERATE GAPS
|
|
175
|
-
# ═══════════════════════════════════════════════════════════════════════════════
|
|
258
|
+
# ===== SECTION 3: HIGH PRIORITY GAPS ======================================
|
|
176
259
|
pdf.section_title("2. High Priority Gaps", SOPHOS_AMBER)
|
|
177
260
|
pdf.body_text(
|
|
178
261
|
"These tenants have tamper protection below 90% on servers or significant partial gaps. "
|
|
179
|
-
"In most cases
|
|
262
|
+
"In most cases, 1-2 specific endpoints or servers need TP re-enabled."
|
|
180
263
|
)
|
|
181
264
|
|
|
182
265
|
high = [
|
|
183
|
-
("Durham Green", "TP 59% computers", "
|
|
184
|
-
("Southern Truck Centre", "TP 99% PC, 66% servers", "1 of 3 servers
|
|
266
|
+
("Durham Green", "TP 59% computers", "Audit and re-enable on multiple endpoints"),
|
|
267
|
+
("Southern Truck Centre", "TP 99% PC, 66% servers", "1 of 3 servers TP disabled"),
|
|
185
268
|
("Gunderson Briggs", "TP 98% PC, 88% servers", "1 server + 1 computer TP disabled"),
|
|
186
|
-
("IOH Injury & Occupational Health", "TP 99% PC, 88% servers", "1 server
|
|
187
|
-
("Project Portfolio Management", "TP 80% servers", "1 server
|
|
188
|
-
("Sydney Automotive Paints & Equipment", "TP 85% servers", "1 server
|
|
269
|
+
("IOH Injury & Occupational Health", "TP 99% PC, 88% servers", "1 server TP disabled"),
|
|
270
|
+
("Project Portfolio Management", "TP 80% servers", "1 server TP disabled"),
|
|
271
|
+
("Sydney Automotive Paints & Equipment", "TP 85% servers", "1 server TP disabled"),
|
|
189
272
|
]
|
|
190
273
|
|
|
191
|
-
|
|
192
|
-
pdf.table_header(
|
|
274
|
+
cols_def = [("Customer", 62), ("Issue", 60), ("Action", 68)]
|
|
275
|
+
pdf.table_header(cols_def)
|
|
193
276
|
for i, (cust, issue, action) in enumerate(high):
|
|
194
|
-
pdf.table_row([(cust,
|
|
277
|
+
pdf.table_row([(cust, 62, "L"), (issue, 60, "L"), (action, 68, "L")], i)
|
|
195
278
|
|
|
196
|
-
pdf.ln(
|
|
279
|
+
pdf.ln(8)
|
|
197
280
|
|
|
198
|
-
|
|
281
|
+
# ===== SECTION 4: MODERATE GAPS ===========================================
|
|
282
|
+
pdf.check_space(60)
|
|
283
|
+
pdf.section_title("3. Moderate Gaps (Cleanup Required)")
|
|
199
284
|
pdf.body_text(
|
|
200
285
|
"These tenants have tamper protection between 92-99%. Typically 1-2 stale or "
|
|
201
|
-
"decommissioned endpoints.
|
|
202
|
-
"
|
|
286
|
+
"decommissioned endpoints. Check if devices are still active; re-enable TP or "
|
|
287
|
+
"remove stale registrations."
|
|
203
288
|
)
|
|
204
289
|
|
|
205
290
|
moderate = [
|
|
@@ -215,152 +300,200 @@ moderate = [
|
|
|
215
300
|
("Cram Foundation", "99%", "1 endpoint"),
|
|
216
301
|
]
|
|
217
302
|
|
|
218
|
-
|
|
219
|
-
pdf.table_header(
|
|
303
|
+
cols_def = [("Customer", 80), ("TP Score", 40), ("Likely Scope", 70)]
|
|
304
|
+
pdf.table_header(cols_def)
|
|
220
305
|
for i, (cust, score, scope) in enumerate(moderate):
|
|
221
|
-
pdf.table_row([(cust,
|
|
306
|
+
pdf.table_row([(cust, 80, "L"), (score, 40, "C"), (scope, 70, "L")], i)
|
|
222
307
|
|
|
223
|
-
#
|
|
224
|
-
# SECTION 4: UPSELL - NO MDR
|
|
225
|
-
# ═══════════════════════════════════════════════════════════════════════════════
|
|
308
|
+
# ===== SECTION 5: UPSELL - NO MDR =========================================
|
|
226
309
|
pdf.add_page()
|
|
227
|
-
pdf.section_title("4. Upsell
|
|
228
|
-
|
|
229
|
-
pdf.sub_title("4a. Customers Without MDR", SOPHOS_BLUE)
|
|
310
|
+
pdf.section_title("4. Upsell: Customers Without MDR")
|
|
230
311
|
pdf.body_text(
|
|
231
|
-
"The following customers are on Sophos Endpoint only
|
|
232
|
-
"
|
|
233
|
-
"
|
|
312
|
+
"The following customers are on Sophos Endpoint only with no MDR coverage. "
|
|
313
|
+
"MDR adds 24/7 threat detection and response by the Sophos MDR team. "
|
|
314
|
+
"Sorted by monthly spend to prioritise the largest existing relationships."
|
|
234
315
|
)
|
|
235
316
|
|
|
236
317
|
no_mdr = [
|
|
237
|
-
("Southern Truck Centre Wollongong", 41, 2, "$446.91", "Endpoint + XG 210
|
|
238
|
-
("Kintyre Country Living", 3, 0, "$415.77", "XG 310
|
|
239
|
-
("
|
|
240
|
-
("
|
|
241
|
-
("
|
|
242
|
-
("
|
|
318
|
+
("Southern Truck Centre Wollongong", 41, 2, "$446.91", "Endpoint + XG 210", "Strong MDR candidate (43 assets)"),
|
|
319
|
+
("Kintyre Country Living", 3, 0, "$415.77", "XG 310 + Endpoint", "Low user count but premium FW"),
|
|
320
|
+
("REI Process", 0, 0, "$392.30", "XGS 2100 + SF Virtual", "Device-only - check if endpoints needed"),
|
|
321
|
+
("University of Wollongong SmartSpace", 0, 0, "$357.72", "XGS 3300", "Single large firewall"),
|
|
322
|
+
("Autopact", 0, 0, "$306.32", "XGS 126 + SF Virtual", "Device-only estate"),
|
|
323
|
+
("IOH Injury & Occupational Health", 0, 0, "$285.38", "Multiple firewalls", "7 devices no MDR"),
|
|
324
|
+
("Community Gateway Limited", 0, 0, "$181.28", "XGS 126 + XGS 2100", "Firewall-only"),
|
|
325
|
+
("Martin Morris & Jones Wollongong", 0, 0, "$159.72", "XGS 2100 + XGS 107", "Firewall-only"),
|
|
326
|
+
("Gunderson Briggs", 23, 2, "$136.11", "Endpoint + XGS 136", "25 assets - strong candidate"),
|
|
327
|
+
("Custom Built Frames & Trusses", 3, 1, "$33.57", "Endpoint + XGS 107", "Small but bundleable"),
|
|
328
|
+
("The Illawarra ITEC Ltd", 3, 1, "$14.21", "Endpoint only", "Small tenant"),
|
|
329
|
+
("Tulich Project Management", 0, 2, "$13.24", "Server Endpoint only", "Needs MDR Essentials minimum"),
|
|
243
330
|
]
|
|
244
331
|
|
|
245
|
-
|
|
246
|
-
pdf.table_header(
|
|
247
|
-
for i, (cust, users, servers, rev,
|
|
248
|
-
pdf.table_row([
|
|
332
|
+
cols_def = [("Customer", 52), ("Users", 12), ("Svrs", 12), ("Monthly $", 20), ("Current Products", 42), ("Opportunity", 52)]
|
|
333
|
+
pdf.table_header(cols_def)
|
|
334
|
+
for i, (cust, users, servers, rev, products, opp) in enumerate(no_mdr):
|
|
335
|
+
pdf.table_row([
|
|
336
|
+
(cust, 52, "L"), (str(users), 12, "C"), (str(servers), 12, "C"),
|
|
337
|
+
(rev, 20, "R"), (products, 42, "L"), (opp, 52, "L")
|
|
338
|
+
], i)
|
|
249
339
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
pdf.
|
|
340
|
+
# ===== SECTION 6: UPSELL - NO FIREWALL ====================================
|
|
341
|
+
pdf.add_page()
|
|
342
|
+
pdf.section_title("5. Upsell: Customers Without Sophos Firewall (20+ Users)")
|
|
253
343
|
pdf.body_text(
|
|
254
|
-
"
|
|
255
|
-
"in their billing.
|
|
256
|
-
"
|
|
344
|
+
"Customers with 20 or more users and no Sophos firewall (XGS, SF Virtual, or XG) "
|
|
345
|
+
"in their billing. Sorted by monthly spend. Those already on MDR Complete "
|
|
346
|
+
"would benefit most from firewall-endpoint integration via Synchronized Security."
|
|
257
347
|
)
|
|
258
348
|
|
|
259
349
|
no_fw = [
|
|
260
|
-
("Sydwest Multicultural Services", 114, 5, "$1,253.48", "
|
|
261
|
-
("Master Builders Assoc NSW", 94, 11, "$1,163.00", "
|
|
262
|
-
("
|
|
263
|
-
("
|
|
264
|
-
("
|
|
265
|
-
("Community Gateway", 54, 2, "$586.82", "
|
|
266
|
-
("
|
|
267
|
-
("
|
|
350
|
+
("Sydwest Multicultural Services", 114, 5, "$1,253.48", "Complete", "TOP PRIORITY - largest no-FW customer"),
|
|
351
|
+
("Master Builders Assoc NSW", 94, 11, "$1,163.00", "Complete", "105 assets with no network protection"),
|
|
352
|
+
("ReadiNow Corporation", 58, 66, "$1,121.98", "Essentials", "124 assets - also MDR upgrade candidate"),
|
|
353
|
+
("Martin Moris & Jones", 79, 2, "$967.30", "Complete", "81 assets no firewall"),
|
|
354
|
+
("QCC", 82, 2, "$871.58", "Complete", "84 assets no network protection"),
|
|
355
|
+
("Community Gateway", 54, 2, "$586.82", "Complete", "56 assets"),
|
|
356
|
+
("Ingenuity Design Group", 67, 17, "$557.20", "Essentials", "84 assets - also MDR upgrade candidate"),
|
|
357
|
+
("Macarthur Diversity Services", 42, 0, "$427.14", "Complete", "No FW, no servers"),
|
|
358
|
+
("Atlas Engineering Group", 30, 1, "$323.92", "Complete", "31 assets"),
|
|
359
|
+
("Regupol", 26, 3, "$320.88", "Complete", "29 assets"),
|
|
360
|
+
("Gordon MacDonald", 31, 0, "$294.45", "Complete", "31 users"),
|
|
361
|
+
("MMJ Real Estate", 23, 0, "$233.91", "Complete", "23 users"),
|
|
362
|
+
("QE Innovations", 19, 1, "$212.05", "Complete", "20 assets"),
|
|
363
|
+
("IAP2 Australasia", 20, 0, "$203.40", "Complete", "20 users"),
|
|
364
|
+
("Market St", 19, 0, "$193.23", "Complete", "19 users"),
|
|
365
|
+
("Southern Pumping", 18, 0, "$183.06", "Complete", "18 users"),
|
|
366
|
+
("Andrew Webb Accounting", 18, 0, "$183.06", "Complete", "18 users"),
|
|
367
|
+
("Anisimoff Legal", 17, 0, "$172.89", "Complete", "17 users"),
|
|
268
368
|
]
|
|
269
369
|
|
|
270
|
-
|
|
271
|
-
pdf.table_header(
|
|
272
|
-
for i, (cust, users, servers, rev,
|
|
273
|
-
pdf.table_row([
|
|
370
|
+
cols_def = [("Customer", 52), ("Users", 14), ("Svrs", 14), ("Monthly $", 22), ("MDR Tier", 22), ("Opportunity", 66)]
|
|
371
|
+
pdf.table_header(cols_def)
|
|
372
|
+
for i, (cust, users, servers, rev, mdr, opp) in enumerate(no_fw):
|
|
373
|
+
pdf.table_row([
|
|
374
|
+
(cust, 52, "L"), (str(users), 14, "C"), (str(servers), 14, "C"),
|
|
375
|
+
(rev, 22, "R"), (mdr, 22, "C"), (opp, 66, "L")
|
|
376
|
+
], i)
|
|
274
377
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
pdf.
|
|
378
|
+
# ===== SECTION 7: MDR ESSENTIALS TO COMPLETE ===============================
|
|
379
|
+
pdf.add_page()
|
|
380
|
+
pdf.section_title("6. MDR Essentials to Complete Upgrade Path")
|
|
278
381
|
pdf.body_text(
|
|
279
|
-
"These customers have MDR Essentials and
|
|
280
|
-
"
|
|
281
|
-
"
|
|
382
|
+
"These customers currently have MDR Essentials and are candidates for upgrade to "
|
|
383
|
+
"MDR Complete. MDR Complete adds human-led threat hunting, full incident response, "
|
|
384
|
+
"and a dedicated Sophos response team. Sorted by monthly spend."
|
|
282
385
|
)
|
|
283
386
|
|
|
284
|
-
|
|
285
|
-
("ReadiNow Corporation", 58, 66, "$1,121.98", "124 assets on Essentials
|
|
286
|
-
("Moduplay Group", 125, 2, "$816.40", "127 assets
|
|
287
|
-
("Ingenuity Design Group", 67, 17, "$557.20", "84 assets
|
|
288
|
-
("National Justice Project", 29, 0, "$149.64", "29 users on Essentials
|
|
387
|
+
essentials = [
|
|
388
|
+
("ReadiNow Corporation", 58, 66, "$1,121.98", "124 assets on Essentials - largest upgrade opportunity"),
|
|
389
|
+
("Moduplay Group", 125, 2, "$816.40", "127 assets + 3 firewalls"),
|
|
390
|
+
("Ingenuity Design Group", 67, 17, "$557.20", "84 assets - also no firewall"),
|
|
391
|
+
("National Justice Project", 29, 0, "$149.64", "29 users on Essentials"),
|
|
289
392
|
]
|
|
290
393
|
|
|
291
|
-
|
|
292
|
-
pdf.table_header(
|
|
293
|
-
for i, (cust, users, servers, rev, notes) in enumerate(
|
|
294
|
-
pdf.table_row([
|
|
394
|
+
cols_def = [("Customer", 52), ("Users", 16), ("Servers", 16), ("Monthly $", 24), ("Notes", 82)]
|
|
395
|
+
pdf.table_header(cols_def)
|
|
396
|
+
for i, (cust, users, servers, rev, notes) in enumerate(essentials):
|
|
397
|
+
pdf.table_row([
|
|
398
|
+
(cust, 52, "L"), (str(users), 16, "C"), (str(servers), 16, "C"),
|
|
399
|
+
(rev, 24, "R"), (notes, 82, "L")
|
|
400
|
+
], i)
|
|
295
401
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
#
|
|
299
|
-
pdf.
|
|
300
|
-
pdf.section_title("5. No Server Protection Enrolled")
|
|
402
|
+
pdf.ln(8)
|
|
403
|
+
|
|
404
|
+
# ===== SECTION 8: NO SERVER PROTECTION =====================================
|
|
405
|
+
pdf.section_title("7. No Server Protection Enrolled (14 Tenants)")
|
|
301
406
|
pdf.body_text(
|
|
302
|
-
"
|
|
303
|
-
"
|
|
304
|
-
"
|
|
407
|
+
"These tenants have no server protection enrolled in Sophos Central. For customers "
|
|
408
|
+
"with on-premises or cloud servers, Sophos Server Protection or MDR Server should "
|
|
409
|
+
"be deployed. Some may genuinely have no servers (SaaS-only organisations)."
|
|
305
410
|
)
|
|
306
411
|
|
|
307
412
|
no_server = [
|
|
308
|
-
"Big Fat Smile",
|
|
309
|
-
"
|
|
310
|
-
"
|
|
311
|
-
"
|
|
312
|
-
"
|
|
413
|
+
"Big Fat Smile",
|
|
414
|
+
"Intrec Management Pty Ltd",
|
|
415
|
+
"ROKT Pty Limited",
|
|
416
|
+
"Golden West Holden",
|
|
417
|
+
"Etheridge Ford",
|
|
418
|
+
"Gateway Motor Group",
|
|
419
|
+
"C-Lite Pty Ltd",
|
|
420
|
+
"C-Lite",
|
|
421
|
+
"True Blue Chemicals",
|
|
422
|
+
"Sydney Auto Paint",
|
|
423
|
+
"CHL Wollongong Pty Limited",
|
|
424
|
+
"Pumps United",
|
|
425
|
+
"Seapost Pty Ltd t/as Dion's Bus Service",
|
|
426
|
+
"Kiama Community College",
|
|
313
427
|
]
|
|
314
428
|
|
|
315
|
-
|
|
316
|
-
pdf.table_header(
|
|
429
|
+
cols_def = [("Customer", 95), ("Recommended Action", 95)]
|
|
430
|
+
pdf.table_header(cols_def)
|
|
317
431
|
for i, cust in enumerate(no_server):
|
|
318
432
|
action = "Assess server estate; deploy Sophos Server Protection or MDR Server"
|
|
319
433
|
pdf.table_row([(cust, 95, "L"), (action, 95, "L")], i)
|
|
320
434
|
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
435
|
+
# ===== SECTION 9: ACTION PLAN =============================================
|
|
436
|
+
pdf.add_page()
|
|
437
|
+
pdf.section_title("8. Recommended Action Plan")
|
|
438
|
+
|
|
439
|
+
# Immediate
|
|
440
|
+
pdf.sub_title("Immediate - This Week", SOPHOS_RED)
|
|
441
|
+
imm = [
|
|
442
|
+
("Fix Critical TP Gaps", "Re-enable tamper protection on 4 tenants at 0%: Marco Polo Aged Care, Unibuild, MasterCraft Windows and Doors, Martin Morris & Jones Sydney"),
|
|
443
|
+
("Investigate WIN Sports", "2 enrolled servers showing 0% protection - determine root cause"),
|
|
444
|
+
("Fix Durham Green", "TP at 59% - multiple endpoints need TP re-enabled"),
|
|
445
|
+
]
|
|
446
|
+
cols_def = [("Action", 50), ("Detail", 140)]
|
|
447
|
+
pdf.table_header(cols_def)
|
|
448
|
+
for i, (action, detail) in enumerate(imm):
|
|
449
|
+
pdf.table_row([(action, 50, "L"), (detail, 140, "L")], i)
|
|
326
450
|
|
|
327
|
-
pdf.
|
|
328
|
-
pdf.body_text(
|
|
329
|
-
"1. Re-enable tamper protection on the 4 critical tenants (Marco Polo Aged Care, "
|
|
330
|
-
"Unibuild, MasterCraft, Martin Morris & Jones Sydney)\n"
|
|
331
|
-
"2. Investigate WIN Sports server protection at 0%\n"
|
|
332
|
-
"3. Fix Durham Green (59% TP) - multiple endpoints need TP re-enabled"
|
|
333
|
-
)
|
|
451
|
+
pdf.ln(6)
|
|
334
452
|
|
|
335
|
-
|
|
336
|
-
pdf.
|
|
337
|
-
|
|
338
|
-
"
|
|
339
|
-
"
|
|
340
|
-
"Gunderson Briggs (25 assets)"
|
|
341
|
-
|
|
453
|
+
# Short term
|
|
454
|
+
pdf.sub_title("Short Term - This Month", SOPHOS_AMBER)
|
|
455
|
+
short = [
|
|
456
|
+
("Fix Server TP Gaps", "Southern Truck Centre, Gunderson Briggs, IOH, Project Portfolio Management, Sydney Automotive Paints & Equipment"),
|
|
457
|
+
("Clean Up Moderate Gaps", "10 tenants with 92-99% TP scores - likely stale devices to remove or re-enable"),
|
|
458
|
+
("Begin MDR Conversations", "Top targets: Southern Truck Centre (43 assets), Gunderson Briggs (25 assets), Tulich Project Management (servers with no MDR)"),
|
|
459
|
+
]
|
|
460
|
+
cols_def = [("Action", 50), ("Detail", 140)]
|
|
461
|
+
pdf.table_header(cols_def)
|
|
462
|
+
for i, (action, detail) in enumerate(short):
|
|
463
|
+
pdf.table_row([(action, 50, "L"), (detail, 140, "L")], i)
|
|
342
464
|
|
|
343
|
-
pdf.
|
|
344
|
-
pdf.body_text(
|
|
345
|
-
"7. Firewall opportunities: Sydwest Multicultural (114 users, no FW), Master Builders (105 assets, no FW), "
|
|
346
|
-
"QCC (84 assets, no FW)\n"
|
|
347
|
-
"8. MDR Complete upgrades: ReadiNow (124 assets), Moduplay (127 assets), Ingenuity Design (84 assets)\n"
|
|
348
|
-
"9. Server protection for the 14 tenants with no server enrollment\n"
|
|
349
|
-
"10. Review all 142 billing customers against the 153 tenants - 11 tenants have no billing "
|
|
350
|
-
"(may be trial, internal, or decommissioned)"
|
|
351
|
-
)
|
|
465
|
+
pdf.ln(6)
|
|
352
466
|
|
|
467
|
+
# QBR
|
|
468
|
+
pdf.sub_title("QBR Conversations", SOPHOS_BLUE)
|
|
469
|
+
qbr = [
|
|
470
|
+
("Firewall Opportunities", "Sydwest Multicultural (114 users), Master Builders (94 users), QCC (82 users) - all MDR Complete with no firewall"),
|
|
471
|
+
("MDR Complete Upgrades", "ReadiNow (124 assets), Moduplay (127 assets), Ingenuity Design (84 assets) - all on MDR Essentials"),
|
|
472
|
+
("Server Protection", "14 tenants with no server enrolment - assess server estates and deploy protection"),
|
|
473
|
+
]
|
|
474
|
+
cols_def = [("Action", 50), ("Detail", 140)]
|
|
475
|
+
pdf.table_header(cols_def)
|
|
476
|
+
for i, (action, detail) in enumerate(qbr):
|
|
477
|
+
pdf.table_row([(action, 50, "L"), (detail, 140, "L")], i)
|
|
478
|
+
|
|
479
|
+
pdf.ln(10)
|
|
480
|
+
|
|
481
|
+
# Data source note
|
|
482
|
+
pdf.set_draw_color(*SOPHOS_BLUE)
|
|
483
|
+
pdf.set_line_width(0.3)
|
|
484
|
+
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
|
|
353
485
|
pdf.ln(4)
|
|
354
|
-
pdf.set_font("Helvetica", "I",
|
|
486
|
+
pdf.set_font("Helvetica", "I", 8)
|
|
355
487
|
pdf.set_text_color(*SOPHOS_GREY)
|
|
356
|
-
pdf.multi_cell(0, 5, (
|
|
357
|
-
"Data sourced from Sophos Central API via MCP Server v0.2.
|
|
488
|
+
pdf.multi_cell(0, 4.5, (
|
|
489
|
+
"Data sourced from Sophos Central API via MCP Server v0.2.4. "
|
|
358
490
|
"Health data from Account Health Check API. Billing data from Partner Billing Usage API (March 2026). "
|
|
359
491
|
"All monetary values in AUD at partner net pricing."
|
|
360
492
|
))
|
|
361
493
|
|
|
362
|
-
#
|
|
494
|
+
# == Save ==================================================================
|
|
363
495
|
out_dir = r"C:\Users\Aaron.Jacobs\OneDrive - Sophos Ltd\projects"
|
|
364
496
|
out_path = os.path.join(out_dir, "Rodin_Sophos_Estate_Health_Report_March_2026.pdf")
|
|
365
497
|
pdf.output(out_path)
|
|
366
498
|
print(f"Report saved to: {out_path}")
|
|
499
|
+
print(f"Pages: {pdf.page_no()}")
|
package/package.json
CHANGED