ci(security): consolidated Semgrep + Psalm taint gate (#7306)

* ci(security): add consolidated Semgrep and Psalm taint gate

One workflow runs both taint scanners as separate jobs. Semgrep and Psalm
treat htmle()/html_escape()/sanitize_uri() as sanitizers, so findings mean
request input reaches a browser sink unescaped.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* stabilize psalm taint analysis

* Install dependencies before Psalm analysis

* Remove obsolete Psalm taint baseline

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-07-20 20:29:57 -04:00
1 parent a9748b92b8
commit 9476a306de
6 files changed
+272

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# +-------------------------------------------------------------------------+
# | Copyright (C) 2004-2026 The Cacti Group |
# | |
# | This program is free software; you can redistribute it and/or |
# | modify it under the terms of the GNU General Public License |
# | as published by the Free Software Foundation; either version 2 |
# | of the License, or (at your option) any later version. |
# +-------------------------------------------------------------------------+
# | Cacti: The Complete RRDtool-based Graphing Solution |
# +-------------------------------------------------------------------------+
name: Security SAST
on:
push:
branches: [develop, 1.2.x]
pull_request:
branches: [develop, 1.2.x]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: security-sast-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
semgrep:
name: Semgrep taint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
fetch-depth: 0
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install Semgrep
run: python3 -m pip install semgrep==1.152.0
- name: Run Semgrep
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [ -n "${BASE_SHA:-}" ]; then
semgrep --config .semgrep.yml --error --baseline-commit "$BASE_SHA" .
else
semgrep --config .semgrep.yml --error .
fi
psalm:
name: Psalm taint
runs-on: ubuntu-latest
env:
COMPOSER_ALLOW_SUPERUSER: 1
PSALM_VERSION: '6.16.1'
PSALM_PHAR_SHA256: '7ace71e4f698466e7e16810679a1895ea623060579aff46fcf9fd0f1c09abe00'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
coverage: none
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Download and verify pinned Psalm phar
run: |
set -euo pipefail
curl -fsSL -o psalm.phar \
"https://github.com/vimeo/psalm/releases/download/${PSALM_VERSION}/psalm.phar"
echo "${PSALM_PHAR_SHA256} psalm.phar" | sha256sum -c -
- name: Run Psalm taint analysis
run: php psalm.phar --taint-analysis --no-progress
+69
View File
@@ -0,0 +1,69 @@
# Cacti-aware Semgrep rules.
#
# Taint-mode rules that understand Cacti's own escapers, so findings are
# limited to request input that reaches a browser sink without passing through
# one of them. htmle()/html_escape() run htmlspecialchars(); htmlerv() and
# html_escape_request_var() are their request-var wrappers; sanitize_uri()
# cleans redirect targets. get_request_var() and get_nfilter_request_var()
# both return $_REQUEST values untouched (the filtering variant is
# get_filter_request_var()), so they are unsanitized sources.
rules:
- id: cacti-reflected-xss
mode: taint
languages: [php]
severity: ERROR
message: >-
Request input reaches echo/print without an HTML escaper. Wrap the value
in htmle()/html_escape() (or htmlerv()/html_escape_request_var() for
request vars) before output to prevent reflected XSS.
metadata:
cwe: "CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)"
owasp: "A03:2021 - Injection"
category: security
confidence: MEDIUM
pattern-sources:
- pattern: $_GET[...]
- pattern: $_POST[...]
- pattern: $_REQUEST[...]
- pattern: get_request_var(...)
- pattern: get_nfilter_request_var(...)
pattern-sanitizers:
- pattern: htmle(...)
- pattern: html_escape(...)
- pattern: html_escape_request_var(...)
- pattern: htmlerv(...)
- pattern: sanitize_uri(...)
pattern-sinks:
- pattern: echo $SINK;
- pattern: print $SINK;
- id: cacti-header-injection
mode: taint
languages: [php]
severity: ERROR
message: >-
Request input reaches header() without sanitization. Validate or escape
the value (e.g. sanitize_uri() for redirect targets) before setting a
response header to prevent header injection and open redirects.
metadata:
cwe: "CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers (HTTP Response Splitting)"
owasp: "A03:2021 - Injection"
category: security
confidence: MEDIUM
# Only raw superglobals are sources here. The get_*_request_var() helpers
# feed the standard redirect idiom (header('Location: page.php?id=' .
# get_request_var('id'))) where 'id' is separately int-validated via
# get_filter_request_var(), so treating them as header sinks is low signal.
# A raw superglobal concatenated into header() is the genuine injection.
pattern-sources:
- pattern: $_GET[...]
- pattern: $_POST[...]
- pattern: $_REQUEST[...]
pattern-sanitizers:
- pattern: htmle(...)
- pattern: html_escape(...)
- pattern: html_escape_request_var(...)
- pattern: htmlerv(...)
- pattern: sanitize_uri(...)
pattern-sinks:
- pattern: header(...)
+7
View File
@@ -0,0 +1,7 @@
# Third-party and generated assets. CLI scripts are not listed here on
# purpose: they take filenames from argv, which the web-sink rules in
# .semgrep.yml do not match, so no blanket ignore is needed for them.
include/vendor/
include/fa/
node_modules/
tests/e2e/node_modules/
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Psalm taint-analysis stubs for Cacti's output escapers.
*
* These declarations exist only to teach Psalm where tainted data is made
* safe. The real implementations live in lib/html.php and lib/functions.php;
* the signatures below must stay in sync with them. Without these escape
* markers Psalm reports XSS taint on every value that is correctly passed
* through htmlspecialchars() by way of these helpers.
*/
/**
* html_escape() runs the value through htmlspecialchars() with ENT_QUOTES.
*
* @psalm-taint-escape html
*/
function html_escape(mixed $string = '') : string {}
/**
* htmle() is a thin alias for html_escape().
*
* @psalm-taint-escape html
*/
function htmle(mixed $string) : string {}
/**
* sanitize_uri() strips the characters used to break out of HTML and URL
* contexts (angle brackets, quotes, backticks, parentheses, etc.), so both
* the html sink and the quote-bearing text sink are satisfied.
*
* @psalm-taint-escape html
* @psalm-taint-escape has_quotes
*/
function sanitize_uri(string $uri) : string {}
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac">
<file src="include/csrf.php">
<TaintedHeader>
<code><![CDATA['Location: ' . sanitize_uri($_SERVER['REQUEST_URI'])]]></code>
</TaintedHeader>
</file>
<file src="include/global.php">
<TaintedHeader>
<code><![CDATA["Content-Security-Policy: default-src *; img-src 'self' https://api.qrserver.com $alternates data: blob:; style-src 'self' 'unsafe-inline' $alternates; script-src 'self' $script_policy 'unsafe-inline' $alternates; frame-ancestors 'self'; worker-src 'self' $alternates;"]]></code>
</TaintedHeader>
</file>
<file src="include/global_session.php">
<TaintedHtml>
<code><![CDATA[gfrv('action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([-a-zA-Z0-9_\s]+)$/']])]]></code>
</TaintedHtml>
<TaintedTextWithQuotes>
<code><![CDATA[gfrv('action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([-a-zA-Z0-9_\s]+)$/']])]]></code>
</TaintedTextWithQuotes>
</file>
<file src="install/functions.php">
<TaintedFile>
<code><![CDATA[$cacheFile]]></code>
</TaintedFile>
<TaintedHtml>
<code><![CDATA[$data]]></code>
</TaintedHtml>
</file>
<file src="lib/auth.php">
<TaintedHeader>
<code><![CDATA['Location: ' . $referer]]></code>
</TaintedHeader>
</file>
<file src="lib/functions.php">
<TaintedHtml>
<code><![CDATA[$omessage]]></code>
<code><![CDATA['Message: ' . trim($string) . PHP_EOL]]></code>
</TaintedHtml>
<TaintedTextWithQuotes>
<code><![CDATA[$omessage]]></code>
<code><![CDATA['Message: ' . trim($string) . PHP_EOL]]></code>
</TaintedTextWithQuotes>
</file>
</files>
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0"?>
<psalm
errorLevel="8"
resolveFromConfigFile="true"
autoloader="include/vendor/autoload.php"
errorBaseline="psalm-baseline.xml"
xmlns="https://getpsalm.org/schema/config"
>
<projectFiles>
<directory name="lib" />
<directory name="install" />
<directory name="cli" />
<directory name="include" />
<file name="managers.php" />
<file name="package_import.php" />
<file name="graphs.php" />
<file name="host.php" />
<file name="reports.php" />
<file name="graph_view.php" />
<file name="data_sources.php" />
<file name="aggregate_graphs.php" />
<ignoreFiles>
<directory name="include/vendor" />
<directory name="include/fa" />
</ignoreFiles>
</projectFiles>
<stubs>
<file name="build/psalm/cacti-sanitizers.phpstub" />
</stubs>
</psalm>