"""
Meridian — DevSecOps doc generator.
Produces _docs/devsecops.pdf: a user guide to this repo's automated security
scanning, organized by category — SAST (SonarQube, CodeQL) and SCA
(Dependabot, Dependency Review) — covering what each tool is, how it's
configured on GitHub and in code, and exactly how it's wired up here.
"""
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, ListFlowable, ListItem,
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT
DARK = colors.HexColor('#0d0d1a')
SLATE = colors.HexColor('#334155')
GRAY = colors.HexColor('#64748b')
BORDER = colors.HexColor('#e2e8f0')
CODE_BG = colors.HexColor('#f4f4f5')
NOTE_BG = colors.HexColor('#f8fafc')
TABLE_HEAD = colors.HexColor('#1e293b')
styles = getSampleStyleSheet()
title_style = ParagraphStyle('TitleX', parent=styles['Title'], fontName='Helvetica-Bold',
fontSize=22, alignment=TA_CENTER, spaceAfter=4, textColor=DARK)
subtitle_style = ParagraphStyle('SubtitleX', parent=styles['Normal'], fontName='Helvetica-Oblique',
fontSize=10.5, alignment=TA_CENTER, textColor=GRAY, spaceAfter=18)
h1_style = ParagraphStyle('H1X', parent=styles['Heading1'], fontName='Helvetica-Bold',
fontSize=15, spaceBefore=16, spaceAfter=8, textColor=DARK)
h2_style = ParagraphStyle('H2X', parent=styles['Heading2'], fontName='Helvetica-Bold',
fontSize=11.5, spaceBefore=10, spaceAfter=5, textColor=SLATE)
body_style = ParagraphStyle('BodyX', parent=styles['Normal'], fontName='Helvetica',
fontSize=10, leading=14.5, spaceAfter=8, alignment=TA_LEFT)
bullet_style = ParagraphStyle('BulletX', parent=body_style, spaceAfter=4)
note_style = ParagraphStyle('NoteX', parent=styles['Normal'], fontName='Helvetica-Oblique',
fontSize=8.7, leading=12.5, textColor=GRAY, leftIndent=10)
code_style = ParagraphStyle('CodeX', parent=styles['Normal'], fontName='Courier',
fontSize=8.3, leading=11.5, textColor=DARK)
table_head_style = ParagraphStyle('TableHeadX', parent=styles['Normal'], fontName='Helvetica-Bold',
fontSize=8.7, textColor=colors.white, leading=11)
table_body_style = ParagraphStyle('TableBodyX', parent=styles['Normal'], fontName='Helvetica',
fontSize=8.7, leading=11.5)
table_body_mono_style = ParagraphStyle('TableBodyMonoX', parent=table_body_style, fontName='Courier', fontSize=8)
def code_block(text: str):
p = Paragraph(text.replace('\n', '
').replace(' ', ' '), code_style)
t = Table([[p]], colWidths=[168 * mm])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), CODE_BG),
('BOX', (0, 0), (-1, -1), 0.5, BORDER),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
return t
def note(text: str):
p = Paragraph(text, note_style)
t = Table([[p]], colWidths=[168 * mm])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), NOTE_BG),
('LINEBEFORE', (0, 0), (0, -1), 2, BORDER),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
return t
def bullets(items):
return ListFlowable(
[ListItem(Paragraph(t, bullet_style), leftIndent=0) for t in items],
bulletType='bullet', start='•', leftIndent=14, bulletFontSize=8, spaceBefore=2, spaceAfter=8,
)
def metrics_table(rows, col_widths):
header = [Paragraph(h, table_head_style) for h in rows[0]]
data = [header]
for r in rows[1:]:
data.append([Paragraph(c, table_body_style) for c in r])
t = Table(data, colWidths=col_widths, repeatRows=1)
style = [
('BACKGROUND', (0, 0), (-1, 0), TABLE_HEAD),
('BOX', (0, 0), (-1, -1), 0.5, BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, BORDER),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]
for i in range(2, len(data), 2):
style.append(('BACKGROUND', (0, i), (-1, i), colors.HexColor('#fafafa')))
t.setStyle(TableStyle(style))
return t
def build():
out_path = os.path.join(os.path.dirname(__file__), '..', '_docs', 'devsecops.pdf')
doc = SimpleDocTemplate(out_path, pagesize=A4,
topMargin=20 * mm, bottomMargin=18 * mm,
leftMargin=20 * mm, rightMargin=20 * mm)
story = []
story.append(Paragraph('DevSecOps: SAST, SCA & DAST Scanning', title_style))
story.append(Paragraph('A user guide to automated security scanning for the Meridian (myblogs) project', subtitle_style))
story.append(Paragraph(
'Every pull request into main runs three categories of automated security scanning, defined in '
'.github/workflows/pr-check.yml ("PR Checks") alongside the ordinary '
'build/test jobs:', body_style))
story.append(bullets([
'SAST (Static Application Security Testing) — analyzes this project’s own source code, '
'without running it (Section 1).',
'SCA (Software Composition Analysis) — inspects the third-party packages it depends on, not '
'its own code (Section 2).',
'DAST (Dynamic Application Security Testing) — tests the application while it’s actually '
'running, by interacting with it like a black-box attacker would (Section 3).',
]))
story.append(Paragraph(
'Each category is covered by more than one tool — not for redundancy, but because each tool catches '
'a different slice of the problem, explained in its own section below. Section 4 explains SARIF, the '
'file format that gets some (not all) of these tools’ results onto GitHub’s Security tab; Section 5 '
'is a short reference table if you just want the at-a-glance version; and Section 6 is an honest '
'list of what this pipeline still doesn’t cover, verified against this repo’s actual live '
'configuration rather than assumed.', body_style))
# ══════════════════════════════════════════════════════════════════════
# 1. SAST — Static Application Security Testing
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('1. SAST — Static Application Security Testing', h1_style))
story.append(Paragraph('1.1 What is SAST?', h2_style))
story.append(Paragraph(
'SAST analyzes a project’s own source code without running it, looking for bugs and '
'exploitable security flaws — injection, XSS, hardcoded secrets, insecure crypto — directly in the '
'code a developer wrote. It never touches third-party packages; that’s SCA’s job (Section 2). Two '
'SAST tools run here, covering different depths of the same first-party code:', body_style))
story.append(bullets([
'SonarQube — broad code quality + security: bugs, vulnerabilities, code smells, security '
'hotspots, duplication, and a coverage-gated Quality Gate.',
'CodeQL — narrower but deeper: real interprocedural dataflow/taint-tracking specifically for '
'security vulnerabilities, with fewer false positives on the bug classes it targets.',
]))
story.append(note(
'Why both, not one: SonarQube’s vulnerability detection is rule/pattern-based, which is fast and '
'broad but can miss or over-flag findings that depend on how data actually moves through the '
'program. CodeQL’s dataflow analysis is slower and narrower in scope, but traces that movement '
'precisely. Running both means whichever style catches a given bug catches it — neither tool alone '
'covers what the other does.'))
# ── 1.2 SonarQube ─────────────────────────────────────────────────────
story.append(Paragraph('1.2 SonarQube', h2_style))
story.append(Paragraph(
'What it is & what it’s used for. A self-hosted static analysis platform that parses '
'source (and ingests test coverage reports you provide) and evaluates the result against a '
'configurable set of rules and thresholds called a Quality Gate. It checks four categories: '
'Bugs (Reliability), Vulnerabilities (Security), Code Smells (maintainability), '
'and Security Hotspots (security-sensitive code a human must manually confirm is or isn’t '
'exploitable in context). Alongside issues, it also tracks test coverage and duplicated '
'code density as gate conditions.', body_style))
story.append(note(
'SonarQube Community Edition (what this project uses) does not scan third-party dependencies for '
'known CVEs — no SCA capability at all. That gap is exactly what Section 2’s tools cover.'))
story.append(Paragraph(
'Configuring on GitHub. Nothing to enable in repo Settings — SonarQube is self-hosted, not a '
'GitHub-native feature. The only GitHub-side piece is three repository secrets '
'(SONAR_TOKEN, SONAR_HOST_URL, '
'SONAR_PROJECT_KEY) that the CI job injects as environment variables — '
'without them the scan/gate steps in pr-check.yml fail outright, since '
'there is no server to talk to.', body_style))
story.append(Paragraph(
'Configuring in code. Three files, each with one job:', body_style))
story.append(bullets([
'sonar-project.properties (repo root) — what gets scanned, independent '
'of secrets: sonar.sources (scan root), '
'sonar.exclusions (keeps node_modules/, dist/, *.db, uploads/, '
'coverage/, .github/ out of analysis), sonar.javascript.file.suffixes / '
'sonar.typescript.file.suffixes (includes .vue as analyzable), and the '
'two coverage report-path settings (Section 1.2, coverage below).',
'package.json scripts — sonar runs the scan '
'(the @sonar/scan npm package’s sonar-scanner '
'binary — no Java install or Docker image needed), sonar:gate checks the '
'result, sonar:check chains both.',
'scripts/sonar-quality-gate.sh — the actual gate logic (below), called '
'identically by both a developer’s machine and CI so neither ever runs different logic than the '
'other can reproduce.',
]))
story.append(note(
'sonar.projectKey=${SONAR_PROJECT_KEY} inside sonar-project.properties is a literal placeholder — '
'SonarScanner only substitutes ${env.VAR_NAME}-style references. The real key is supplied explicitly '
'on the command line instead (below), so this placeholder has no functional effect.'))
story.append(Paragraph(
'How it’s done here. Locally, with .env populated with real '
'SONAR_* values (loaded via dotenv-cli, the '
'same tool the project’s start scripts already use):', body_style))
story.append(code_block(
'npm run sonar # run the scan only, uploads results to the server\n'
'npm run sonar:gate # check the gate result of the most recent scan\n'
'npm run sonar:check # run both in sequence; exits non-zero if the gate fails\n'
'npm run sonar:full # coverage:all + sonar:check — the whole CI pipeline, locally'))
story.append(Paragraph(
'In CI, defined in .github/workflows/pr-check.yml ("PR Checks") as a job '
'alongside build-check, CodeQL, Dependency Review, and OWASP ZAP (Sections 1.3, 2.3, 3.2) — all in '
'the same file, all triggered by the same '
'on: pull_request: branches: [main] block, so one '
'PR runs every check this repo has. The secrets come from GitHub Actions repository secrets instead '
'of .env; dotenv-cli silently no-ops when no .env file is present, so the identical npm commands run '
'unchanged in both places:', body_style))
story.append(code_block(
'sonarqube:\n'
' permissions: {pull-requests: read}\n'
' steps:\n'
' - actions/checkout@v4 (fetch-depth: 0)\n'
' - actions/setup-node@v4\n'
' - actions/setup-python@v5 (3.11)\n'
' - run: pip install -e ".[all]" # pytest + every agent extra\n'
' - run: npm ci --ignore-scripts\n'
' - run: npm run coverage:all # 4 services + frontend + Python\n'
' - name: SonarQube Scan\n'
' env: {SONAR_TOKEN, SONAR_HOST_URL, SONAR_PROJECT_KEY} # from GitHub Secrets\n'
' run: npm run sonar\n'
' - name: SonarQube Quality Gate check\n'
' env: {SONAR_TOKEN, SONAR_HOST_URL, SONAR_PROJECT_KEY}\n'
' run: npm run sonar:gate'))
story.append(Paragraph(
'This job is what actually blocks a PR merge on a failed gate (via a required status check), which '
'running from a developer’s machine or IDE alone cannot enforce. The Python setup step exists '
'because coverage:all shells out to pytest for the meridian_agents suite '
'— without it, that step fails with “No module named pytest”, tolerated only because it runs with '
'continue-on-error: true.', body_style))
story.append(Paragraph(
'As a complementary, faster feedback loop, the VS Code extension SonarQube for IDE '
'(SonarLint) can connect to the same server (Connected Mode) so issues show up inline while typing, '
'using the server’s quality profile. It’s local-only, though — it never uploads results or '
'evaluates the gate; a supplement to the scan-and-gate flow, not a replacement for it.', body_style))
story.append(Paragraph('Gate mechanics — scripts/sonar-quality-gate.sh', h2_style))
story.append(Paragraph('Analysis and gate evaluation are asynchronous, in two stages:', body_style))
story.append(bullets([
'Scan (client-side): sonar-scanner analyzes source locally, writes an intermediate report '
'under .scannerwork/, and uploads it via '
'POST /api/ce/submit. The server responds with a task receipt written to '
'.scannerwork/report-task.txt (ceTaskId, dashboardUrl).',
'Processing (server-side): the upload is queued as a Compute Engine (CE) background task, '
'which ingests the report, computes final measures, matches issues against the active quality '
'profile, and evaluates the Quality Gate — all before the task reports SUCCESS.',
]))
story.append(Paragraph('The gate script then:', body_style))
story.append(bullets([
'Reads ceTaskId and polls GET /api/ce/task?id=... '
'every 10s (up to 30 attempts) until it reaches SUCCESS/FAILED/CANCELED — failing immediately if it '
'never reaches SUCCESS, since the gate would otherwise reflect a stale prior analysis.',
'Queries GET /api/qualitygates/project_status?projectKey=... — this '
'reads an already-computed result, it does not trigger new evaluation.',
'Prints the overall status (SonarQube’s API uses ERROR for a failed '
'gate, not FAIL) and, on failure, each failing condition’s actual value '
'and threshold, then exits non-zero — what lets CI block the PR on this step.',
]))
story.append(Paragraph('Example: a real gate failure observed on this project', h2_style))
story.append(Paragraph(
'Default gate conditions evaluate only new code (lines changed since the gate’s baseline — '
'the “leak period”), not the whole codebase. This example is from a scan after a large batch of test '
'files was added in one session, so “new code” spans everything since that baseline:', body_style))
story.append(metrics_table(
[['Metric', 'Actual', 'Required', 'Meaning'],
['new_coverage', '19.0%', '≥ 100%', 'Coverage on lines changed since baseline'],
['new_duplicated_lines_density', '5.27%', '≤ 3%', 'Too much duplicated new code'],
['new_security_hotspots_reviewed', '0.0%', '= 100%', 'Hotspot(s) not yet manually reviewed'],
['new_violations', '73', '= 0', 'New bugs / vulnerabilities / code smells introduced']],
col_widths=[45 * mm, 20 * mm, 22 * mm, 81 * mm]))
story.append(Spacer(1, 6))
story.append(Paragraph(
'A failing gate does not mean the scan failed — the scan always succeeds if it can reach the server '
'and upload results. Pass/fail is purely the gate’s evaluation of those results against its '
'configured thresholds.', body_style))
story.append(note(
'The new_coverage threshold shown here (≥100%) is unusually strict versus SonarQube’s default '
'“Sonar way” gate (typically ≥80%) — worth reviewing in the SonarQube UI if the standard threshold '
'was actually intended. The other three conditions are independent of coverage entirely.'))
story.append(Paragraph('Code coverage — how it feeds the gate', h2_style))
story.append(Paragraph(
'Coverage is a runtime metric, not a static-analysis one: as tests execute, a coverage tool records '
'which lines actually ran. It measures only whether a line executed, not whether the test '
'asserted anything meaningful about it — necessary but not sufficient for a trustworthy suite, while '
'low coverage reliably signals completely unverified code. The gate’s coverage condition is scoped '
'to new/changed code specifically so it acts as a forcing function while the author still has full '
'context, not months later during a bug report.', body_style))
story.append(Paragraph(
'SonarQube doesn’t execute tests itself — it’s purely a consumer of reports the project’s own test '
'runners produce, declared in sonar-project.properties: '
'sonar.javascript.lcov.reportPaths (one lcov.info per JS/TS/Vue codebase) '
'and sonar.python.coverage.reportPaths (one Cobertura XML for '
'meridian_agents). During analysis the scanner cross-references each report’s line numbers against '
'the files it parsed and derives per-file, per-directory, whole-project, and git-diff-scoped '
'(new_coverage) percentages — the last of which the gate actually '
'evaluates.', body_style))
story.append(metrics_table(
[['Codebase', 'Runner', 'Command', 'Report'],
['auth-service, blog-service,\nmedia-service, api-gateway',
'Jest (ts-jest)',
'npm run test:cov:<service>\n(jest --coverage)',
'coverage/lcov.info'],
['frontend', 'Vitest (v8 provider)', 'npm run test:cov:frontend\n(vitest run --coverage)', 'coverage/lcov.info'],
['meridian_agents (Python)', 'pytest + pytest-cov', 'npm run coverage:python\n(--cov-report=xml)',
'coverage-python.xml']],
col_widths=[42 * mm, 30 * mm, 50 * mm, 46 * mm]))
story.append(Spacer(1, 8))
story.append(note(
'Coverage reports must already exist on disk before npm run sonar runs — the scan is a point-in-time '
'read. Stale or missing reports silently produce 0% or a previous run’s numbers, not an error. '
'npm run coverage:all regenerates every report in one command; '
'npm run sonar:full chains coverage:all + the scan + the gate.'))
# ── 1.3 CodeQL ────────────────────────────────────────────────────────
story.append(Paragraph('1.3 CodeQL', h2_style))
story.append(Paragraph(
'What it is & what it’s used for. GitHub’s own SAST engine: it treats source as a '
'queryable relational database and performs real interprocedural dataflow / taint-tracking '
'analysis — tracing how a specific piece of untrusted input actually flows into a dangerous sink (a '
'SQL query, a shell exec, an HTML response), rather than matching syntactic patterns. This is what '
'makes it materially stronger than pattern-based tools at finding real, exploitable injection / XSS '
'/ SSRF / insecure-deserialization vulnerabilities with fewer false positives — at the cost of being '
'narrower in scope than SonarQube (Section 1.2): no code smells, no duplication, no coverage gate.',
body_style))
story.append(Paragraph(
'Configuring on GitHub. Runs as native GitHub Code Scanning — free for public '
'repositories (this repo is public), with zero additional infrastructure: no server to host, no '
'secret to manage, unlike SonarQube’s self-hosted setup. Results surface on the repo’s Security '
'tab → Code scanning alerts. The workflow job needs a permissions: '
'{security-events: write} block to upload results there — everything else about enabling it '
'lives in the workflow file itself, no repo Settings toggle required.', body_style))
story.append(Paragraph(
'Configuring in code. Two files:', body_style))
story.append(bullets([
'.github/codeql/codeql-config.yml — enables the '
'security-extended query pack (broader than the default query set) and '
'excludes node_modules/, dist/, coverage/, uploads/, and DB/PDF files from analysis, deliberately '
'mirroring SonarQube’s sonar.exclusions so both tools scan the same real '
'source.',
'.github/workflows/pr-check.yml — the codeql '
'and codeql-gate jobs (below).',
]))
story.append(note(
'CodeQL analysis initially lived in its own .github/workflows/codeql.yml — GitHub’s own default '
'convention (the “Set up code scanning” button always generates a standalone file). It was folded '
'into pr-check.yml instead for one concrete reason: the gate job needs to know when analysis has '
'finished, and jobs can only declare a native needs: dependency on a job within the same workflow '
'file — not across two independent workflow runs. Keeping it separate would have meant polling '
'GitHub’s Checks API from the gate job just to work around that file boundary. A job’s permissions: '
'block is still scoped per-job regardless of which file it lives in, so this didn’t widen any other '
'job’s permissions — the one real trade-off is losing CodeQL’s independent trigger set (it also ran '
'on every push to main and a weekly schedule as a standalone workflow); in one shared file, every '
'job uses the same pull_request-only trigger.'))
story.append(Paragraph(
'How it’s done here. The single on: pull_request: branches: [main] '
'block at the top of pr-check.yml governs every job in the file — CodeQL runs whenever a PR targets '
'main, exactly like build-check and the SonarQube scan:', body_style))
story.append(code_block(
'codeql:\n'
' permissions: {security-events: write, actions: read, contents: read}\n'
' strategy:\n'
' matrix:\n'
' language: [javascript-typescript, python]\n'
' steps:\n'
' - actions/checkout@v4\n'
' - actions/setup-node@v4 (js/ts leg only)\n'
' - run: npm ci --ignore-scripts (js/ts leg only)\n'
' - github/codeql-action/init@v3\n'
' with: {languages: matrix.language,\n'
' config-file: .github/codeql/codeql-config.yml}\n'
' - github/codeql-action/autobuild@v3\n'
' - github/codeql-action/analyze@v3\n\n'
'codeql-gate:\n'
' needs: codeql # waits for both matrix legs to finish\n'
' permissions: {security-events: read, contents: read}\n'
' steps:\n'
' - actions/checkout@v4\n'
' - run: npm run codeql:gate'))
story.append(Paragraph(
'The matrix runs two independent legs in parallel, one per language. The javascript-typescript leg '
'installs dependencies first purely to help CodeQL resolve TS/JS imports more precisely; neither '
'language actually needs a compiled build to be analyzed, so autobuild '
'is effectively a no-op for both.', body_style))
story.append(Paragraph('Custom severity gate — scripts/codeql-quality-gate.sh', h2_style))
story.append(Paragraph(
'codeql-action/analyze always exits successfully regardless of what it '
'finds — GitHub Code Scanning has no built-in “fail the build on severity count” gate the way '
'SonarQube’s Quality Gate does natively. This script closes that gap, mirroring '
'sonar-quality-gate.sh’s approach rather than reusing SonarQube’s exact thresholds:', body_style))
story.append(bullets([
'FAIL if any open alert has security severity CRITICAL.',
'FAIL if more than 2 open alerts have security severity HIGH.',
'otherwise PASS.',
]))
story.append(Paragraph(
'Because codeql-gate declares needs: codeql, '
'it only starts once every matrix leg has completed — no polling required to know analysis is done. '
'The one remaining timing wrinkle: a finished analyze step means the SARIF upload was accepted, not '
'that GitHub has finished turning it into queryable alerts yet, so the script pauses briefly before '
'querying GET /repos/{owner}/{repo}/code-scanning/alerts?state=open '
'(paginated), filtering each alert’s rule.security_severity_level.',
body_style))
story.append(note(
'An empty result from that query is genuinely ambiguous — zero open alerts, or alerts not finished '
'processing yet — so the script uses a fixed pause rather than retrying-until-nonempty, which would '
'silently treat “not ready yet” as “clean”.'))
# ── 1.4 SonarQube vs CodeQL ───────────────────────────────────────────
story.append(Paragraph('1.4 SonarQube vs CodeQL — why both run, not either/or', h2_style))
story.append(Paragraph(
'Both tools are SAST and both report Vulnerabilities, which invites the question of whether one '
'makes the other redundant. It doesn’t — each has strengths the other doesn’t attempt:', body_style))
story.append(metrics_table(
[['CodeQL’s edge over SonarQube', 'SonarQube’s edge over CodeQL'],
['Real interprocedural dataflow/taint-tracking — traces '
'how a specific untrusted input reaches a dangerous sink, '
'not just pattern-matching the code around it. Fewer '
'false positives on the injection/XSS/SSRF-class bugs it '
'targets.',
'Test coverage as a first-class gate condition — '
'CodeQL has no concept of coverage at all (Section 1.2, '
'coverage subsection).'],
['Free, zero infrastructure for public repos — no server '
'to host, no secret to manage.',
'Much broader issue categories: Code Smells '
'(maintainability), duplicated code density, and '
'general Bugs beyond security — none of which are '
'in CodeQL’s scope at all.'],
['security-extended query pack is purpose-built for '
'security; nothing to configure beyond enabling it.',
'Security Hotspots — flags security-sensitive code '
'(crypto, cookies, deserialization) for a human to '
'triage, instead of only binary-flagging it as a '
'vulnerability or not.'],
['Native GitHub Code Scanning — results land directly on '
'the Security tab via SARIF (Section 4), no separate UI '
'to check.',
'Configurable, tunable rule sets and quality profiles on '
'a real dashboard — better suited to enforcing house '
'style/maintainability standards over time, not just '
'point-in-time security findings.']],
col_widths=[84 * mm, 84 * mm]))
story.append(Spacer(1, 6))
story.append(note(
'Net effect used here: SonarQube is the general quality gate (bugs, smells, duplication, '
'coverage-gated); CodeQL adds a deeper, security-specific pass on top of it. Running both means '
'whichever style of analysis catches a given bug, catches it.'))
# ══════════════════════════════════════════════════════════════════════
# 2. SCA — Software Composition Analysis
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('2. SCA — Software Composition Analysis', h1_style))
story.append(Paragraph('2.1 What is SCA?', h2_style))
story.append(Paragraph(
'SCA inspects the third-party packages a project depends on — not its own source — against '
'databases of publicly known vulnerabilities (CVEs) and license issues. Neither SonarQube Community '
'Edition nor CodeQL do this (Section 1); a vulnerable dependency sitting in node_modules or a Python '
'virtualenv is completely invisible to both, since both only parse first-party source. SCA closes '
'that gap by matching exact package+version pairs already recorded in the project’s '
'dependency graph (derived from lockfiles/manifests) against an advisory database — '
'fundamentally a lookup, not an analysis, so it needs no understanding of how the code that '
'uses the package actually works.', body_style))
story.append(Paragraph('Two tools cover this here, at different scopes:', body_style))
story.append(bullets([
'Dependabot — continuous, whole-dependency-tree coverage, independent of any PR.',
'Dependency Review — scoped to exactly what a single PR’s diff introduces, with an inline gate.',
]))
story.append(note(
'“Dependency graph” is not a third tool or a third pipeline — it’s the data both of the above '
'read. It’s GitHub’s own parsed inventory of this repo’s manifests/lockfiles (which package '
'depends on which, at what version); it does no scanning itself and produces no findings on its '
'own, so it never shows up as a job with a pass/fail. It only appears as a Settings toggle '
'(Settings → Code security and analysis → Dependency graph) because it’s the prerequisite '
'substrate: Dependabot alerts (2.2.1) cross-reference it against the advisory database, and '
'Dependency Review (2.3) reads the exact same graph, scoped to a PR’s diff. Neither can produce a '
'single finding without it — which is exactly why Dependency Review failed outright '
'(“Dependency review is not supported on this repository…”) on this repo until Dependency graph was '
'explicitly turned on, even though Dependabot alerts were already active and reading that same '
'graph fine.'))
# ── 2.2 Dependabot ────────────────────────────────────────────────────
story.append(Paragraph('2.2 Dependabot', h2_style))
story.append(Paragraph(
'“Dependabot” is a brand name covering three genuinely distinct GitHub features, easy to '
'conflate because they share one config file and one Security-tab presence. Each is explained '
'separately below because each has a different job and a different reason for existing.', body_style))
story.append(bullets([
'2.2.1 Dependabot alerts — the actual SCA lookup: notices when a dependency already in use '
'has a known vulnerability.',
'2.2.2 Dependabot security updates — automatically opens a PR to fix a dependency an '
'alert just flagged.',
'2.2.3 Dependabot version updates — opens PRs to bump dependencies to their latest version on '
'a schedule, vulnerable or not; not a security feature by itself.',
]))
story.append(note(
'GitHub deliberately keeps these independent: security-update PRs are never grouped with '
'version-update PRs, and dependabot.yml (2.2.3) has no effect on whether alerts or security updates '
'happen — the only link between them is that merging a security-update PR automatically closes the '
'alert that triggered it.'))
story.append(Paragraph('2.2.1 Dependabot alerts', h2_style))
story.append(Paragraph(
'What it is & the need for it. The actual SCA tool (Section 2.1’s “lookup, not analysis” '
'description is specifically this). It’s event-driven, not scheduled: it re-checks whenever '
'the dependency graph changes (a manifest/lockfile push) or whenever a new advisory is '
'published for a package already in use — meaning an alert can appear on a dependency nobody has '
'touched in months, the moment a CVE for it becomes public. Without this, a vulnerable dependency '
'already merged into the codebase would sit there silently forever; nothing else in this document '
'watches the tree continuously the way this does.', body_style))
story.append(Paragraph(
'Configuring on GitHub. A repo Settings toggle: Settings → Code security and analysis → '
'Dependabot alerts. Nothing to configure in code — confirmed enabled on this repo (the API’s '
'/vulnerability-alerts endpoint returns 204). Results surface on the '
'repo’s Security tab → Dependabot alerts, entirely independent of PR activity.', body_style))
story.append(Paragraph(
'How it’s done here. Enabled, nothing further to configure. Real example observed on this '
'repo: two alerts appeared on multer (used by media-service and '
'api-gateway) — high severity for a deeply-nested-field-names DoS, medium for incomplete cleanup of '
'aborted uploads — the moment those advisories were published, with zero commits made to this repo '
'at that time. That’s the event-driven behavior above, not a coincidence of timing.', body_style))
story.append(Paragraph('2.2.2 Dependabot security updates', h2_style))
story.append(Paragraph(
'What it is & the need for it. Alerts (2.2.1) only notify — something still has to '
'act on them. When this is enabled, Dependabot automatically tries to open a pull request for every '
'open alert that has an available patch: it checks whether the vulnerable dependency can be upgraded '
'to the minimum version containing the fix without breaking the dependency graph, then raises a PR '
'to exactly that version and links it to the alert. This is a completely separate mechanism from '
'version updates (2.2.3) — it needs no dependabot.yml entry at all to '
'function, since it’s driven by alerts, not by a schedule.', body_style))
story.append(Paragraph(
'Configuring on GitHub. Its own repo Settings toggle, separate from alerts: Settings → '
'Code security and analysis → Dependabot security updates. Confirmed enabled on this repo (the '
'API’s security_and_analysis.dependabot_security_updates.status field '
'reads enabled).', body_style))
story.append(Paragraph(
'How it’s done here. Enabled, nothing further to configure — this is the mechanism behind any '
'PR titled like a plain version bump but opened without waiting for Monday’s schedule (2.2.3), '
'and whose merge closes a specific alert rather than just updating a version number.', body_style))
story.append(Paragraph('2.2.3 Dependabot version updates', h2_style))
story.append(Paragraph(
'What it is & the need for it. Opens scheduled PRs bumping dependencies to their latest '
'version, vulnerable or not — not a security feature; it exists to stop the codebase drifting '
'arbitrarily far behind upstream, since an old-enough dependency eventually becomes hard to patch '
'safely at all, security-relevant or not.', body_style))
story.append(Paragraph(
'Configuring on GitHub. No Settings toggle — committing '
'.github/dependabot.yml to the repo is itself what enables it, per ecosystem entry.',
body_style))
story.append(Paragraph(
'Configuring in code. .github/dependabot.yml — one entry per '
'package ecosystem:', body_style))
story.append(code_block(
'updates:\n'
' - package-ecosystem: npm\n'
' directory: /\n'
' schedule: {interval: weekly, day: monday, time: "06:00"}\n'
' open-pull-requests-limit: 10\n'
' - package-ecosystem: pip\n'
' directory: /\n'
' schedule: {interval: weekly, day: monday, time: "06:00"}\n'
' open-pull-requests-limit: 5'))
story.append(note(
'Deliberately no groups: — each bump becomes its own individual PR so the maintenance agent can '
'parse a plain “Bump X from A to B” title and decide whether to merge or close it, rather than '
'having to unpack a batched multi-package PR.'))
story.append(Paragraph(
'How it’s done here. One npm entry and one pip entry, both rooted at '
'/ — a direct consequence of this repo’s single-root-package.json '
'consolidation (previously five separate npm entries, one per service, before that restructuring). '
'Weekly on Mondays, capped at 10 open npm PRs / 5 open pip PRs at a time.', body_style))
story.append(Paragraph('So which mechanism opened that PR I just saw?', h2_style))
story.append(Paragraph(
'Two different, independent things create Dependabot PRs on this repo, and the title/body tells you '
'which:', body_style))
story.append(bullets([
'If it references a security advisory / Dependabot alert in its description, or appeared '
'without waiting for a Monday — that’s a security update (2.2.2), reacting to an alert. '
'Merging it closes that alert.',
'If it’s a routine “Bump X from A to B” with no advisory reference, opened on/near Monday 06:00 UTC '
'— that’s a version update (2.2.3), running on dependabot.yml’s schedule regardless of '
'vulnerability status.',
]))
# ── 2.3 Dependency Review Action ──────────────────────────────────────
story.append(Paragraph('2.3 Dependency Review Action', h2_style))
story.append(Paragraph(
'What it is & what it’s used for. A GitHub Action that runs the same SCA lookup as '
'Dependabot alerts (same underlying Advisory Database) but scoped to exactly what a single PR’s diff '
'changes in the dependency manifests — and, unlike Dependabot alerts, with a native inline gate: '
'it can fail the check itself, rather than only posting an informational alert to the Security tab. '
'It complements Dependabot alerts rather than replacing them: Dependabot alerts cover the whole '
'existing tree continuously; this action only ever sees what a given PR is about to add.', body_style))
story.append(Paragraph(
'Configuring on GitHub. Requires Dependency graph enabled (Settings → Code security '
'and analysis → Dependency graph) — a real gap hit while wiring this up on this repo: the first '
'run failed outright with “Dependency review is not supported on this repository. Please ensure that '
'Dependency graph is enabled”, even though Dependabot alerts (2.2) were already active. Turning the '
'toggle on and re-running the same job with no code changes fixed it. No other GitHub-side setup is '
'needed — no secret, no additional permission beyond the job’s own '
'contents: read.', body_style))
story.append(Paragraph(
'Configuring in code. One job in .github/workflows/pr-check.yml:',
body_style))
story.append(code_block(
'dependency-review:\n'
' permissions: {contents: read}\n'
' steps:\n'
' - actions/checkout@v4\n'
' - actions/dependency-review-action@v4\n'
' with: {fail-on-severity: high}'))
story.append(Paragraph(
'How it’s done here. fail-on-severity: high blocks the PR if the '
'diff introduces anything rated high or critical — a plain binary cutoff, unlike the '
'“0 critical, ≤2 high” count-based tolerance used for the CodeQL and SonarQube gates (Section '
'1). That count-based tolerance exists because those gates evaluate the whole existing codebase, '
'where some accumulated, already-triaged risk is tolerated; a single PR realistically introduces at '
'most one or two new dependencies at a time, so there’s no equivalent “accumulated” risk to tolerate '
'here — any new high/critical dependency is worth blocking outright. First real run on this repo '
'passed clean (no vulnerable or license-problematic new dependencies).', body_style))
# ══════════════════════════════════════════════════════════════════════
# 3. DAST — Dynamic Application Security Testing
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('3. DAST — Dynamic Application Security Testing', h1_style))
story.append(Paragraph('3.1 What is DAST?', h2_style))
story.append(Paragraph(
'DAST tests the application while it is actually running, from the outside — sending it real '
'HTTP requests and observing the responses, the way an attacker (or a browser) would, with no access '
'to source code at all. This is the fundamental difference from Sections 1 and 2: SAST and SCA are '
'both static — they read files (source, or dependency manifests) and never execute anything. DAST '
'catches an entirely different class of problem as a result: things that only exist at runtime — '
'missing security headers, cookies without the right flags, information disclosed in error '
'responses, endpoints reachable that shouldn’t be — regardless of how clean the underlying source '
'looks to a static tool.', body_style))
story.append(note(
'The trade-off is depth of understanding: a DAST tool has no idea what the code is supposed to do, '
'only how it actually responds to being poked. It complements SAST/SCA rather than substituting for '
'either — this project runs all three.'))
story.append(Paragraph('3.2 OWASP ZAP', h2_style))
story.append(Paragraph(
'What it is & what it’s used for. OWASP ZAP (Zed Attack Proxy) is a free, open-source '
'DAST tool. It runs as a proxy that spiders a target — following every link/form it can '
'discover, building a map of the application — then inspects the actual HTTP traffic that generates '
'against a ruleset of known-bad patterns. ZAP has two very different modes:', body_style))
story.append(bullets([
'Baseline (passive) scan — spiders the app and inspects whatever responses that spidering '
'naturally generates: headers, cookies, disclosed information. It never submits a form or sends a '
'crafted attack payload, so it cannot mutate data and is safe to run against a live-ish instance.',
'Full (active) scan — additionally sends real attack payloads (SQL injection strings, XSS '
'payloads, etc.) at every form/endpoint the spider found, including ones that create/delete real '
'data. Far more thorough, but slower, noisier, and will happily submit every form it finds.',
]))
story.append(Paragraph(
'This project runs the baseline scan — it needs no authentication flow, no throwaway test '
'data, and can’t corrupt the seeded content already committed to blog.db/auth.db/media.db, which '
'made it the reasonable default to start from.', body_style))
story.append(Paragraph(
'Configuring on GitHub. Nothing to enable in repo Settings, unlike CodeQL — ZAP is not a '
'GitHub-native feature, it’s a third-party GitHub Action ('
'zaproxy/action-baseline) that runs the official ZAP Docker image. No secret required for the '
'scan itself. Its results currently do not reach the Security tab at all (Section 4) — they '
'live only in the workflow log and a build artifact.', body_style))
story.append(Paragraph(
'Configuring in code. One job in .github/workflows/pr-check.yml:',
body_style))
story.append(code_block(
'dast:\n'
' permissions: {contents: read, security-events: write}\n'
' steps:\n'
' - actions/checkout@v4\n'
' - actions/setup-node@v4\n'
' - run: npm ci\n'
' - name: Start the app\n'
' run: |\n'
' npm start & wait-loop curl-ing :5173 and :3000/api/posts\n'
' - zaproxy/action-baseline@v0.15.0\n'
' with: {target: http://localhost:5173,\n'
' fail_action: false, allow_issue_writing: false,\n'
' artifact_name: zap-baseline-report}\n'
' - run: python3 scripts/zap-alerts-to-sarif.py report_json.json zap-high.sarif\n'
' - github/codeql-action/upload-sarif@v3\n'
' with: {sarif_file: zap-high.sarif, category: zap-baseline}'))
story.append(Paragraph(
'How it’s done here. The app is booted via npm start directly on '
'the runner — not the real production Dockerfile. That image also builds the TTS service (PyTorch + '
'Kokoro model download), which DAST doesn’t need at all, so building it would add several minutes to '
'every PR for no scanning benefit; raw npm start boots the whole app in '
'roughly 15 seconds. The step backgrounds it (&) and polls both the '
'frontend (port 5173) and the API gateway (port 3000) with curl until '
'both respond, up to 30 attempts. ZAP then targets http://localhost:5173 '
'— the actual browsable SPA, not the api-gateway’s bare JSON API directly, since a passive spider '
'needs crawlable HTML/links to find the attack surface at all.', body_style))
story.append(note(
'GitHub-hosted Linux runners execute Docker-based actions (which is what action-baseline is, under '
'the hood) with host networking, so “localhost” inside ZAP’s container really does mean the '
'runner’s own localhost where npm start is listening — this only holds on Linux runners, which is '
'what this workflow uses throughout.'))
story.append(Paragraph(
'This job still has no merge gate — fail_action: false means it cannot '
'fail the PR no matter what it finds, and allow_issue_writing: false '
'stops it from filing a GitHub issue every run (its default behavior, which would otherwise need a '
'broader issues: write permission). That stays intentional: no baseline '
'run had happened yet, at the time this job was added, to know what this app’s normal finding count '
'even looks like. Turning on a hard merge-blocking gate blind — the way SonarQube’s and '
'CodeQL’s gates only were, after seeing real results (Sections 1.2, 1.3) — would risk blocking every '
'future PR on pre-existing findings unrelated to whatever it actually changes.', body_style))
story.append(note(
'A first attempt at this asked zap-baseline.py for a differently-named JSON report via '
'cmd_options: "-J zap-report.json". That broke the job outright: '
'report_json.json is action-baseline’s own default JSON report path — '
'it writes that file itself, unprompted — and its own post-processing step expects to find the '
'report there and fails with “File report_json.json does not exist” if zap-baseline.py was told, '
'via an extra -J, to write somewhere else instead. Fixed by dropping the '
'cmd_options override entirely and reading the action’s existing default '
'output.'))
story.append(Paragraph(
'What did change once a real baseline run existed: '
'scripts/zap-alerts-to-sarif.py reads '
'report_json.json — the JSON report action-baseline already produces by '
'default, alongside the HTML report the artifact upload captures — and converts only the '
'High-risk alerts — ZAP’s risk scale tops out at High, there is no tier above it — into a minimal '
'SARIF 2.1.0 file, which codeql-action/upload-sarif then pushes to the '
'Security tab under its own zap-baseline category (kept separate from '
'CodeQL’s own categories so the two don’t collide). Medium/Low/Informational alerts — the '
'missing-security-header findings a dev-mode server commonly produces — are deliberately left out of '
'that conversion: they still show up in the job log and the full HTML artifact, just not as '
'permanent, cross-linkable Security-tab entries. This is a visibility change only, not a gate '
'change — the job still cannot fail a PR on its own.', body_style))
# ══════════════════════════════════════════════════════════════════════
# 4. SARIF — what actually reaches the Security tab
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('4. SARIF — what actually reaches the Security tab', h1_style))
story.append(Paragraph(
'SARIF (Static Analysis Results Interchange Format) is a standard JSON schema for representing '
'static-analysis findings — which tool found what, where, how severe, with what confidence — so '
'different tools and different consumers (like GitHub’s Security tab) can speak the same language '
'instead of every tool needing its own bespoke integration. GitHub’s Code scanning alerts UI '
'is fundamentally a SARIF viewer: anything that gets a SARIF file to '
'github/codeql-action/upload-sarif (or an equivalent upload call) shows '
'up there, and nothing else does.', body_style))
story.append(Paragraph('Of every tool in this document, two now do that — one automatically, one via a small custom converter:', body_style))
story.append(metrics_table(
[['Tool', 'Generates SARIF?', 'Reaches the Security tab?'],
['CodeQL', 'Yes — built into\ncodeql-action/analyze', 'Yes — automatically, same step\n(Code scanning alerts)'],
['SonarQube', 'No', 'No — findings live on the separate,\nself-hosted SonarQube server UI'],
['Dependabot alerts', 'No', 'Yes — but via GitHub’s own advisory-\nmatching, a different pipeline entirely\n(Dependabot alerts, not Code scanning)'],
['Dependency Review', 'No', 'No — inline PR annotation only,\nnot a persistent Security-tab entry'],
['OWASP ZAP', 'Natively, via its own\nreport-generation add-on\n(not what action-baseline\nuses by default)', 'High-risk alerts only — see below.\nMedium/Low/Info stay artifact-only.']],
col_widths=[32 * mm, 52 * mm, 84 * mm]))
story.append(Spacer(1, 6))
story.append(note(
'ZAP itself can natively emit a SARIF report (its report-generation add-on ships a sarif-json '
'template) — but zaproxy/action-baseline wraps the older zap-baseline.py script, which doesn’t '
'expose that template directly. Rather than depend on that, this repo takes the same "write a small '
'script" approach already used for the SonarQube and CodeQL gates (Sections 1.2, 1.3): '
'scripts/zap-alerts-to-sarif.py (a script this repo owns, not a third-party converter) reads the '
'JSON report zap-baseline.py already writes by default (report_json.json) and hand-builds a '
'minimal, schema-valid SARIF file containing only the High-risk alerts, which '
'codeql-action/upload-sarif then pushes up — exactly the mechanism CodeQL uses internally, just '
'invoked as a separate step instead of being bundled into the scan action itself.'))
story.append(Paragraph(
'Why only High, and not everything ZAP finds: ZAP’s risk scale is Informational/Low/Medium/High — '
'there is no “Critical” tier above High the way CodeQL has one above High. Publishing every finding '
'(the 8 WARN-level missing-security-header alerts a dev-mode Vite server normally produces — see the '
'gate discussion above) would clutter the one place people check for “what’s actually exploitable” '
'with findings that may not even reflect the real production nginx config. Restricting the SARIF '
'conversion to High keeps that view meaningful while the DAST job itself still has no merge gate.',
body_style))
story.append(Paragraph(
'Two consequences worth being explicit about: first, “check GitHub’s Security tab” does not mean '
'“check everything” — SonarQube still requires checking its own separate server UI, and even ZAP’s '
'Medium/Low/Informational findings only ever show up in its job log and HTML artifact, never here. '
'Second, Dependabot alerts and Code scanning alerts are two different sub-tabs fed by two entirely '
'different pipelines that happen to live under the same Security tab umbrella, not one unified feed.',
body_style))
# ══════════════════════════════════════════════════════════════════════
# 5. Summary
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('5. Summary', h1_style))
story.append(Paragraph(
'One row here doesn’t fit the “Tool” framing the rest of this table uses: Dependency graph. Section '
'2.1 already covers what it is (data, not a pipeline) — worth being equally explicit about '
'how it actually gets created, since nothing in this pipeline builds it. GitHub itself parses '
'whatever manifest/lockfile formats it recognizes for a repo’s ecosystems — for this repo, '
'package.json + package-lock.json for npm, '
'pyproject.toml for pip — automatically, in the background, whenever '
'one of those files changes on a tracked branch. There is no job, workflow, or Action that runs '
'this: it’s a platform-level feature GitHub performs on its own once Dependency graph is '
'switched on (Section 2.1), and it’s the lockfile specifically that lets it resolve transitive '
'dependencies, not just the direct ones listed in the manifest. It’s the same graph whether it’s '
'Dependabot alerts reading the repo’s current default-branch state (continuously) or Dependency '
'Review reading a PR’s proposed diff against it (per PR) — both cases below.', body_style))
story.append(metrics_table(
[['Tool', 'Category', 'Scope', 'Trigger', 'Blocks merge?'],
['Dependency graph', 'substrate\n(not a tool/pipeline)', 'Every recognized manifest/\nlockfile in the repo',
'Automatic — GitHub parses on\nevery push to a tracked branch',
'N/A — produces no findings;\nDependabot & Dependency\nReview both read it'],
['SonarQube', 'SAST', 'Whole repo\n(gate: new/changed code)', 'pull_request\n(pr-check.yml)',
'Yes — sonar:gate'],
['CodeQL', 'SAST', 'Whole repo\n(JS/TS + Python matrix)', 'pull_request\n(pr-check.yml)',
'Yes — custom codeql-gate'],
['Dependabot alerts', 'SCA', 'Whole dependency tree,\ncontinuous', 'Event-driven\n(new advisory / graph change)',
'No — informational,\nSecurity tab only'],
['Dependabot\nsecurity updates', 'SCA (remediation)', 'Whole dependency tree,\ncontinuous', 'Event-driven\n(reacts to an alert)',
'N/A — opens PRs,\ndoesn’t gate'],
['Dependabot\nversion updates', '(not security)', 'Whole dependency tree', 'Weekly schedule',
'N/A — opens PRs,\ndoesn’t gate'],
['Dependency Review', 'SCA', 'This PR’s manifest\ndiff only', 'pull_request\n(pr-check.yml)',
'Yes — fail-on-severity: high'],
['OWASP ZAP', 'DAST', 'Running app,\npassive spider', 'pull_request\n(pr-check.yml)',
'No — fail_action: false\n(pending baseline data)']],
col_widths=[32 * mm, 24 * mm, 36 * mm, 36 * mm, 40 * mm]))
story.append(Spacer(1, 6))
story.append(note(
'Read the “Blocks merge?” column as “fails its own check” — see Section 6’s first gap. As of '
'writing, none of these are wired as required status checks on main, so a red check here does not '
'currently stop GitHub from allowing the merge itself.'))
# ══════════════════════════════════════════════════════════════════════
# 6. Known Gaps — what this pipeline doesn't cover yet
# ══════════════════════════════════════════════════════════════════════
story.append(Paragraph('6. Known Gaps — What This Pipeline Doesn’t Cover Yet', h1_style))
story.append(Paragraph(
'Verified directly against this repo’s live GitHub configuration (API, not assumption), ranked by '
'severity. Documenting a gap here is not a recommendation to silently fix it — several of these are '
'real workflow/policy decisions, not just missing YAML, and are called out for a deliberate choice, '
'not a silent patch.', body_style))
story.append(Paragraph('6.1 No required status checks on main (High)', h2_style))
story.append(Paragraph(
'This repo’s branch protection is a modern ruleset (Settings → Rules → Rulesets), not classic '
'branch protection — which is why the legacy '
'/branches/main/protection API returns 404 even though protection '
'exists. The active ruleset enforces exactly three things: no direct deletion of '
'main, no non-fast-forward (force) pushes, and a pull-request requirement with '
'required_approving_review_count: 0. There is no '
'required_status_checks rule at all.', body_style))
story.append(Paragraph(
'Practical effect: every gate described in Sections 1–3 — SonarQube’s, CodeQL’s, Dependency '
'Review’s, even a future calibrated ZAP gate — makes its own check go red on failure, but nothing '
'stops the PR’s merge button from being clicked anyway. “Blocks merge” throughout this document '
'describes what each script/action does when invoked, not what GitHub itself currently enforces. '
'Fix: add a required_status_checks rule to the ruleset (or a '
'branch protection rule) naming the job names from this document — e.g. '
'SonarQube Scan, CodeQL Security Gate, '
'Dependency Review (SCA) — plus strict: true '
'if you also want the branch to be up to date before merging.', body_style))
story.append(note(
'This is a real behavior change once applied, not a documentation fix: any future PR would be '
'blocked from merging while a check is red — including for causes unrelated to the PR’s own change, '
'like the transient SonarQube-server 502 hit while building this pipeline out. Decide the required '
'check list deliberately rather than requiring everything by default.'))
story.append(Paragraph('6.2 Secret scanning is disabled (High)', h2_style))
story.append(Paragraph(
'A distinct, free, native GitHub feature — not part of SAST/SCA/DAST as covered above, and not '
'gated behind anything this repo would need to build. It scans commit content itself for '
'recognizable credential patterns (API keys, tokens, private keys) and, with push protection '
'enabled, can block the push before a secret ever lands in git history at all — a meaningfully '
'stronger guarantee than catching it after the fact. Currently, every related toggle on this repo — '
'secret_scanning, '
'secret_scanning_push_protection, and '
'secret_scanning_validity_checks (which checks a detected secret against the issuing '
'provider to see if it’s still live) — reads disabled. Fix: '
'Settings → Code security and analysis → Secret scanning (and push protection underneath it).',
body_style))
story.append(Paragraph('6.3 No container/base-image scanning (Medium)', h2_style))
story.append(Paragraph(
'The production Dockerfile builds from '
'node:20-slim — SAST covers this project’s own source, SCA covers its npm/pip dependencies, '
'but neither touches the base image’s OS packages or any CVEs specific to the image itself. A '
'dedicated container-scanning step (e.g. aquasecurity/trivy-action or '
'Docker Scout, both with official GitHub Actions) closes this gap and would slot in as a sibling job '
'to the ones in Section 1, scanning the built image before it’s ever pushed anywhere.', body_style))
story.append(Paragraph('6.4 Dependabot doesn’t track the base image (Medium)', h2_style))
story.append(Paragraph(
'.github/dependabot.yml (Section 2.2.3) has entries for '
'npm and pip only. Dependabot also supports '
'a docker ecosystem specifically for tracking a Dockerfile’s '
'FROM line and opening a PR when a newer tag is available — currently '
'absent, so node:20-slim only ever changes when someone remembers to '
'bump it by hand.', body_style))
story.append(Paragraph('6.5 Repo-level default GITHUB_TOKEN permission is write (Low)', h2_style))
story.append(Paragraph(
'Every job inside pr-check.yml already sets its own explicit, '
'least-privilege permissions: block — that part is done correctly '
'throughout this document. The gap is the repo-wide default '
'(Settings → Actions → General → Workflow permissions), which reads '
'write. That default only matters for a workflow that omits its '
'own permissions: block — none in this repo currently do — but as a '
'defense-in-depth measure, setting the repo default to read-only means any future job added without '
'thinking about permissions fails closed (needs explicit write access requested) instead of failing '
'open (silently getting it).', body_style))
story.append(HRFlowable(width='100%', thickness=0.5, color=BORDER, spaceBefore=14, spaceAfter=8))
story.append(Paragraph(
'Generated for the Meridian (myblogs) project.',
ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, textColor=GRAY, alignment=TA_CENTER)))
doc.build(story)
print(f'Wrote {out_path}')
if __name__ == '__main__':
build()