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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
CHANGELOG for 1.x
===================
## v1.7.1 - (2026-09-23)
### TODO When updating
- Run `composer recipes:install smartbooster/standard-bundle --reset --force` to pick up the updated `make/security.mk`,
`dast_scan/build-plan.sh` and `dast_scan/scripts/` (the set of copied files is unchanged, so there is no new dotted manifest)
- The DAST scans now **fail the build** as soon as an alert reaches `failOnRisk` (default `Low`), where they used to always exit `0` :
before enabling `dast-blackbox-ci` on a project that already ran it, either arbitrate the current alerts in `dast_scan/alert-filters.json`
or raise `failOnRisk` in the project's `dast_scan/targets.json` ; see [docs/security.md](docs/security.md)
- Widen `reportRisks` in `dast_scan/targets.json` to at least the levels covered by `failOnRisk` (`["info", "low", "medium", "high"]` with
the default), otherwise the CI fails on alerts absent from the reports
- The first run of each `dast-*` target now downloads the `ascanrulesBeta` add-on into the container (set `DAST_ADDONS=` to opt out)

### Fixed

All the following is added content but it aim to fix the overall behavior of zap scan tool chain.

- `dast_scan/build-plan.sh` : `exitStatus` job appended to every generated plan, the gate that turns an alert into a red pipeline
(ZAP itself always exits `0`). `errorLevel` comes from the new `failOnRisk` key of `targets.json` (default `Low`), `warnLevel` is
`Informational`, and `alwaysRun: true` keeps the status set when an earlier job interrupts the plan
- `dast_scan/build-plan.sh` : optional `dast_scan/alert-filters.json` baseline, rendered as an `alertFilter` job emitted before any
traffic-generating job (a filter only applies to the alerts raised after it is registered). An alert requalified as `False Positive`
is skipped by `exitStatus` while staying visible in the report ; the non-ZAP `note` key of each filter is rendered as a YAML comment
above the filter it documents, so the generated plan carries the arbitration. Like `targets.json`, the file is project-specific and
**never shipped by the recipe**
- Three custom passive scan rules in `dast_scan/scripts/`, covering vulnerabilities Qualys reports and ZAP has no native equivalent for :
- `form-autocomplete.js` (id `9000001`, QID 150112) : `type="password"` input with autocompletion disabled neither on the field nor on
its form — ZAP retired its own rule `10012` in 2018, Qualys still reports it
- `blank-target-links.js` (id `9000002`, QID 150222) : cross-domain `target="_blank"` link without `rel="noopener"` — the native rule
`10108` only alerts when `rel` explicitly contains `opener`, so a link with no `rel` at all never triggers it, at any threshold
- `client-side-cookies.js` (id `9000003`, QID 150122/150123) : cookie written by the served JavaScript — it never appears in a
`Set-Cookie` header, so the native rules `10010`/`10011` are structurally blind to it
- `make/security.mk` : `DAST_ADDONS` variable (default `ascanrulesBeta`), installing the listed add-ons into the container before the scan.
The official image ships the release add-ons only, and the CORS active scan rule (alert `40040`, equivalent of Qualys' QID 150631) lives
in beta. The install is a **separate `zap.sh` invocation** in the same container : doing it in the `-autorun` run pulls a newer
`commonlib` and breaks the custom scripts (`Could not initialize class ...ScanRuleMetadata`)
- `make/security.mk` : ZAP's `zap.log` copied out of the container into `dast_scan/report/<target>.log` before `--rm` discards it. In
`-cmd` mode the log never reaches stdout, so it is the only way to read the warnings counted by the report's `insight.log.warn` ; the
copy is unconditional and the exit code saved beforehand, so a failing scan still yields it
- `docs/security.md` : "The gate (`failOnRisk`)", "The baseline (`dast_scan/alert-filters.json`)" (schema, the `String.matches()` whole-URL
trap, the silence of a filter matching nothing) and "Add-ons (`DAST_ADDONS`)" sections ; the custom rules section now lists the four
shipped rules and reserves the `9000001+` id range for home-made ones

### Changed
- `dast_scan/scripts/phpinfo.js` : id `100001` → `9000004`, to sit in the `9000001+` range now reserved for the bundle's own scan rules

## v1.7.0 - (2026-09-21)
### Added
- PHPUnit extension `DeprecationLoggerExtension` and `DeprecationLoggerSubscriber` to intercept and log unhandled deprecations during test execution
Expand Down
44 changes: 44 additions & 0 deletions dast_scan/build-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@
# excludePaths : regexes taken out of scope in active mode, for the routes that are
# destructive or that trigger emails.
#
# Two more files shape the plan:
# dast_scan/alert-filters.json : the baseline, turned into an alertFilter job so that an
# arbitrated alert stops failing the build while staying in the report.
# dast_scan/scripts/*.js : home-made scan rules, inlined below.
#
set -eu

mode=${1:?"missing mode (passive|crawl|active)"}
env_name=${2:?"missing environment (see .environments in targets.json)"}
targets=${3:-"$(dirname "$0")/targets.json"}
scripts_dir="$(dirname "$0")/scripts"
filters_file="$(dirname "$0")/alert-filters.json"

command -v jq >/dev/null 2>&1 || { echo "build-plan.sh: jq is required" >&2; exit 1; }
[ -f "$targets" ] || { echo "build-plan.sh: targets file not found ($targets)" >&2; exit 1; }
Expand All @@ -43,6 +49,7 @@ spider_max_mins=$(jq -r --arg env "$env_name" '.environments[$env].spiderMaxMins
ajax_max_mins=$(jq -r --arg env "$env_name" '.environments[$env].spiderAjaxMaxMins // 2' "$targets")
active_max_mins=$(jq -r --arg env "$env_name" '.environments[$env].activeScanMaxMins // 10' "$targets")
report_risks=$(jq -r --arg env "$env_name" '.environments[$env].reportRisks // [] | join(", ")' "$targets")
fail_on_risk=$(jq -r --arg env "$env_name" '.environments[$env].failOnRisk // "Low"' "$targets")
prefix=${4:-"blackbox-$env_name-$mode"}
context="blackbox"

Expand Down Expand Up @@ -107,6 +114,30 @@ jobs:
enableTags: false
EOF

# The baseline, first of all the jobs that matter: an alert filter only applies to the alerts
# raised after it is registered, so it has to come before any job generating traffic.
# "note" is ours, not ZAP's: it is rendered as a YAML comment above the filter it documents.
alert_filters() {
[ -f "$filters_file" ] || return 0
jq -r '
.filters[]? as $f
| [$f | del(.note) | to_entries[]] as $entries
| (if ($f.note // "") != "" then " # " + $f.note else empty end),
( range(0; $entries | length) as $i
| (if $i == 0 then " - " else " " end)
+ $entries[$i].key + ": " + ($entries[$i].value | tojson) )
' "$filters_file"
}

if [ -n "$(alert_filters)" ]; then
cat <<'EOF'

- type: alertFilter
alertFilters:
EOF
alert_filters
fi

# Home-made passive scan rules, registered before any job that generates traffic.
# The code is copied into the plan (inline) rather than mounted into the container: the plan
# stays self-contained, and the one archived by the CI holds the rules that actually ran.
Expand Down Expand Up @@ -235,3 +266,16 @@ EOF
# It weighs about 7 times more than the standard template.
report_job traditional-html-plus
report_job traditional-json

# The gate. ZAP itself exits 0 whatever it finds, so this job is what turns an alert into a red
# pipeline. Alerts requalified as "False Positive" by the alertFilter job are skipped by ZAP here
# (ExitStatusJob ignores CONFIDENCE_FALSE_POSITIVE), which is what makes the baseline work.
# alwaysRun so that the status is still set when an earlier job interrupts the plan.
cat <<EOF

- type: exitStatus
parameters:
errorLevel: "$fail_on_risk"
warnLevel: "Informational"
alwaysRun: true
EOF
94 changes: 94 additions & 0 deletions dast_scan/scripts/blank-target-links.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Passive scan rule: link opening a third-party page in a new tab without rel="noopener".
//
// The native rule 10108 (Blank link target) only alerts when rel explicitly contains "opener"
// without "noopener": a plain <a target="_blank"> carrying no rel at all - the exact pattern
// Qualys reports as QID 150222 - never triggers it, at any threshold.

const ScanRuleMetadata = Java.type("org.zaproxy.addon.commonlib.scanrules.ScanRuleMetadata");

function getMetadata() {
return ScanRuleMetadata.fromYaml(`
id: 9000002
name: Link to another domain opened with target="_blank" and no rel="noopener"
description: >
The page links to another domain with target="_blank" but without rel="noopener" nor
rel="noreferrer". The opened page can then reach the opener through window.opener and
redirect the original tab, for instance to a phishing page (reverse tabnabbing).
solution: >
Add rel="noopener noreferrer" to every link carrying target="_blank".
references:
- https://cwe.mitre.org/data/definitions/1022.html
risk: LOW
confidence: HIGH
cweId: 1022
wascId: 15
alertTags:
QUALYS_QID: "150222"
status: alpha
`);
}

const ANCHOR_PATTERN = /<(?:a|area)\b[^>]*>/gi;

function attribute(tag, name) {
const match = new RegExp(name + '\\s*=\\s*("([^"]*)"|\'([^\']*)\'|([^\\s">]+))', "i").exec(tag);
if (match === null) {
return null;
}
const value = match[2] !== undefined ? match[2] : (match[3] !== undefined ? match[3] : match[4]);
return value === undefined ? null : value;
}

function hostOf(url) {
const match = /^https?:\/\/([^/?#]+)/i.exec(url);
return match === null ? null : match[1].toLowerCase();
}

function scan(ps, msg, src) {
if (!msg.getResponseHeader().isHtml()) {
return;
}

const host = String(msg.getRequestHeader().getHostName()).toLowerCase();
const body = String(msg.getResponseBody().toString());
const reported = {};

ANCHOR_PATTERN.lastIndex = 0;
let match;
while ((match = ANCHOR_PATTERN.exec(body)) !== null) {
const tag = match[0];

const target = (attribute(tag, "target") || "").toLowerCase();
if (target !== "_blank") {
continue;
}

const rel = (attribute(tag, "rel") || "").toLowerCase();
if (rel.indexOf("noopener") !== -1 || rel.indexOf("noreferrer") !== -1) {
continue;
}

// Relative links stay on the same origin: only absolute links to another host matter.
const href = attribute(tag, "href");
if (href === null) {
continue;
}
const linkHost = hostOf(href);
if (linkHost === null || linkHost === host) {
continue;
}

// One alert per target URL is enough, a menu can repeat the same link many times.
if (reported[href]) {
continue;
}
reported[href] = true;

ps.newAlert()
.setParam(href)
.setEvidence(tag)
.setOtherInfo("Qualys reports this as QID 150222 (CWE-1022).")
.setMessage(msg)
.raise();
}
}
95 changes: 95 additions & 0 deletions dast_scan/scripts/client-side-cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Passive scan rule: cookies written by client-side JavaScript.
//
// A cookie written through document.cookie never appears in a Set-Cookie response header, so
// the native rules 10010 (HttpOnly) and 10011 (Secure) are structurally blind to it - as is any
// scanner that only looks at responses. Qualys sees those cookies because its crawler reads them
// from the browser, and reports them as QID 150122 (no Secure) and QID 150123 (no HttpOnly).
//
// This rule works on what is always observable: the JavaScript actually served. A cookie written
// there can never be HttpOnly, and its Secure attribute depends solely on client code, so it is
// worth an explicit review - and the rule fires whether or not the crawl happened to trigger the
// write, which is what makes it reliable.
//
// Only writes naming the cookie are reported. A bare "document.cookie = someVariable" is the
// plumbing of every cookie library and would alert on each bundle embedding one. A name built
// from a template literal - cookies.set(`${s}`, ...), as shipped by swagger-ui - is the same
// case: what the regex captures is the template, not a cookie name.

const ScanRuleMetadata = Java.type("org.zaproxy.addon.commonlib.scanrules.ScanRuleMetadata");

function getMetadata() {
return ScanRuleMetadata.fromYaml(`
id: 9000003
name: Cookie written by client-side JavaScript
description: >
The served JavaScript writes a cookie from the browser. Such a cookie cannot carry the HttpOnly
attribute (it would break the code reading it back), and its Secure attribute is set by the
client code alone, not by the server: both have to be reviewed in the code rather than assumed.
solution: >
Keep client-side cookies free of any sensitive value, and make sure the code setting them forces
the Secure attribute when the page is served over HTTPS. Move to a server-set cookie whenever the
value does not have to be read by JavaScript.
references:
- https://cwe.mitre.org/data/definitions/1004.html
- https://cwe.mitre.org/data/definitions/614.html
risk: LOW
confidence: HIGH
cweId: 1004
wascId: 13
alertTags:
QUALYS_QID: "150122 / 150123"
status: alpha
`);
}

// cookies.set("name", ...) / $cookies.set('name') / Cookies.set(`name`) - the call sites of the
// usual libraries (vue-cookies, js-cookie...), which survive minification.
const LIBRARY_WRITE_PATTERN = /(?:\$?cookies|cookieStore)\s*\.\s*set\s*\(\s*["'`]([^"'`]+)["'`]/gi;

// document.cookie = "name=..." - the raw write, when the name is a literal.
const RAW_WRITE_PATTERN = /document\s*\.\s*cookie\s*=\s*["'`]([^="'`]+)=/gi;

function isJavaScript(msg) {
const contentType = msg.getResponseHeader().getHeader("Content-Type");
return contentType !== null && String(contentType).toLowerCase().indexOf("javascript") !== -1;
}

// A ${...} left in the captured text means the name is assembled at runtime: nothing to report.
function isDynamic(name) {
return name.indexOf("${") !== -1;
}

function collect(body, pattern, names) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(body)) !== null) {
const name = match[1].trim();
if (name.length > 0 && !isDynamic(name) && names.indexOf(name) === -1) {
names.push(name);
}
}
}

function scan(ps, msg, src) {
if (!msg.getResponseHeader().isHtml() && !isJavaScript(msg)) {
return;
}

const body = String(msg.getResponseBody().toString());
const names = [];
collect(body, LIBRARY_WRITE_PATTERN, names);
collect(body, RAW_WRITE_PATTERN, names);

for (let i = 0; i < names.length; i++) {
ps.newAlert()
.setParam(names[i])
.setEvidence(names[i])
.setOtherInfo(
"Cookie written from JavaScript, so it can carry neither HttpOnly (Qualys QID 150123) "
+ "nor a server-controlled Secure attribute (Qualys QID 150122). Check the code that "
+ "sets it."
)
.setMessage(msg)
.raise();
}
}
Loading
Loading