| """ |
| 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', '<br/>').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 ' |
| '<font face="Courier">.github/workflows/pr-check.yml</font> ("PR Checks") alongside the ordinary ' |
| 'build/test jobs:', body_style)) |
| story.append(bullets([ |
| '<b>SAST</b> (Static Application Security Testing) β analyzes this projectβs own source code, ' |
| 'without running it (Section 1).', |
| '<b>SCA</b> (Software Composition Analysis) β inspects the third-party packages it depends on, not ' |
| 'its own code (Section 2).', |
| '<b>DAST</b> (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)) |
|
|
| |
| |
| |
| 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 <b>own source code</b> 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([ |
| '<b>SonarQube</b> β broad code quality + security: bugs, vulnerabilities, code smells, security ' |
| 'hotspots, duplication, and a coverage-gated Quality Gate.', |
| '<b>CodeQL</b> β 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.')) |
|
|
| |
| story.append(Paragraph('1.2 SonarQube', h2_style)) |
|
|
| story.append(Paragraph( |
| '<b>What it is & what itβs used for.</b> 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 <b>Quality Gate</b>. It checks four categories: ' |
| '<b>Bugs</b> (Reliability), <b>Vulnerabilities</b> (Security), <b>Code Smells</b> (maintainability), ' |
| 'and <b>Security Hotspots</b> (security-sensitive code a human must manually confirm is or isnβt ' |
| 'exploitable in context). Alongside issues, it also tracks <b>test coverage</b> and <b>duplicated ' |
| 'code density</b> 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( |
| '<b>Configuring on GitHub.</b> Nothing to enable in repo Settings β SonarQube is self-hosted, not a ' |
| 'GitHub-native feature. The only GitHub-side piece is three <b>repository secrets</b> ' |
| '(<font face="Courier">SONAR_TOKEN</font>, <font face="Courier">SONAR_HOST_URL</font>, ' |
| '<font face="Courier">SONAR_PROJECT_KEY</font>) that the CI job injects as environment variables β ' |
| 'without them the scan/gate steps in <font face="Courier">pr-check.yml</font> fail outright, since ' |
| 'there is no server to talk to.', body_style)) |
|
|
| story.append(Paragraph( |
| '<b>Configuring in code.</b> Three files, each with one job:', body_style)) |
| story.append(bullets([ |
| '<font face="Courier">sonar-project.properties</font> (repo root) β what gets scanned, independent ' |
| 'of secrets: <font face="Courier">sonar.sources</font> (scan root), ' |
| '<font face="Courier">sonar.exclusions</font> (keeps node_modules/, dist/, *.db, uploads/, ' |
| 'coverage/, .github/ out of analysis), <font face="Courier">sonar.javascript.file.suffixes</font> / ' |
| '<font face="Courier">sonar.typescript.file.suffixes</font> (includes .vue as analyzable), and the ' |
| 'two coverage report-path settings (Section 1.2, coverage below).', |
| '<font face="Courier">package.json</font> scripts β <font face="Courier">sonar</font> runs the scan ' |
| '(the <font face="Courier">@sonar/scan</font> npm packageβs <font face="Courier">sonar-scanner</font> ' |
| 'binary β no Java install or Docker image needed), <font face="Courier">sonar:gate</font> checks the ' |
| 'result, <font face="Courier">sonar:check</font> chains both.', |
| '<font face="Courier">scripts/sonar-quality-gate.sh</font> β 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( |
| '<b>How itβs done here.</b> Locally, with <font face="Courier">.env</font> populated with real ' |
| '<font face="Courier">SONAR_*</font> values (loaded via <font face="Courier">dotenv-cli</font>, the ' |
| 'same tool the projectβs <font face="Courier">start</font> 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 <font face="Courier">.github/workflows/pr-check.yml</font> ("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 ' |
| '<font face="Courier">on: pull_request: branches: [main]</font> 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 <font face="Courier">coverage:all</font> 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 ' |
| '<font face="Courier">continue-on-error: true</font>.', body_style)) |
| story.append(Paragraph( |
| 'As a complementary, faster feedback loop, the VS Code extension <b>SonarQube for IDE</b> ' |
| '(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([ |
| '<b>Scan (client-side):</b> sonar-scanner analyzes source locally, writes an intermediate report ' |
| 'under <font face="Courier">.scannerwork/</font>, and uploads it via ' |
| '<font face="Courier">POST /api/ce/submit</font>. The server responds with a task receipt written to ' |
| '<font face="Courier">.scannerwork/report-task.txt</font> (ceTaskId, dashboardUrl).', |
| '<b>Processing (server-side):</b> 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 <font face="Courier">ceTaskId</font> and polls <font face="Courier">GET /api/ce/task?id=...</font> ' |
| '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 <font face="Courier">GET /api/qualitygates/project_status?projectKey=...</font> β this ' |
| 'reads an already-computed result, it does not trigger new evaluation.', |
| 'Prints the overall status (SonarQubeβs API uses <font face="Courier">ERROR</font> for a failed ' |
| 'gate, not <font face="Courier">FAIL</font>) 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 <i>new</i> 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 <i>executed</i>, 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: ' |
| '<font face="Courier">sonar.javascript.lcov.reportPaths</font> (one lcov.info per JS/TS/Vue codebase) ' |
| 'and <font face="Courier">sonar.python.coverage.reportPaths</font> (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 ' |
| '(<font face="Courier">new_coverage</font>) 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. ' |
| '<font face="Courier">npm run coverage:all</font> regenerates every report in one command; ' |
| '<font face="Courier">npm run sonar:full</font> chains coverage:all + the scan + the gate.')) |
|
|
| |
| story.append(Paragraph('1.3 CodeQL', h2_style)) |
|
|
| story.append(Paragraph( |
| '<b>What it is & what itβs used for.</b> GitHubβs own SAST engine: it treats source as a ' |
| 'queryable relational database and performs real interprocedural <b>dataflow / taint-tracking</b> ' |
| '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( |
| '<b>Configuring on GitHub.</b> Runs as native <b>GitHub Code Scanning</b> β 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 <b>Security ' |
| 'tab β Code scanning alerts</b>. The workflow job needs a <font face="Courier">permissions: ' |
| '{security-events: write}</font> 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( |
| '<b>Configuring in code.</b> Two files:', body_style)) |
| story.append(bullets([ |
| '<font face="Courier">.github/codeql/codeql-config.yml</font> β enables the ' |
| '<font face="Courier">security-extended</font> 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 <font face="Courier">sonar.exclusions</font> so both tools scan the same real ' |
| 'source.', |
| '<font face="Courier">.github/workflows/pr-check.yml</font> β the <font face="Courier">codeql</font> ' |
| 'and <font face="Courier">codeql-gate</font> 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( |
| '<b>How itβs done here.</b> The single <font face="Courier">on: pull_request: branches: [main]</font> ' |
| '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 <font face="Courier">autobuild</font> ' |
| '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( |
| '<font face="Courier">codeql-action/analyze</font> 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([ |
| '<b>FAIL</b> if any open alert has security severity <b>CRITICAL</b>.', |
| '<b>FAIL</b> if more than <b>2</b> open alerts have security severity <b>HIGH</b>.', |
| 'otherwise <b>PASS</b>.', |
| ])) |
| story.append(Paragraph( |
| 'Because <font face="Courier">codeql-gate</font> declares <font face="Courier">needs: codeql</font>, ' |
| '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 <font face="Courier">GET /repos/{owner}/{repo}/code-scanning/alerts?state=open</font> ' |
| '(paginated), filtering each alertβs <font face="Courier">rule.security_severity_level</font>.', |
| 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β.')) |
|
|
| |
| 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 <b>coverage</b> 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: <b>Code Smells</b> ' |
| '(maintainability), <b>duplicated code density</b>, and ' |
| 'general <b>Bugs</b> 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.', |
| '<b>Security Hotspots</b> β 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.')) |
|
|
| |
| |
| |
| 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 <b>third-party packages</b> 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 ' |
| '<b>dependency graph</b> (derived from lockfiles/manifests) against an advisory database β ' |
| 'fundamentally a <i>lookup</i>, 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([ |
| '<b>Dependabot</b> β continuous, whole-dependency-tree coverage, independent of any PR.', |
| '<b>Dependency Review</b> β scoped to exactly what a single PRβs diff introduces, with an inline gate.', |
| ])) |
| story.append(note( |
| '<b>βDependency graphβ is not a third tool or a third pipeline β itβs the data both of the above ' |
| 'read.</b> 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 ' |
| '(<b>Settings β Code security and analysis β Dependency graph</b>) 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.')) |
|
|
| |
| story.append(Paragraph('2.2 Dependabot', h2_style)) |
| story.append(Paragraph( |
| 'βDependabotβ is a brand name covering <b>three</b> 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([ |
| '<b>2.2.1 Dependabot alerts</b> β the actual SCA lookup: notices when a dependency already in use ' |
| 'has a known vulnerability.', |
| '<b>2.2.2 Dependabot security updates</b> β automatically opens a PR to <i>fix</i> a dependency an ' |
| 'alert just flagged.', |
| '<b>2.2.3 Dependabot version updates</b> β 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( |
| '<b>What it is & the need for it.</b> The actual SCA tool (Section 2.1βs βlookup, not analysisβ ' |
| 'description is specifically this). Itβs <i>event-driven</i>, not scheduled: it re-checks whenever ' |
| 'the dependency graph changes (a manifest/lockfile push) <i>or</i> 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( |
| '<b>Configuring on GitHub.</b> A repo Settings toggle: <b>Settings β Code security and analysis β ' |
| 'Dependabot alerts</b>. Nothing to configure in code β confirmed enabled on this repo (the APIβs ' |
| '<font face="Courier">/vulnerability-alerts</font> endpoint returns 204). Results surface on the ' |
| 'repoβs <b>Security tab β Dependabot alerts</b>, entirely independent of PR activity.', body_style)) |
| story.append(Paragraph( |
| '<b>How itβs done here.</b> Enabled, nothing further to configure. Real example observed on this ' |
| 'repo: two alerts appeared on <font face="Courier">multer</font> (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( |
| '<b>What it is & the need for it.</b> Alerts (2.2.1) only <i>notify</i> β 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 <font face="Courier">dependabot.yml</font> entry at all to ' |
| 'function, since itβs driven by alerts, not by a schedule.', body_style)) |
| story.append(Paragraph( |
| '<b>Configuring on GitHub.</b> Its own repo Settings toggle, separate from alerts: <b>Settings β ' |
| 'Code security and analysis β Dependabot security updates</b>. Confirmed enabled on this repo (the ' |
| 'APIβs <font face="Courier">security_and_analysis.dependabot_security_updates.status</font> field ' |
| 'reads <font face="Courier">enabled</font>).', body_style)) |
| story.append(Paragraph( |
| '<b>How itβs done here.</b> Enabled, nothing further to configure β this is the mechanism behind any ' |
| 'PR titled like a plain version bump but opened <i>without</i> 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( |
| '<b>What it is & the need for it.</b> Opens scheduled PRs bumping dependencies to their latest ' |
| 'version, vulnerable or not β <i>not</i> 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( |
| '<b>Configuring on GitHub.</b> No Settings toggle β committing <font face="Courier">' |
| '.github/dependabot.yml</font> to the repo is itself what enables it, per ecosystem entry.', |
| body_style)) |
| story.append(Paragraph( |
| '<b>Configuring in code.</b> <font face="Courier">.github/dependabot.yml</font> β 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( |
| '<b>How itβs done here.</b> One npm entry and one pip entry, both rooted at ' |
| '<font face="Courier">/</font> β 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 <b>security advisory / Dependabot alert</b> in its description, or appeared ' |
| 'without waiting for a Monday β thatβs a <b>security update</b> (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 <b>version update</b> (2.2.3), running on dependabot.ymlβs schedule regardless of ' |
| 'vulnerability status.', |
| ])) |
|
|
| |
| story.append(Paragraph('2.3 Dependency Review Action', h2_style)) |
| story.append(Paragraph( |
| '<b>What it is & what itβs used for.</b> 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 <b>native inline gate</b>: ' |
| '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( |
| '<b>Configuring on GitHub.</b> Requires <b>Dependency graph</b> enabled (<b>Settings β Code security ' |
| 'and analysis β Dependency graph</b>) β 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 ' |
| '<font face="Courier">contents: read</font>.', body_style)) |
|
|
| story.append(Paragraph( |
| '<b>Configuring in code.</b> One job in <font face="Courier">.github/workflows/pr-check.yml</font>:', |
| 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( |
| '<b>How itβs done here.</b> <font face="Courier">fail-on-severity: high</font> blocks the PR if the ' |
| 'diff introduces anything rated high <i>or</i> critical β a plain binary cutoff, unlike the ' |
| 'β0 critical, β€2 highβ <i>count</i>-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)) |
|
|
| |
| |
| |
| 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 <b>while it is actually running</b>, 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( |
| '<b>What it is & what itβs used for.</b> OWASP ZAP (Zed Attack Proxy) is a free, open-source ' |
| 'DAST tool. It runs as a proxy that <b>spiders</b> 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([ |
| '<b>Baseline (passive) scan</b> β 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.', |
| '<b>Full (active) scan</b> β 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 <b>baseline</b> 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( |
| '<b>Configuring on GitHub.</b> Nothing to enable in repo Settings, unlike CodeQL β ZAP is not a ' |
| 'GitHub-native feature, itβs a third-party GitHub Action (<font face="Courier">' |
| 'zaproxy/action-baseline</font>) that runs the official ZAP Docker image. No secret required for the ' |
| 'scan itself. Its results currently do <i>not</i> reach the Security tab at all (Section 4) β they ' |
| 'live only in the workflow log and a build artifact.', body_style)) |
|
|
| story.append(Paragraph( |
| '<b>Configuring in code.</b> One job in <font face="Courier">.github/workflows/pr-check.yml</font>:', |
| 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( |
| '<b>How itβs done here.</b> The app is booted via <font face="Courier">npm start</font> 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 <font face="Courier">npm start</font> boots the whole app in ' |
| 'roughly 15 seconds. The step backgrounds it (<font face="Courier">&</font>) and polls both the ' |
| 'frontend (port 5173) and the API gateway (port 3000) with <font face="Courier">curl</font> until ' |
| 'both respond, up to 30 attempts. ZAP then targets <font face="Courier">http://localhost:5173</font> ' |
| 'β 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 β <font face="Courier">fail_action: false</font> means it cannot ' |
| 'fail the PR no matter what it finds, and <font face="Courier">allow_issue_writing: false</font> ' |
| 'stops it from filing a GitHub issue every run (its default behavior, which would otherwise need a ' |
| 'broader <font face="Courier">issues: write</font> 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 <i>merge-blocking</i> 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 ' |
| '<font face="Courier">cmd_options: "-J zap-report.json"</font>. That broke the job outright: ' |
| '<font face="Courier">report_json.json</font> 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 <font face="Courier">-J</font>, to write somewhere else instead. Fixed by dropping the ' |
| '<font face="Courier">cmd_options</font> override entirely and reading the actionβs existing default ' |
| 'output.')) |
| story.append(Paragraph( |
| 'What <i>did</i> change once a real baseline run existed: ' |
| '<font face="Courier">scripts/zap-alerts-to-sarif.py</font> reads ' |
| '<font face="Courier">report_json.json</font> β 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 <font face="Courier">codeql-action/upload-sarif</font> then pushes to the ' |
| 'Security tab under its own <font face="Courier">zap-baseline</font> 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)) |
|
|
| |
| |
| |
| 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 <b>Code scanning alerts</b> UI ' |
| 'is fundamentally a SARIF viewer: anything that gets a SARIF file to ' |
| '<font face="Courier">github/codeql-action/upload-sarif</font> (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)) |
|
|
| |
| |
| |
| 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 <i>is</i> (data, not a pipeline) β worth being equally explicit about ' |
| 'how it actually gets <i>created</i>, since nothing in this pipeline builds it. GitHub itself parses ' |
| 'whatever manifest/lockfile formats it recognizes for a repoβs ecosystems β for this repo, ' |
| '<font face="Courier">package.json</font> + <font face="Courier">package-lock.json</font> for npm, ' |
| '<font face="Courier">pyproject.toml</font> 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 <b>Dependency graph</b> is ' |
| 'switched on (Section 2.1), and itβs the lockfile specifically that lets it resolve <i>transitive</i> ' |
| '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.')) |
|
|
| |
| |
| |
| 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 <b>ruleset</b> (Settings β Rules β Rulesets), not classic ' |
| 'branch protection β which is why the legacy ' |
| '<font face="Courier">/branches/main/protection</font> 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 ' |
| '<font face="Courier">required_approving_review_count: 0</font>. There is no ' |
| '<font face="Courier">required_status_checks</font> 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. ' |
| '<b>Fix:</b> add a <font face="Courier">required_status_checks</font> rule to the ruleset (or a ' |
| 'branch protection rule) naming the job names from this document β e.g. ' |
| '<font face="Courier">SonarQube Scan</font>, <font face="Courier">CodeQL Security Gate</font>, ' |
| '<font face="Courier">Dependency Review (SCA)</font> β plus <font face="Courier">strict: true</font> ' |
| '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 <b>push protection</b> ' |
| '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 β ' |
| '<font face="Courier">secret_scanning</font>, <font face="Courier">' |
| 'secret_scanning_push_protection</font>, and <font face="Courier">' |
| 'secret_scanning_validity_checks</font> (which checks a detected secret against the issuing ' |
| 'provider to see if itβs still live) β reads <font face="Courier">disabled</font>. <b>Fix:</b> ' |
| '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 <font face="Courier">Dockerfile</font> builds from <font face="Courier">' |
| 'node:20-slim</font> β 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. <font face="Courier">aquasecurity/trivy-action</font> 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( |
| '<font face="Courier">.github/dependabot.yml</font> (Section 2.2.3) has entries for ' |
| '<font face="Courier">npm</font> and <font face="Courier">pip</font> only. Dependabot also supports ' |
| 'a <font face="Courier">docker</font> ecosystem specifically for tracking a Dockerfileβs ' |
| '<font face="Courier">FROM</font> line and opening a PR when a newer tag is available β currently ' |
| 'absent, so <font face="Courier">node:20-slim</font> 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 <font face="Courier">pr-check.yml</font> already sets its own explicit, ' |
| 'least-privilege <font face="Courier">permissions:</font> block β that part is done correctly ' |
| 'throughout this document. The gap is the repo-wide <i>default</i> ' |
| '(<b>Settings β Actions β General β Workflow permissions</b>), which reads ' |
| '<font face="Courier">write</font>. That default only matters for a workflow that <i>omits</i> its ' |
| 'own <font face="Courier">permissions:</font> 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( |
| '<i>Generated for the Meridian (myblogs) project.</i>', |
| ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, textColor=GRAY, alignment=TA_CENTER))) |
|
|
| doc.build(story) |
| print(f'Wrote {out_path}') |
|
|
|
|
| if __name__ == '__main__': |
| build() |
|
|