Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions .github/workflows/security-txt-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: security.txt drift check

# SAP's security.txt (RFC 9116) is maintained centrally by the SGSC PSRT team
# in an internal source-of-truth repo and published publicly at
# https://www.sap.com/.well-known/security.txt. We serve our own copy from the
# approuter (approuter/lib/security-txt.js) because the AEM/Akamai edge that
# historically served developers.sap.com/.well-known/security.txt is being
# decommissioned — once the vanity host points at our origin, we must serve it.
#
# This job detects when SAP changes the canonical file (typically a bump of the
# Expires date) and opens a PR to update our served copy so it never goes stale.
#
# Why diff against www.sap.com and not the origin repo: the origin repo is on
# github.tools.sap (SAP-internal), unreachable from GitHub.com hosted runners,
# and we hold no Actions token for it. www.sap.com serves byte-identical content
# with no token or internal-network requirement.
#
# This workflow NEVER auto-merges or auto-deploys. A human reviews the PR and
# runs a full approuter deploy (npm run deploy -- --env <env>, no --skip-build).

on:
schedule:
# Weekly, Monday 05:19 UTC. Off the :00/:30 marks and off the hour so we
# don't coalesce with the fleet of jobs pinned to "5am UTC sharp".
- cron: '19 5 * * 1'
workflow_dispatch: {}
pull_request:
# Run on PRs that touch the served copy, the drift script, or this workflow,
# so a regression in any of them surfaces on the PR that introduces it.
paths:
- 'approuter/lib/security-txt.js'
- 'scripts/check-security-txt-drift.cjs'
- '.github/workflows/security-txt-drift.yml'

permissions:
contents: read

jobs:
drift:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

# Run the drift check. Exit codes: 0 = match, 1 = tooling/connectivity
# failure, 2 = drift. We capture the log so the drift branch can extract
# the new upstream content from the ===UPSTREAM=== block.
- name: Check security.txt against upstream (www.sap.com)
id: check
run: |
set +e
node scripts/check-security-txt-drift.cjs > drift.log 2>&1
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
set -e
cat drift.log

# On a PR (or any non-drift run) a tooling failure (exit 1) should fail
# the job loudly; a match (exit 0) passes. Drift (exit 2) on a PR run also
# fails the check — the PR author changed the served copy in a way that no
# longer matches upstream, which is exactly what we want flagged.
- name: Fail on tooling error or (on PR) drift
if: ${{ github.event_name == 'pull_request' || steps.check.outputs.exit_code == '1' }}
run: |
EXIT="${{ steps.check.outputs.exit_code }}"
if [ "$EXIT" = "0" ]; then
echo "✅ security.txt matches upstream."
exit 0
fi
if [ "$EXIT" = "2" ]; then
echo "::error title=security.txt drift::approuter/lib/security-txt.js no longer matches https://www.sap.com/.well-known/security.txt. Update SECURITY_TXT to match upstream."
exit 2
fi
echo "::error title=security.txt drift check failed::check-security-txt-drift.cjs exited $EXIT (not a drift signal — see log). Common cause: www.sap.com unreachable."
exit "$EXIT"

# On a scheduled/dispatch run with drift (exit 2), open (or update) a PR
# that updates the served copy to the new upstream content.
- name: Generate GitHub App token
id: app-token
if: ${{ steps.check.outputs.exit_code == '2' && github.event_name != 'pull_request' && vars.USE_GITHUB_APP == 'true' }}
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.TUTORIALS_APP_ID }}
private-key: ${{ secrets.TUTORIALS_APP_PRIVATE_KEY }}
owner: sap-tutorials

- name: Open or update drift PR
if: ${{ steps.check.outputs.exit_code == '2' && github.event_name != 'pull_request' && vars.USE_GITHUB_APP == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
BRANCH="chore/security-txt-drift"

# Extract the new upstream content captured between the markers.
awk '/^===UPSTREAM===$/{f=1;next} /^===END-UPSTREAM===$/{f=0} f' drift.log > upstream.txt
if [ ! -s upstream.txt ]; then
echo "::error::drift detected but no upstream content captured — aborting PR creation."
exit 1
fi

# Rewrite the SECURITY_TXT constant in the served copy from upstream.txt.
# The constant is two concatenated string literals ending in \n; rebuild
# it programmatically from the fetched bytes so we never hand-edit.
node - <<'NODE'
const fs = require('fs')
const file = 'approuter/lib/security-txt.js'
const upstream = fs.readFileSync('upstream.txt', 'utf8').replace(/\r\n/g, '\n')
// Build a JS string-literal expression: one quoted line per source line.
const lines = upstream.split('\n')
// drop a trailing empty element from the final newline, we re-add \n per line
if (lines[lines.length - 1] === '') lines.pop()
const expr = lines.map(l => ` '${l.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}\\n'`).join(' +\n')
let src = fs.readFileSync(file, 'utf8')
const re = /const SECURITY_TXT =\n[\s\S]*?\n\n/
const replacement = `const SECURITY_TXT =\n${expr}\n\n`
if (!re.test(src)) { console.error('could not locate SECURITY_TXT block'); process.exit(1) }
src = src.replace(re, replacement)
fs.writeFileSync(file, src)
console.log('Updated SECURITY_TXT in ' + file)
NODE

rm -f upstream.txt drift.log

git config user.name "tutorials-security-txt-bot"
git config user.email "noreply@sap.com"

# Reset to a clean drift branch off the default branch each run so the
# PR always reflects current upstream (idempotent — no duplicate PRs).
git checkout -B "$BRANCH"
git add approuter/lib/security-txt.js
if git diff --cached --quiet; then
echo "No net change after regeneration — nothing to PR."
exit 0
fi
git commit -m "chore: sync .well-known/security.txt with SAP upstream"
git push --force-with-lease origin "$BRANCH"

# Open a PR if none is open for this head branch; otherwise the force-push
# already updated the existing one.
EXISTING=$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')
if [ "$EXISTING" = "0" ]; then
gh pr create \
--title "chore: sync .well-known/security.txt with SAP upstream" \
--body "SAP's canonical security.txt (https://www.sap.com/.well-known/security.txt) has changed. This PR updates the copy served by the approuter (\`approuter/lib/security-txt.js\`) to match.

Source of truth: \`github.tools.sap/sgsc-engineering-and-automation/securitytxt\` (SGSC PSRT), mirrored publicly at www.sap.com.

**After merge:** deploy the approuter (\`npm run deploy -- --env <env>\`, full build — no \`--skip-build\`) so the new file is served.

_Opened automatically by \`.github/workflows/security-txt-drift.yml\`._" \
--head "$BRANCH"
else
echo "Existing open PR for $BRANCH updated via force-push."
fi

# If drift was detected on a scheduled run but the GitHub App isn't
# configured, still surface it loudly (no silent expiry).
- name: Warn if drift detected but no App token to open a PR
if: ${{ steps.check.outputs.exit_code == '2' && github.event_name != 'pull_request' && vars.USE_GITHUB_APP != 'true' }}
run: |
echo "::warning title=security.txt drift (no auto-PR)::Upstream security.txt changed but USE_GITHUB_APP is not 'true', so no PR was opened. Update approuter/lib/security-txt.js manually to match https://www.sap.com/.well-known/security.txt."
exit 1
59 changes: 59 additions & 0 deletions approuter/lib/security-txt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// approuter/lib/security-txt.js
//
// Serves the SAP canonical security.txt at /.well-known/security.txt (RFC 9116)
// DIRECTLY from the approuter, as middleware — NOT as a static file.
//
// Why middleware, not a static file:
// approuter/static/ is atomically REPLACED on every content publish
// (rebuildHandler in approuter/server.js untars a fresh Hugo tree over it).
// A file dropped in hugo/static/.well-known/ would only survive the swap by
// coincidence and would depend on a content rebuild ever landing. Middleware
// in the approuter's insertMiddleware.first chain always answers first and
// ships with the approuter module itself, so it can never be wiped by a
// content publish. Mirrors approuter/lib/well-known-oauth.js.
//
// Source of truth for the content:
// github.tools.sap/sgsc-engineering-and-automation/securitytxt (SAP SGSC
// PSRT team). SAP publishes the identical bytes at
// https://www.sap.com/.well-known/security.txt, which is the public mirror
// the drift-check workflow diffs against (the origin repo is SAP-internal and
// unreachable from GitHub.com hosted runners). When SGSC bumps the content
// (typically the Expires date), .github/workflows/security-txt-drift.yml
// detects the change and opens a PR to update SECURITY_TXT below.
//
// Spec: RFC 9116 (A File Format to Aid in Security Vulnerability Disclosure).

const SECURITY_TXT_PATH = '/.well-known/security.txt'

// Canonical SAP security.txt. Keep byte-identical to
// https://www.sap.com/.well-known/security.txt (LF line endings, trailing
// newline, no other fields). scripts/check-security-txt-drift.cjs guards this.
const SECURITY_TXT =
'Contact: https://www.sap.com/report-a-vulnerability\n' +
'Expires: 2028-01-31T18:29:00.000Z\n'

// Express-style middleware. Mount at path '/' in the approuter's
// insertMiddleware.first chain, BEFORE the static/proxy handlers so this exact
// path is answered here and never falls through to the srv-api /.well-known/*
// proxy route in xs-app.json (which does not serve security.txt).
function securityTxtHandler(req, res, next) {
if (req.method !== 'GET' && req.method !== 'HEAD') return next()

// Strip any query string before matching.
const pathOnly = (req.url || '').split('?')[0]
if (pathOnly !== SECURITY_TXT_PATH) return next()

res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
})
// HEAD must not carry a body; GET serves the file.
res.end(req.method === 'HEAD' ? undefined : SECURITY_TXT)
}

module.exports = {
securityTxtHandler,
// exported for the unit test and the drift-check script (single source of truth)
SECURITY_TXT,
SECURITY_TXT_PATH,
}
2 changes: 2 additions & 0 deletions approuter/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const { normalizeLegacyCatalogUrl } = require('./lib/catalog-legacy-redirects')
const { bump, startAutoFlush } = require('./lib/hit-counter')
const { safeFetch } = require('./lib/safe-fetch')
const { wellKnownOAuthHandler } = require('./lib/well-known-oauth')
const { securityTxtHandler } = require('./lib/security-txt')

// srv-api URL: in CF it's provided via the `destinations` env var (JSON
// array) injected by the approuter framework when mta.yaml declares
Expand Down Expand Up @@ -572,6 +573,7 @@ ar.start({
insertMiddleware: {
first: [
{ path: '/', handler: wellKnownOAuthHandler },
{ path: '/', handler: securityTxtHandler },
{ path: '/', handler: devtoberfestCspHandler },
{ path: '/admin/rebuild', handler: rebuildHandler },
{ path: '/', handler: imgCdnHandler },
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"prebuild:parsers-bundle": "esbuild scripts/parsers/index.ts --bundle --platform=node --format=esm --outfile=srv-qa/lib/parsers.bundle.mjs --external:node:* --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
"check:security-annotations": "node scripts/check-hugo-safe-html.cjs",
"check:api-docs-drift": "node scripts/check-api-docs-drift.cjs",
"check:security-txt-drift": "node scripts/check-security-txt-drift.cjs",
"prebuild": "npm run prebuild:parsers-bundle && npm run check:security-annotations",
"fetch-tutorials:qa": "tsx scripts/fetch-tutorials.ts --target hugo --channel qa",
"build:qa": "hugo --source hugo --config ../hugo.qa.toml --minify && tsx scripts/verify-qa-build.ts hugo/public-qa",
Expand Down
90 changes: 90 additions & 0 deletions scripts/check-security-txt-drift.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node
// scripts/check-security-txt-drift.cjs
//
// Detects when SAP's canonical security.txt has changed upstream relative to
// the copy we serve from approuter/lib/security-txt.js.
//
// Drift source: https://www.sap.com/.well-known/security.txt
// This is SAP's PUBLIC mirror of the internal source-of-truth repo
// (github.tools.sap/sgsc-engineering-and-automation/securitytxt, maintained
// by the SGSC PSRT team). We diff against www.sap.com, not the repo, because:
// - the origin repo is SAP-internal and unreachable from GitHub.com hosted
// runners, and we hold no Actions token for github.tools.sap;
// - www.sap.com needs no token or internal network and serves byte-identical
// content.
//
// Exit codes (mirrors the publish-content drift convention):
// 0 = match (our served copy equals upstream)
// 1 = tooling/connectivity failure (could not fetch or read)
// 2 = drift detected (upstream changed — a PR should update SECURITY_TXT)
//
// On drift, prints the upstream content to stdout after a `===UPSTREAM===`
// marker so the workflow can capture it and write it into the PR branch.

const path = require('node:path')

const UPSTREAM_URL = 'https://www.sap.com/.well-known/security.txt'
const FETCH_TIMEOUT_MS = 20000

// Normalize for comparison: CRLF -> LF, strip a trailing blank line's worth of
// whitespace differences but keep a single trailing newline. We compare the
// meaningful bytes, tolerant of edge/proxy line-ending rewrites.
function normalize(s) {
return String(s).replace(/\r\n/g, '\n').replace(/\s+$/, '') + '\n'
}

async function main() {
let SECURITY_TXT
try {
({ SECURITY_TXT } = require(path.join('..', 'approuter', 'lib', 'security-txt.js')))
} catch (err) {
console.error(`[security-txt-drift] cannot load approuter/lib/security-txt.js: ${err.message}`)
return 1
}

let upstream
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(UPSTREAM_URL, {
signal: ac.signal,
headers: { 'User-Agent': 'tutorials-ims-security-txt-drift-check' },
})
clearTimeout(t)
if (!res.ok) {
console.error(`[security-txt-drift] upstream ${UPSTREAM_URL} returned HTTP ${res.status}`)
return 1
}
upstream = await res.text()
} catch (err) {
console.error(`[security-txt-drift] failed to fetch ${UPSTREAM_URL}: ${err.message}`)
return 1
}

const ours = normalize(SECURITY_TXT)
const theirs = normalize(upstream)

if (ours === theirs) {
console.log('[security-txt-drift] ✅ match — our served security.txt equals upstream (www.sap.com).')
return 0
}

console.error('[security-txt-drift] ⚠️ DRIFT — upstream security.txt differs from our served copy.')
console.error('')
console.error('--- ours (approuter/lib/security-txt.js) ---')
console.error(ours)
console.error('--- upstream (www.sap.com) ---')
console.error(theirs)
// Machine-readable block for the workflow to capture the new content.
console.log('===UPSTREAM===')
process.stdout.write(theirs)
console.log('===END-UPSTREAM===')
return 2
}

main()
.then((code) => process.exit(code))
.catch((err) => {
console.error(`[security-txt-drift] unexpected error: ${err && err.stack || err}`)
process.exit(1)
})
9 changes: 9 additions & 0 deletions test/smoke/seo-files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ describe('SEO files', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toMatch(/image\/png/);
});

it('serves /.well-known/security.txt (RFC 9116) with Contact + Expires', async () => {
const res = await fetchWithRetry(`${BASE_URL}/.well-known/security.txt`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toMatch(/text\/plain/);
const text = await res.text();
expect(text).toMatch(/^Contact:\s+https?:\/\//m);
expect(text).toMatch(/^Expires:\s+\d{4}-\d{2}-\d{2}T/m);
});
});
Loading
Loading