Skip to content

"9 systems" while naming eight #215

"9 systems" while naming eight

"9 systems" while naming eight #215

Workflow file for this run

name: tests
on:
push:
branches: [ main ]
pull_request:
workflow_dispatch:
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
# ------------------------------------------------------------------ #
# CLI — the standard-library half of the product #
# ------------------------------------------------------------------ #
# The charter changed for `server/`, NOT for `modules/` and
# `sap_scanner.py`. This job installs nothing but pytest, so if anyone
# imports a third-party package into the scanner core it fails here
# rather than at a customer who pip-installed nothing.
cli:
name: cli (stdlib only, Python ${{ matrix.python-version }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install test dependencies (pytest ONLY — this is the point of the job)
run: python -m pip install --upgrade pip pytest
- name: Byte-compile the scanner core
run: python -m compileall modules sap_scanner.py -q
# The DB-backed suites skip without DB_DSN, which is correct here — this
# job has no database. They are covered by the `server` job below, and
# that job FAILS if they skip, so the coverage cannot quietly vanish.
#
# EVERY SERVER-TIER SUITE MUST BE LISTED, AND AN INCOMPLETE LIST MADE THIS JOB RED.
# This job installs pytest and NOTHING ELSE — that is its whole point. Two
# consequences follow, and only the first was handled before:
#
# 1. A test file's imports run at COLLECTION, before any skip marker is
# consulted. A module-level `import psycopg` / `starlette` therefore
# aborts the WHOLE run with "Interrupted: N errors during collection",
# taking every scanner test with it. tests/test_graph_paths.py
# (-> server.graph -> psycopg) had done this since the attack-path work
# and tests/test_spa_mount.py (-> starlette) since the SPA migration.
# 2. A test that imports the server tier INSIDE the test body fails rather
# than aborting — quieter, equally red. test_api_auth, test_branding,
# test_enrich, test_sapcontent and the two deferral suites all do this.
#
# Excluding them costs no coverage: the `server` job runs every one of them
# with the real dependencies, and its skip guard means they cannot quietly
# vanish there. A module-level third-party import in a genuine SCANNER test
# still fails this job, which is the boundary it exists to defend — and the
# `purity` job enforces that boundary structurally over modules/ regardless.
#
# THE LIST IS DERIVED, NOT WRITTEN DOWN, and that is the whole point.
#
# It used to be eighteen `--ignore=` lines maintained by hand, under a
# comment reading "WHEN YOU ADD A TEST THAT TOUCHES server/, ADD IT HERE.
# This list rotted once already." By the time it was replaced it had rotted
# twice: ELEVEN test files imported `server` and were not listed, passing
# only because the modules they happened to import were dependency-free.
# That is not a job that passes, it is a job that has not failed yet.
#
# tools/stdlib_only_ignores.py answers the real question — "does running
# this test require a package this job does not install?" — by following
# each test's imports transitively through server/. It is deliberately
# conservative about WHERE it looks: an import inside a fixture does not
# break collection but does break the test, which cost two rounds of
# measurement to establish.
#
# It also preserves the carve-out a naive rule would have destroyed:
# server/totp.py and server/qr.py import only the standard library, so
# tests/test_totp.py and tests/test_qr.py still run HERE as well as in the
# server job — 86 tests of the RFC 6238 core and the ISO/IEC 18004 encoder
# against published vectors, on every Python in the matrix.
#
# Verified in a pytest-only virtualenv before this replaced the list:
# 2174 passed, 11 skipped, nothing failed.
- name: Run scanner tests
run: pytest -q $(python -m tools.stdlib_only_ignores)
- name: Smoke-run the scanner (all modules over sample_data)
env:
PYTHONIOENCODING: utf-8
run: python sap_scanner.py --data-dir ./sample_data --output /tmp/sap_report.html
# ------------------------------------------------------------------ #
# The image itself — ten jobs tested the code and none looked here #
# ------------------------------------------------------------------ #
# A customer's security review asks two questions about a container: what is in
# it, and what does it run as. Nothing answered either.
#
# FAILS ON CRITICAL AND HIGH ONLY, and `ignore-unfixed` is set. A build that
# goes red for a MEDIUM in a base-image package with no available patch teaches
# everyone to ignore the job, and an ignored gate is worse than no gate — it
# looks like assurance. Findings at every severity are still uploaded to the
# Security tab, so nothing is hidden; only the build-breaking threshold moves.
image:
name: the built image scans clean and runs unprivileged
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Build the runtime image
run: docker build -t monitorrisk:ci .
- name: Scan for known vulnerabilities
# A REAL TAG, CHECKED AGAINST THE GITHUB API BEFORE BEING WRITTEN HERE.
# The first version of this line said @0.28.0, which does not exist. The
# job then failed in "Set up job" — before a single step ran — because the
# runner could not resolve the action, and the failure said nothing about
# a version number. Invented dependency versions fail in the least
# informative place available.
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: monitorrisk:ci
format: table
exit-code: "1"
severity: CRITICAL,HIGH
ignore-unfixed: true
vuln-type: os,library
# The image must not run as root even if docker-compose.yml is ignored —
# a customer running `docker run` directly gets the same guarantees as one
# using compose, because the USER is baked in rather than configured.
- name: The image runs as a non-root user
run: |
set -euo pipefail
uid="$(docker run --rm --entrypoint id monitorrisk:ci -u)"
echo "image runs as uid $uid"
test "$uid" != "0"
# Proves the read_only claim at the image level: with the root filesystem
# mounted read-only and every capability dropped, the app must still start
# and answer /health.
#
# The uploads VOLUME is not optional under --read-only, and this step is how
# that was discovered: without it the app died with FileNotFoundError on a
# path inside the image. The secret is 40 characters because config.validate
# rejects anything under 32 — the first version used a short one and the
# container refused to start, correctly. If a future change starts writing to the image, this
# goes red here rather than at a customer.
- name: It starts under the compose hardening
run: |
set -euo pipefail
docker network create ci-net
docker run -d --name ci-db --network ci-net -e POSTGRES_USER=sapsec -e POSTGRES_PASSWORD=citest -e POSTGRES_DB=sapsec postgres:16
for i in $(seq 1 30); do
docker exec ci-db pg_isready -U sapsec -d sapsec && break || sleep 2
done
docker run -d --name ci-app --network ci-net --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --cap-drop ALL --security-opt no-new-privileges:true -e DB_DSN=postgresql://sapsec:citest@ci-db:5432/sapsec -e SESSION_SECRET=ci-only-not-a-real-secret-0123456789abcdef -v ci-uploads:/var/lib/sapsec/uploads -p 127.0.0.1:8000:8000 monitorrisk:ci
ok=""
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/health > /tmp/health.json; then ok=1; break; fi
sleep 3
done
docker logs ci-app | tail -30
test -n "$ok"
cat /tmp/health.json
# ------------------------------------------------------------------ #
# Stdlib purity — enforce the charter boundary mechanically #
# ------------------------------------------------------------------ #
# "modules/ stays stdlib-only" is written in CLAUDE.md, and a rule that
# lives only in a document is a rule that erodes. This makes it fail a build.
purity:
name: modules/ and collect/ import nothing third-party
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check every import in modules/, collect/ and sap_scanner.py
run: |
python - <<'PY'
import ast, sys
from pathlib import Path
# `collect` is here because decision D4 ALLOWS that package its own
# requirements and it deliberately spends none: urllib, ssl and
# xml.etree cover SOAP, OData, SCIM and REST, so a connector tier with
# NO dependencies is a stronger position than one with different ones —
# the customer running it against their own SAP system installs nothing.
# An allowance that is not taken is only real if something checks.
allowed = set(sys.stdlib_module_names) | {"modules", "server", "data",
"collect"}
offenders = []
for path in (sorted(Path("modules").glob("*.py"))
+ sorted(Path("collect").glob("*.py"))
+ [Path("sap_scanner.py")]):
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names = [a.name for a in node.names]
elif isinstance(node, ast.ImportFrom):
# level > 0 is a relative import, always local
names = [node.module or ""] if not node.level else []
else:
continue
for name in names:
root = name.split(".")[0]
if root and root not in allowed:
offenders.append(f"{path}:{node.lineno} {name}")
if offenders:
print("::error::modules/ and sap_scanner.py must import only the "
"standard library. The charter change applies to server/ ONLY.")
for o in offenders:
print(" ", o)
sys.exit(1)
print("clean: the scanner core imports only the standard library")
PY
# ------------------------------------------------------------------ #
# SAP content drift — the catalogue must stay current #
# ------------------------------------------------------------------ #
# data/sap_baseline_requirements.json is DERIVED from SAP's Apache-2.0
# policy repository. Vendoring it keeps the tool working offline and
# air-gapped, which is the whole product premise — but a vendored copy
# of someone else's content goes stale silently, and a coverage page
# measured against a stale denominator is worse than none.
#
# So CI re-derives it from source and fails on any difference. That is
# also free version currency: SAP updates these policies on its own
# schedule and the build tells us the day it happens.
#
# `continue-on-error` is deliberately NOT set. If the fetch breaks, the
# check must fail loudly rather than quietly stop verifying.
- name: docs/CHECKS_REFERENCE.md matches the code
run: python -m tools.build_checks_reference --check
# The release gate's coverage table drifted twice while it was maintained
# by hand, under a note claiming it was derived. It is derived now, and
# this is what keeps that true: a coverage check added without
# regenerating fails the build rather than going quietly missing from the
# document somebody reads before wiring the gate into CI.
- name: docs/RELEASE_GATE.md matches the modules
run: python -m tools.build_gate_reference --check
sap-content:
name: SAP baseline and notes catalogues are current
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: SAP's Baseline Template archive has not moved
# The job below covers the POLICY repository. This covers the Baseline
# Template ZIP, a different artefact on a different cadence — the policy
# set we derive from is v2.4 and the archive ships V2.6. A new version
# here means a new change_marker.pdf, which is what makes "the
# requirement changed" distinguishable from "the system changed" in a
# repeat scan. HEAD plus a 300 KB ranged read of the archive's own
# central directory; it never downloads the 99 MB.
#
# continue-on-error: SAP's support host is outside our control and a
# transient 5xx must not fail an unrelated pull request. The step still
# goes red, which is the signal; it does not block the merge.
continue-on-error: true
run: python -m tools.check_baseline_zip
- name: Fetch SAP's published policy repository
run: |
set -euo pipefail
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/SAP-samples/frun-csa-policies-best-practices.git /tmp/sappol
cd /tmp/sappol
git sparse-checkout set BaselinePolicies NotesPolicies Schema
echo "policy files: $(find . -name '*.xml' | wc -l)"
- name: Re-derive the catalogue and compare
run: |
python - <<'PY'
import json, sys
from pathlib import Path
sys.path.insert(0, ".")
from server.sapcontent import build_catalogue, load_catalogue
fresh = build_catalogue(Path("/tmp/sappol"))
stored = load_catalogue()
def norm(cat):
# Compare the CONTENT, not the run-to-run metadata.
return {"requirements": cat.get("requirements", []),
"policies": cat.get("policies", []),
"counts": cat.get("_meta", {}).get("counts", {})}
if norm(fresh) != norm(stored):
fr = {r["requirement"] for r in fresh["requirements"]}
st = {r["requirement"] for r in stored["requirements"]}
print("::error::data/sap_baseline_requirements.json is out of date with "
"SAP's published policies. The coverage page is measured against "
"this catalogue, so a stale copy misreports coverage.")
if fr - st:
print(f" SAP added: {sorted(fr - st)}")
if st - fr:
print(f" SAP removed: {sorted(st - fr)}")
print(f" counts stored={stored.get('_meta',{}).get('counts')} "
f"fresh={fresh['_meta']['counts']}")
print(" Regenerate with: python -m server.cli rebuild-sap-catalogue /tmp/sappol")
raise SystemExit(1)
print(f"catalogue current: {fresh['_meta']['counts']}")
PY
# The notes catalogue is derived from the OTHER half of the same upstream
# repository, by a different generator, for a different consumer. --strict
# fails on a header line that looks like a note and did not parse, so a new
# patch-day header format is a build failure here rather than a silent gap
# in the note list a customer is working from.
- name: Re-derive the notes catalogue and compare
run: python -m tools.build_sap_notes_catalogue --source /tmp/sappol --strict --check
# ------------------------------------------------------------------ #
# Brand asset drift — the served PNGs must match the master #
# ------------------------------------------------------------------ #
# server/static/* is DERIVED from docs/brand/monitorrisk-master.png. The
# derived files are committed so a deployment — and every other CI job —
# needs nothing but the repo. But a committed binary with no provenance is
# exactly the thing nobody notices going stale, so the same rule as the SAP
# catalogue above applies: if it is derived, CI re-derives it.
#
# Pillow is installed HERE and nowhere else. It is a build-time tool, not a
# runtime dependency, and requirements.txt stays at four packages.
brand-assets:
name: brand assets match the master
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install --quiet Pillow
- name: Re-derive the brand assets and compare
run: python tools/build_brand_assets.py --check
# ------------------------------------------------------------------ #
# Schema upgrade — the documented upgrade path, actually exercised #
# ------------------------------------------------------------------ #
# The `server` job applies schema.sql TWICE, which proves IDEMPOTENCY and not
# UPGRADE. Those are different properties and the gap between them hid a real
# defect: `CREATE TABLE IF NOT EXISTS` is a no-op on an existing table, so a
# changed CHECK list altered a fresh database, left every deployed one on the
# old rule, and passed CI green because CI always built from empty.
#
# This job builds a database from the PREVIOUS commit's schema.sql — a real
# "already deployed" starting point — then applies the current one and asserts
# the constraint moved. It is the only job that can fail on a migration that
# was written but does nothing.
schema-upgrade:
name: schema upgrades an existing database
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: sapsec
POSTGRES_PASSWORD: sapsec
POSTGRES_DB: sapsec
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U sapsec -d sapsec"
--health-interval 5s --health-timeout 5s --health-retries 12
# Not a secret, and a scanner will say otherwise. This credential belongs to
# a service container that exists only for the life of this job, is reachable
# only from it, holds nothing but a throwaway schema, and is destroyed with
# the runner. It matches the `server` job below deliberately — two spellings
# of a disposable CI password is worse than one. A real secret would come
# from `secrets.`, and none of this repository's jobs needs one.
env:
PGPASSWORD: sapsec
DSN: postgresql://sapsec:sapsec@localhost:5432/sapsec
steps:
# Depth 2 so HEAD~1 exists. A shallow clone would make the previous
# schema unreachable and the job would silently degrade to a fresh install.
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- name: Recover the PREVIOUS schema.sql
run: |
set -euo pipefail
if git rev-parse HEAD~1 >/dev/null 2>&1 \
&& git cat-file -e HEAD~1:server/schema.sql 2>/dev/null; then
git show HEAD~1:server/schema.sql > /tmp/previous.sql
echo "using HEAD~1's schema as the deployed starting point"
else
cp server/schema.sql /tmp/previous.sql
echo "::notice::no previous schema reachable; upgrading from the current one"
fi
- name: Build a database from the PREVIOUS schema
run: psql "$DSN" -v ON_ERROR_STOP=1 -q -f /tmp/previous.sql
# Data first: a migration that only works on an empty table is not a
# migration. If widening a constraint ever has to rewrite rows, this is
# where the job starts failing and asking somebody to decide.
# SEED EVERY TABLE THE MIGRATION TOUCHES, NOT JUST THE ONE IT USED TO.
# This job seeded `landscape` alone, so for the whole run `sap_system` was
# empty — which meant a discriminator CHECK validated against zero rows and
# always passed, a nullability change was never exercised by an insert, and a
# unique index built over an empty table could never collide. The comment
# above promised data coverage the job did not deliver.
- name: Put rows in it
run: |
set -euo pipefail
psql "$DSN" -v ON_ERROR_STOP=1 -c \
"INSERT INTO landscape (name, deployment_mode) VALUES ('upgrade-probe','on_prem');"
psql "$DSN" -v ON_ERROR_STOP=1 -c \
"INSERT INTO sap_system (landscape_id, sid, client, tier)
SELECT id, 'PRD', '100', 'prod' FROM landscape WHERE name = 'upgrade-probe';"
- name: Apply the CURRENT schema over it, then twice more
run: |
set -euo pipefail
for i in 1 2 3; do
psql "$DSN" -v ON_ERROR_STOP=1 -q -f server/schema.sql
echo " application $i ok"
done
- name: The rows survived, unaltered
run: |
set -euo pipefail
psql "$DSN" -v ON_ERROR_STOP=1 -tAc \
"SELECT count(*) FROM landscape WHERE name = 'upgrade-probe';" | grep -qx 1 \
|| { echo "::error::the upgrade destroyed existing data"; exit 1; }
# The pre-existing ABAP system must come through untouched AND be given
# the new column's default. A migration that silently rewrote sid or
# client would re-identify every finding attached to it.
psql "$DSN" -v ON_ERROR_STOP=1 -tAc \
"SELECT sid || '|' || client || '|' || platform || '|' ||
coalesce(external_key,'<null>') FROM sap_system;" \
| grep -qx 'PRD|100|abap|<null>' \
|| { echo "::error::the upgrade altered the existing sap_system row"; exit 1; }
# THE SHAPE OF THE TABLE, NOT ONLY ITS CHECK CONSTRAINTS. A nullability
# change and an index definition are both invisible to the constraint
# assertion below, and both are load-bearing for decision D8.
- name: Nullability and the tenant index actually moved
run: |
set -euo pipefail
nullable=$(psql "$DSN" -v ON_ERROR_STOP=1 -tAc \
"SELECT string_agg(column_name || '=' || is_nullable, ',' ORDER BY column_name)
FROM information_schema.columns
WHERE table_name = 'sap_system'
AND column_name IN ('sid','client','platform','external_key');")
echo " $nullable"
[ "$nullable" = "client=YES,external_key=YES,platform=NO,sid=YES" ] || {
echo "::error::sap_system column nullability is not what schema.sql declares."
echo "::error::Relaxing a column in the CREATE TABLE alone is a no-op on an"
echo "::error::existing database — it needs an ALTER in the migration section."
exit 1; }
# CREATE UNIQUE INDEX IF NOT EXISTS is idempotent by NAME only; PostgreSQL
# never compares the definition. So an index whose predicate or column list
# changed is a silent no-op on every deployed database, and this is the
# only place that would notice.
idx=$(psql "$DSN" -v ON_ERROR_STOP=1 -tAc \
"SELECT indexdef FROM pg_indexes
WHERE tablename = 'sap_system' AND indexname = 'sap_system_tenant_key';")
echo " $idx"
[ -n "$idx" ] || { echo "::error::sap_system_tenant_key does not exist after the upgrade"; exit 1; }
case "$idx" in
*"landscape_id, platform, external_key"*) ;;
*) echo "::error::tenant index columns are not what schema.sql declares"; exit 1;;
esac
case "$idx" in
*"NULLS NOT DISTINCT"*) ;;
*) echo "::error::tenant index lost NULLS NOT DISTINCT — duplicate tenants become insertable"; exit 1;;
esac
# BEHAVIOUR, NOT ONLY SHAPE. The constraints exist to refuse specific rows;
# asserting their text proves they are declared, not that they work.
- name: The migrated table refuses what it must
run: |
set -euo pipefail
refuses() { # $1 = description, $2 = SQL that must fail
if psql "$DSN" -v ON_ERROR_STOP=1 -q -c "$2" >/dev/null 2>&1; then
echo "::error::ACCEPTED but should be refused: $1"; exit 1
fi
echo " refused: $1"
}
L="(SELECT id FROM landscape WHERE name='upgrade-probe')"
# An empty-string sid satisfies IS NOT NULL and normalises to the same
# fingerprint as every other empty sid. This is the exact row decision D8
# exists to make impossible, and the old schema accepted it.
refuses "an ABAP row with an empty sid" \
"INSERT INTO sap_system (landscape_id, platform, sid, client) VALUES ($L,'abap','','100');"
refuses "an ABAP row with no sid at all" \
"INSERT INTO sap_system (landscape_id, platform, client) VALUES ($L,'abap','100');"
refuses "a tenant with no external key" \
"INSERT INTO sap_system (landscape_id, platform) VALUES ($L,'concur');"
refuses "an unknown platform" \
"INSERT INTO sap_system (landscape_id, platform, external_key) VALUES ($L,'myspace','x');"
# And the rows it must ACCEPT, or the migration has bought safety by
# making the feature impossible.
psql "$DSN" -v ON_ERROR_STOP=1 -q -c \
"INSERT INTO sap_system (landscape_id, platform, external_key, tier)
VALUES ($L,'successfactors','acme-sf-prod','prod'),
($L,'successfactors','acme-sf-test','dev');" \
|| { echo "::error::two distinct SaaS tenants were rejected"; exit 1; }
echo " accepted: two distinct SuccessFactors tenants"
refuses "the SAME tenant twice" \
"INSERT INTO sap_system (landscape_id, platform, external_key) VALUES ($L,'successfactors','acme-sf-prod');"
# add-system names UNIQUE (landscape_id, sid, client) as an ON CONFLICT
# arbiter. PostgreSQL cannot infer an arbiter from a PARTIAL index, so if
# that constraint is ever replaced by one this raises 42P10 — at runtime,
# on a path no pytest executes.
psql "$DSN" -v ON_ERROR_STOP=1 -q -c \
"INSERT INTO sap_system (landscape_id, sid, client, tier) VALUES ($L,'PRD','100','qa')
ON CONFLICT (landscape_id, sid, client) DO UPDATE SET tier = EXCLUDED.tier;" \
|| { echo "::error::ON CONFLICT (landscape_id, sid, client) no longer resolves (42P10)."
echo "::error::server/cli.py add-system depends on this constraint being non-partial."
exit 1; }
echo " ON CONFLICT arbiter still resolves"
# THE ASSERTION THAT MAKES THIS JOB WORTH HAVING. Compare the constraint
# LIVE IN THE DATABASE against the one this commit's schema.sql declares.
# A CHECK edited only inside a CREATE TABLE IF NOT EXISTS fails here.
# ⚠️ THIS STEP FAILS CLOSED, AND THE FIRST DRAFT DID NOT.
# It was two `grep -oP` pipelines compared with `[ "$a" = "$b" ]`. When the
# extraction returned nothing — a locale without PCRE, a renamed constraint,
# a reworded CHECK — both sides were empty, empty equalled empty, and the
# step reported success having compared nothing. That is precisely the
# defect this job exists to catch, reproduced inside the job itself. Both
# sides are now asserted non-empty before they are compared, and the
# extraction is Python rather than a locale-dependent grep.
- name: The constraints actually moved
run: |
python - <<'PY'
import os, re, subprocess, sys
# EVERY growable value list, not one. This checked `deployment_mode`
# alone, which was all there was; decision D8 added `platform`, and a
# single-constraint assertion would have let it be widened in the
# CREATE TABLE only — the exact mistake, on the exact next constraint.
# Adding a row here is what registering a new growable CHECK costs.
CONSTRAINTS = [
("deployment_mode", "landscape_deployment_mode_check"),
("platform", "sap_system_platform_check"),
]
sql = open("server/schema.sql", encoding="utf-8").read()
failed = False
# PostgreSQL does NOT store `IN (...)`. It normalises it to
# CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))
# so parsing the live side for "IN (" finds nothing and — before the
# non-empty assertions below — compared nothing against nothing.
# Pull the quoted literals from either shape instead.
def values(text):
return sorted(re.findall(r"'([^']*)'", text))
for column, conname in CONSTRAINTS:
print(f"\n--- {column} / {conname} ---")
declared = re.findall(
rf"{column}\s+IN\s*\(([^)]*)\)", sql, re.IGNORECASE)
if len(declared) < 2:
sys.exit(f"::error::expected the {column} CHECK in BOTH the "
f"CREATE TABLE and the constraint-migrations section, found "
f"{len(declared)}. This assertion has stopped checking.")
live_raw = subprocess.run(
["psql", os.environ["DSN"], "-tAc",
"SELECT pg_get_constraintdef(oid) FROM pg_constraint "
f"WHERE conname = '{conname}'"],
capture_output=True, text=True, check=True).stdout.strip()
if not live_raw:
sys.exit(f"::error::no constraint named {conname} exists in the "
"upgraded database - the migration in the "
"constraint-migrations section did not run")
# THREE-WAY, and it has to be. Comparing only the LAST declaration —
# the migration block — against the database proves the migration ran
# and says nothing about whether it agrees with the CREATE TABLE above
# it. Editing only the CREATE is exactly the mistake this job exists to
# catch, and a two-way check passes it: the migration and the database
# still agree with each other, both on the old list. Every declaration
# in the file and the live constraint must be the same set.
lists = [values(d) for d in declared]
have = values(live_raw)
for i, v in enumerate(lists):
print(f"declared #{i + 1} in schema.sql : {v}")
print(f"live in the database : {have}")
assert all(lists), f"a declared {column} value list parsed as empty"
assert have, f"the live {column} value list parsed as empty"
if len({tuple(v) for v in lists}) != 1:
print(f"::error::the {column} CHECK is declared more than one "
"way inside schema.sql. The copy in CREATE TABLE governs fresh "
"installs and the copy in the constraint-migrations section "
"governs upgrades - an installation gets whichever one it was "
"born under. Change both.")
failed = True
continue
if lists[0] != have:
print(f"::error::the {column} CHECK in the database does not "
"match schema.sql. A CHECK edited inside CREATE TABLE IF NOT "
"EXISTS is a no-op on an existing database - add a DROP/ADD "
"pair to the constraint-migrations section at the end of "
"schema.sql.")
failed = True
continue
print(f"{column}: matches what this commit declares, on both sites")
if failed:
sys.exit(1)
print("\nevery growable constraint matches on every site")
PY
# ------------------------------------------------------------------ #
# Server — needs a real PostgreSQL #
# ------------------------------------------------------------------ #
server:
name: server (PostgreSQL 16)
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: sapsec
POSTGRES_PASSWORD: sapsec
POSTGRES_DB: sapsec
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U sapsec -d sapsec"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
DB_DSN: postgresql://sapsec:sapsec@localhost:5432/sapsec
SESSION_SECRET: ci-session-secret-not-a-real-deployment-value-0123456789
PYTHONIOENCODING: utf-8
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements.txt
# The console is a build artefact and server/spa is gitignored, so without
# this step it does not exist in CI: every `built` assertion in
# tests/test_spa_mount.py skips, and the skip guard below correctly counts
# that as drift. It matters more since the SPA took "/" — an unbuilt bundle
# used to mean /ui answered 503 while the server-rendered console carried
# on, and now means every screen answers 503.
#
# Node appears in this job and in the Dockerfile's first stage. It is a
# build-time dependency in both; nothing JavaScript reaches the runtime
# image or requirements.txt.
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build the console (type-checks, then emits server/spa)
working-directory: frontend
run: |
npm ci
npm run build
test -f ../server/spa/index.html \
|| { echo "::error::the bundle was not emitted where the server serves it"; exit 1; }
# THE TYPE-CHECKER ABOVE PROVES THE CODE COMPILES, NOT WHAT THE SCREEN SAYS.
#
# The console reached ~5,000 lines of TSX with `tsc --noEmit` as its only
# check, and two defects shipped through that gap in a single day: a CSF
# Category filed under "Assessed here" while it displayed the "Export not
# supplied" chip an inch above, and a null pass rate coerced to 0% by
# `?? 0`, so a category nobody had scanned rendered as the customer's
# worst-performing area and sorted to the top of the table. Both
# type-check perfectly; both invert the product's central discipline on
# screen. This step is what makes that class fail a pull request.
- name: Test the console
working-directory: frontend
run: npm test
# `httpx` is TEST tooling, not a runtime dependency, which is why it is
# here and not in requirements.txt: starlette's TestClient refuses to
# construct without it, and tests/test_http_console.py goes through it.
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt httpx
- name: Byte-compile the server (catches syntax errors in files tests skip)
run: python -m compileall server -q
- name: Verify PostgreSQL is actually reachable
run: |
pg_isready -d "$DB_DSN" \
|| { echo "::error::PostgreSQL unreachable — every DB-backed test would SKIP and the job would still pass"; exit 1; }
psql "$DB_DSN" -v ON_ERROR_STOP=1 -c 'SELECT version();'
# The schema is the documented upgrade path: an existing deployment
# upgrades by re-running this file. It has already broken once (a
# CREATE OR REPLACE VIEW over SELECT f.* could not survive an ALTER
# TABLE), so applying it TWICE is the regression test.
- name: Apply the schema twice — idempotency is the upgrade path
run: |
python -m server.cli init-db
python -m server.cli init-db
# SEED BEFORE TESTING, not after. Four tests self-skip with "no findings in
# this database" — they are DATA-dependent, not DB-dependent, and the
# end-to-end step below used to be the only thing that created findings, so
# they never ran and the skip guard below counted them as drift. Seeding first
# buys real coverage of the finding-detail and code-console surfaces instead of
# relaxing the guard, which would only move the goalposts.
# SEED BEFORE TESTING, not after. Several tests are DATA-dependent rather
# than DB-dependent -- they need a scan to have happened, a second system
# to scope a viewer to, a second landscape to tell a union from a
# narrowing -- and the end-to-end step below used to be the only thing
# that created findings, so they never ran and the guard counted them as
# drift. Seeding first buys real coverage instead of relaxing the guard.
#
# The seed itself lives in tools/seed_test_db.py rather than inline here,
# so `python -m tools.db_test` can reproduce this run locally against the
# SAME fixture. It used to be forty lines of Python in this file, which
# meant a developer could not run the database half the way CI does
# without copying them -- and a copy is a second thing to keep in step.
# tests/test_ci_seed_is_not_inlined.py fails if it grows back.
- name: Seed a scan so data-dependent suites actually execute
run: python -m tools.seed_test_db
- name: Run the full test suite
run: |
set -o pipefail
pytest -q -W ignore::DeprecationWarning | tee /tmp/pytest.out
# THE GUARD. Before this existed, `pytest -q` ran the DB-backed suites,
# they skipped for want of DB_DSN, and the job went green having verified
# none of the journey, none of the analytics and none of the HTTP layer.
# That is how a bug that broke EVERY page in the console reached main.
- name: Fail if the database-backed tests skipped
run: |
for suite in test_integration_ingest test_integration_journey test_http_console; do
n=$(pytest --collect-only -q "tests/$suite.py" 2>/dev/null | tail -1)
echo "$suite: $n"
done
if grep -qE '[0-9]+ skipped' /tmp/pytest.out; then
skipped=$(grep -oE '[0-9]+ skipped' /tmp/pytest.out | head -1)
# The seed above now creates a second system and a second landscape,
# so the scoping and narrowing suites RUN rather than skip: the
# expected number here is zero. One is tolerated for a scanner test
# that needs an optional sibling engine, which is absent on some
# runners. More than that means a DB-backed suite is silently not
# running -- and a suite that skips is worse than one that does not
# exist, because it LOOKS verified.
count=${skipped%% *}
if [ "$count" -gt 1 ]; then
echo "::error::$skipped — the DB-backed suites are not executing. A suite that silently skips is worse than one that does not exist, because it LOOKS verified."
exit 1
fi
fi
# docs/CHECK_FIRING.md publishes how many of the product's checks are
# PROVEN to produce a finding somewhere in this suite. Nothing regenerated
# it, so the figure would have drifted the first time anybody added a
# check -- a published number nobody re-derives is a claim, not a
# measurement, which is exactly what this repository reports on in
# customers' systems.
#
# It runs HERE, in the only job that executes the full suite: the
# stdlib-only job runs a subset, and conftest deliberately refuses to
# write a recording from a partial run rather than publish "these checks
# are unproven" on the strength of tests that never ran.
#
# Any difference fails, in both directions. A figure that rose matters as
# much as one that fell.
- name: docs/CHECK_FIRING.md matches what the suite proves
run: python -m tools.build_firing_reference --check
- name: Prove the console actually answers end to end
run: |
python - <<'PY'
import sys; sys.path.insert(0, ".")
from pathlib import Path
from fastapi.testclient import TestClient
from server import app as appmod, auth, db, ingest
# WHAT THIS STEP IS FOR, AFTER THE JINJA RETIREMENT.
# It existed because a bug broke EVERY page in the console and three
# layers of testing said it was fine. The pages are gone; the failure
# did not go away, it moved. A screen is now a JSON endpoint plus a
# compiled bundle, so both halves are exercised here: every endpoint a
# screen reads answers 200 with a real database and a real scan behind
# it, and every screen ADDRESS serves the console that was built in the
# step above. Either half alone passes while the product is unusable.
db.init_schema()
auth.create_user("ci", "ci-password-1234", "admin")
land = db.one("INSERT INTO landscape (name, deployment_mode) "
"VALUES ('ci','rise_pce') RETURNING id")["id"]
sysid = db.one("INSERT INTO sap_system (landscape_id, sid, client, tier) "
"VALUES (%s,'PRD','100','prod') RETURNING id", (land,))["id"]
run = db.one("INSERT INTO scan_run (landscape_id, system_id, status) "
"VALUES (%s,%s,'pending') RETURNING id", (land, sysid))["id"]
res = ingest.scan_directory(Path("sample_data"), land, sysid, run,
deployment_mode="rise_pce",
default_sid="PRD", default_client="100")
print(f"scanned: {res['findings']} findings, {res['nodes']} graph nodes")
assert res["findings"] > 0, "the CI scan produced no findings"
c = TestClient(appmod.app)
# The JSON sign-in. The form POST that answered 303 was deleted with the
# templates it rendered; there is one sign-in surface now.
r = c.post("/api/auth/login",
json={"username": "ci", "password": "ci-password-1234"})
assert r.status_code == 200, r.text[:300]
assert r.json()["username"] == "ci"
failed = []
def check(path, expect=200, note=""):
resp = c.get(path)
print(f" {resp.status_code} {path} {note}")
if resp.status_code != expect:
failed.append((path, resp.status_code, expect, resp.text[:300]))
return resp
print("API — what every screen reads:")
for path in ("/api/dashboard", "/api/findings", "/api/trend", "/api/risk",
"/api/paths", "/api/coverage", "/api/account", "/api/systems",
"/api/landscapes", "/api/views", "/api/findings?tier=P1",
f"/api/runs/{run}", f"/api/runs/{run}/diff", "/health"):
check(path)
fid = db.one("SELECT id FROM finding LIMIT 1")["id"]
detail = check(f"/api/findings/{fid}")
if detail.status_code == 200:
for key in ("check_id", "remediation", "risk_narrative", "latest_details"):
if key not in detail.json():
failed.append((f"/api/findings/{fid}", f"missing {key}", "present", ""))
check(f"/api/findings/{fid}/history")
print("Console — the addresses people type and bookmark:")
for path in ("/", "/findings", f"/findings/{fid}", "/trend", "/risk", "/paths",
"/coverage", "/upload", "/account", "/login", f"/runs/{run}",
"/v/some-slug"):
resp = check(path)
# 503 is the "bundle was never built" answer. The step above builds
# it, so a 503 here means the artefact did not reach the server and
# the whole console is down.
if resp.status_code == 200 and '<div id="root">' not in resp.text:
failed.append((path, "served something other than the console", "index.html", ""))
print("Retired URLs — bookmarks from before the cutover:")
nofollow = TestClient(appmod.app, follow_redirects=False)
nofollow.cookies.update(c.cookies)
for old_url, expected in (("/ui", "/"),
("/ui/findings", "/findings"),
(f"/ui/findings/{fid}", f"/findings/{fid}")):
resp = nofollow.get(old_url)
print(f" {resp.status_code} {old_url} -> {resp.headers.get('location')}")
if resp.status_code != 301 or resp.headers.get("location") != expected:
failed.append((old_url, resp.status_code, f"301 -> {expected}", ""))
# A mistyped endpoint must not be answered with the console: the mount is
# at the root and is the last route, so this is the guard that keeps an
# integrator's typo a 404 instead of a page of HTML.
check("/api/findigs", expect=404, note="(a typo must 404, not render)")
if failed:
print("::error::the console did not answer end to end")
for f in failed:
print(" ", f)
raise SystemExit(1)
print("console answers")
PY