Skip to content

Commit f3145ea

Browse files
Merge pull request #683 from brodjieski/dev_2.0
Update the logo and banner, add banner generator
2 parents fd31d50 + deff13b commit f3145ea

24 files changed

Lines changed: 306 additions & 616 deletions

scripts/2.0-merge.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,13 @@ def main():
361361
)
362362
new_yaml["tags"].remove("nlmapgov_plus")
363363

364+
if "800-53r4_low" in new_yaml["tags"]:
365+
new_yaml["tags"].remove("800-53r4_low")
366+
if "800-53r4_moderate" in new_yaml["tags"]:
367+
new_yaml["tags"].remove("800-53r4_moderate")
368+
if "800-53r4_high" in new_yaml["tags"]:
369+
new_yaml["tags"].remove("800-53r4_high")
370+
364371
if "nlmapgov_base" in rule_yaml["tags"]:
365372
if "benchmarks" in new_yaml["platforms"]["macOS"][os_]:
366373
new_yaml["platforms"]["macOS"][os_]["benchmarks"].append(
@@ -1231,12 +1238,7 @@ def main():
12311238
)
12321239
new_yaml["tags"].remove("nlmapgov_base")
12331240

1234-
if (
1235-
os_ == "ios_18"
1236-
or os_ == "ios_17"
1237-
or os_ == "ios_16"
1238-
or os_ == "ios_26"
1239-
):
1241+
if os_ == "ios_18" or os_ == "ios_17" or os_ == "ios_26":
12401242
new_yaml["tags"].remove("ios")
12411243
new_yaml["platforms"].update({"iOS": {}})
12421244
new_yaml["platforms"]["iOS"].update({os_: {}})

src/mscp/admin_utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from .build_baselines import build_all_baselines
1010
from .rule_utilities import add_new_rule
11+
from .banner_generator import generate_mscp_banners
1112

1213

1314
__all__ = [
1415
"build_all_baselines",
1516
"add_new_rule",
17+
"generate_mscp_banners",
1618
]
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""
2+
Banner image generator for the macOS Security Compliance Project (mSCP).
3+
4+
Produces per-platform banner images (macOS, iOS, visionOS) in light, dark,
5+
and high-contrast (8 px stroke) modes by compositing the mSCP logo with
6+
platform-specific text and a rounded colored divider.
7+
8+
Background removal uses a BFS flood-fill from image edges (``isolate_logo``).
9+
Logo pixels are recolored per platform via ``change_color``.
10+
11+
Public entry point: ``generate_mscp_banners()``
12+
13+
Configuration keys consumed from ``config``:
14+
images_dir — directory containing ``mscp_logo.png``; output is written here.
15+
16+
Output filename pattern:
17+
mscp_banner_<platform>_<mode>.png
18+
where platform ∈ {macos, ios, visionos} and mode ∈ {light, dark, both_8px}.
19+
"""
20+
21+
import argparse
22+
from collections import deque
23+
from pathlib import Path
24+
25+
from PIL import Image, ImageDraw, ImageFont
26+
from yaspin.core import Yaspin
27+
from yaspin.spinners import Spinners
28+
29+
from ..common_utils import (
30+
config,
31+
logger,
32+
conditional_inject_spinner,
33+
)
34+
35+
LIGHT = {"fill": (0, 0, 0, 255), "stroke_fill": None, "stroke_width": 0}
36+
DARK = {"fill": (255, 255, 255, 255), "stroke_fill": None, "stroke_width": 0}
37+
BOTH_8PX = {
38+
"fill": (255, 255, 255, 255),
39+
"stroke_fill": (0, 0, 0, 255),
40+
"stroke_width": 8,
41+
}
42+
43+
MSCP_GREEN = (41, 99, 54, 255)
44+
PLATFORMS = ["macOS", "iOS", "visionOS"]
45+
PLATFORM_COLORS = {
46+
"macOS": (41, 99, 54, 255),
47+
"iOS": (88, 86, 214, 255),
48+
"visionOS": (255, 45, 85, 255),
49+
}
50+
51+
SUFFIX = "Security Configuration Guide"
52+
SUBTITLE = "as generated by the macOS Security Compliance Project"
53+
MODES = {"light": LIGHT, "dark": DARK, "both_8px": BOTH_8PX}
54+
FONT = "HelveticaNeue.ttc"
55+
56+
BANNER_HEIGHT = 420
57+
LOGO_LEFT_MARGIN = 110
58+
LOGO_RIGHT_PADDING = 70
59+
DIVIDER_GAP_LEFT = 25
60+
DIVIDER_GAP_RIGHT = 25
61+
DIVIDER_WIDTH = 8
62+
DIVIDER_HEIGHT = 92
63+
GAP_BETWEEN = 22
64+
CANVAS_RIGHT_PADDING = 40
65+
66+
67+
def color_dist(a: tuple, b: tuple) -> int:
68+
return sum(abs(a[i] - b[i]) for i in range(3))
69+
70+
71+
def isolate_logo(logo_path: str, threshold: int = 40) -> Image.Image:
72+
"""Remove the background of a logo image using a flood-fill from the edges.
73+
74+
Assumes the top-left pixel is representative of the background color.
75+
Edge-reachable pixels within *threshold* color distance (sum of absolute
76+
RGB channel differences) of that background are made fully transparent.
77+
The result is cropped to the tightest bounding box of remaining opaque content.
78+
79+
Args:
80+
logo_path: Path to the source image (any PIL-supported format).
81+
threshold: Maximum RGB channel distance to treat a pixel as background.
82+
83+
Returns:
84+
RGBA Image with background removed and cropped to content bounds.
85+
"""
86+
img = Image.open(logo_path).convert("RGBA")
87+
w, h = img.size
88+
src_pixels = img.load()
89+
bg = img.getpixel((0, 0)) # top-left pixel assumed to be background
90+
91+
# visited is indexed [x][y] to match PIL's (x, y) convention
92+
visited = [[False] * h for _ in range(w)]
93+
q = deque()
94+
95+
for x in range(w):
96+
for y in (0, h - 1):
97+
if not visited[x][y] and color_dist(src_pixels[x, y], bg) <= threshold:
98+
visited[x][y] = True
99+
q.append((x, y))
100+
for y in range(h):
101+
for x in (0, w - 1):
102+
if not visited[x][y] and color_dist(src_pixels[x, y], bg) <= threshold:
103+
visited[x][y] = True
104+
q.append((x, y))
105+
106+
while q:
107+
x, y = q.popleft()
108+
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
109+
if 0 <= nx < w and 0 <= ny < h and not visited[nx][ny]:
110+
if color_dist(src_pixels[nx, ny], bg) <= threshold:
111+
visited[nx][ny] = True
112+
q.append((nx, ny))
113+
114+
out = img.copy()
115+
out_pixels = out.load()
116+
for x in range(w):
117+
for y in range(h):
118+
if visited[x][y]:
119+
out_pixels[x, y] = (0, 0, 0, 0)
120+
121+
bbox = out.getchannel("A").getbbox()
122+
return out.crop(bbox)
123+
124+
125+
def font(
126+
font_path: str, size: int, bold: bool = False, italic: bool = False
127+
) -> ImageFont.FreeTypeFont:
128+
# TTC index: 0=regular, 1=bold, 2=italic
129+
index = 2 if italic else (1 if bold else 0)
130+
return ImageFont.truetype(font_path, size, index=index)
131+
132+
133+
def draw_text(
134+
draw: ImageDraw.ImageDraw,
135+
xy: tuple,
136+
text: str,
137+
fnt: ImageFont.FreeTypeFont,
138+
style: dict,
139+
) -> None:
140+
kwargs = {"font": fnt, "fill": style["fill"]}
141+
if style.get("stroke_fill") is not None and style.get("stroke_width", 0) > 0:
142+
kwargs["stroke_fill"] = style["stroke_fill"]
143+
kwargs["stroke_width"] = style["stroke_width"]
144+
draw.text(xy, text, **kwargs)
145+
146+
147+
def change_color(image: Image.Image, old_color: tuple, new_color: tuple, tolerance: int = 0) -> Image.Image:
148+
pixels = image.load()
149+
width, height = image.size
150+
151+
for y in range(height):
152+
for x in range(width):
153+
r, g, b, a = pixels[x, y]
154+
if a == 0:
155+
continue
156+
if (
157+
abs(r - old_color[0]) <= tolerance
158+
and abs(g - old_color[1]) <= tolerance
159+
and abs(b - old_color[2]) <= tolerance
160+
):
161+
pixels[x, y] = new_color
162+
return image
163+
164+
165+
@conditional_inject_spinner()
166+
def generate_mscp_banners(sp: Yaspin, args: argparse.Namespace) -> None:
167+
"""Generate MSCP banner images for all supported platforms and display modes.
168+
169+
Reads ``mscp_logo.png`` from ``config["images_dir"]``, removes its background,
170+
recolors it per platform, and composites it with title text onto a transparent
171+
RGBA canvas. Nine files are written in total (3 platforms × 3 modes).
172+
173+
Args:
174+
sp: Yaspin spinner instance injected by ``@conditional_inject_spinner``.
175+
args: Parsed CLI arguments from argparse.
176+
"""
177+
logger.info("Generating MSCP banners...")
178+
sp.spinner = Spinners.dots
179+
sp.text = "Generating MSCP banners"
180+
181+
outdir = Path(config["images_dir"])
182+
logo = isolate_logo(Path(config["images_dir"]) / "mscp_logo.png")
183+
184+
platform_font = font(FONT, 72, bold=True)
185+
suffix_font = font(FONT, 62)
186+
subtitle_font = font(FONT, 24, italic=True)
187+
188+
probe = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
189+
dp = ImageDraw.Draw(probe)
190+
191+
platform_widths = {}
192+
platform_heights = {}
193+
max_platform_h = 0
194+
for p in PLATFORMS:
195+
bb = dp.textbbox((0, 0), p, font=platform_font)
196+
platform_widths[p] = bb[2] - bb[0]
197+
platform_heights[p] = bb[3] - bb[1]
198+
max_platform_h = max(max_platform_h, platform_heights[p])
199+
200+
suffix_bb = dp.textbbox((0, 0), SUFFIX, font=suffix_font)
201+
suffix_h = suffix_bb[3] - suffix_bb[1]
202+
suffix_w = suffix_bb[2] - suffix_bb[0]
203+
204+
block_h = max_platform_h + GAP_BETWEEN + suffix_h
205+
206+
logo_w, logo_h = logo.size
207+
resized_logo_h = BANNER_HEIGHT - 40
208+
resized_logo_w = int(logo_w * (resized_logo_h / logo_h))
209+
logo_resized = logo.resize((resized_logo_w, resized_logo_h), Image.LANCZOS)
210+
211+
logo_x = LOGO_LEFT_MARGIN
212+
logo_y = (BANNER_HEIGHT - logo_resized.height) // 2
213+
text_left = logo_x + logo_resized.width + LOGO_RIGHT_PADDING
214+
top_y = logo_y + (logo_resized.height - block_h) // 2 + 10
215+
216+
text_block_h = max(max(platform_heights.values()), suffix_h)
217+
platform_y = (BANNER_HEIGHT - text_block_h) // 2 - 6
218+
suffix_y = platform_y + 8
219+
220+
divider_y = (BANNER_HEIGHT - DIVIDER_HEIGHT) // 2 + 8
221+
222+
count = 0
223+
for p in PLATFORMS:
224+
logger.info(f"Generating banner for {p}...")
225+
platform_w = platform_widths[p]
226+
227+
divider_x = text_left + platform_w + DIVIDER_GAP_LEFT
228+
suffix_x = divider_x + DIVIDER_GAP_RIGHT + DIVIDER_WIDTH
229+
230+
colored_logo = change_color(
231+
logo_resized.copy(),
232+
MSCP_GREEN[:3],
233+
PLATFORM_COLORS.get(p, MSCP_GREEN[:3]),
234+
tolerance=25,
235+
)
236+
237+
canvas_width = (
238+
text_left
239+
+ platform_w
240+
+ DIVIDER_GAP_LEFT
241+
+ DIVIDER_WIDTH
242+
+ DIVIDER_GAP_RIGHT
243+
+ suffix_w
244+
+ CANVAS_RIGHT_PADDING
245+
)
246+
247+
for mode_name, style in MODES.items():
248+
canvas = Image.new("RGBA", (canvas_width, BANNER_HEIGHT), (0, 0, 0, 0))
249+
draw = ImageDraw.Draw(canvas)
250+
canvas.alpha_composite(colored_logo, (logo_x, logo_y))
251+
draw_text(draw, (text_left, platform_y), p, platform_font, style)
252+
draw.rounded_rectangle(
253+
(divider_x, divider_y, divider_x + DIVIDER_WIDTH, divider_y + DIVIDER_HEIGHT),
254+
fill=PLATFORM_COLORS.get(p, MSCP_GREEN),
255+
radius=DIVIDER_WIDTH // 2,
256+
)
257+
draw_text(draw, (suffix_x, suffix_y), SUFFIX, suffix_font, style)
258+
draw_text(
259+
draw,
260+
(text_left, top_y + DIVIDER_HEIGHT + GAP_BETWEEN),
261+
SUBTITLE,
262+
subtitle_font,
263+
style,
264+
)
265+
canvas.save(outdir / f"mscp_banner_{p.lower()}_{mode_name}.png")
266+
count += 1
267+
268+
logger.info(f"Created {count} files in {outdir}")
269+
sp.text = f"Created {count} files in {outdir}"
270+
sp.ok("✔")

src/mscp/admin_utils/build_baselines.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,17 @@ def build_all_baselines(args: argparse.Namespace) -> None:
6363
args.controls = False
6464

6565
# exclude generating baselines for these keys
66-
excluded_tags = {"arm64", "i368", "inherent", "manual", "n_a", "none", "permanent"}
66+
excluded_tags = {
67+
"arm64",
68+
"i386",
69+
"inherent",
70+
"manual",
71+
"n_a",
72+
"none",
73+
"permanent",
74+
"supplemental",
75+
"800-53r5_privacy",
76+
}
6777

6878
all_rules: list[Macsecurityrule] = Macsecurityrule.collect_all_rules(
6979
args.os_name, args.os_version, args.tailor, parent_values="Default"

src/mscp/cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from pathlib import Path
1616

1717
# Local python modules
18-
from .admin_utils import build_all_baselines, add_new_rule
18+
from .admin_utils import (
19+
build_all_baselines,
20+
add_new_rule,
21+
generate_mscp_banners,
22+
)
1923
from .common_utils import (
2024
logger,
2125
set_logger,
@@ -517,6 +521,14 @@ def parse_cli() -> None:
517521
)
518522
add_rule_parser.set_defaults(func=add_new_rule)
519523

524+
generate_banners_parser = admin_subparsers.add_parser(
525+
"banners",
526+
parents=[parent_parser],
527+
help="generate MSCP banners with the updated color scheme for use in documentation and other collateral",
528+
add_help=False,
529+
)
530+
generate_banners_parser.set_defaults(func=generate_mscp_banners)
531+
520532
validate_parser: argparse.ArgumentParser = admin_subparsers.add_parser(
521533
"validate",
522534
help="validates the YAML files against the mscp_rule.json schema found in the rules and custom directories",
-163 KB
Binary file not shown.
97.7 KB
Loading
84 KB
Loading
82 KB
Loading
102 KB
Loading

0 commit comments

Comments
 (0)