This document defines Questarr’s policy for handling findings from Software Composition Analysis (SCA — third-party dependency vulnerabilities and licenses), Static Application Security Testing (SAST — first-party code analysis), and Dynamic Application Security Testing (DAST — vulnerabilities observed by exercising a running instance of the app). It covers how findings are identified, prioritized, remediated, and enforced before release.
This is a policy document, distinct from two related documents:
docs/SECURITY_ASSESSMENT.md — the risk
register of specific, already-known architectural/design risks.docs/SECURITY.md — vulnerability
disclosure process and deployment hardening guide.This document instead answers: when an SCA, SAST, or DAST tool reports a new finding, what happens next, and how fast.
SCA covers vulnerabilities in third-party npm dependencies and the licenses
those dependencies are distributed under. See
docs/DEPENDENCIES.md for how dependencies are
selected and tracked; this section covers what happens once a vulnerability
or license problem is found in one.
Findings are surfaced through two automated channels, both required, not optional add-ons:
package-lock.json, configured in
.github/dependabot.yml. Runs continuously
and on every dependency-graph update, in addition to the weekly update PRs.npm audit — run as the sca-scan job in
.github/workflows/ci.yml on every push and
pull request against main/release/*, and again as a release gate in
.github/workflows/deploy.yml (see
§1.3). This catches vulnerabilities in the exact resolved dependency tree
at build time, independent of Dependabot’s scan cadence.license-checker runs in the same sca-scan job
against every production dependency to catch newly introduced disallowed
licenses (see §1.4).Vulnerability findings are triaged by severity (npm audit / GitHub Advisory
Database CVSS rating, Critical / High / Moderate / Low). Each
severity has a maximum time-to-remediation, measured from when the finding
first appears:
| Severity | Remediation SLA | Notes |
|---|---|---|
| Critical | 7 days | Patch, upgrade, or apply a documented overrides pin immediately; treat as release-blocking (§1.3). |
| High | 30 days | Same remediation paths as Critical; release-blocking (§1.3). |
| Moderate | 90 days | Bundled into the next routine Dependabot update cycle unless actively exploited, in which case treat as High. |
| Low | Next scheduled dependency update | No dedicated action required; fixed opportunistically via Dependabot’s weekly PRs. |
Remediation, in order of preference:
npm install) to a patched version.overrides in package.json when the vulnerable package is a
transitive dependency and no direct upgrade path exists yet (see
docs/DEPENDENCIES.md).not_affected statement in the
VEX feed (security/vex/questarr.openvex.json) with a
justification tied to the actual code path, so it’s visible and revisited
rather than silently suppressed.Every release must be clean of unresolved Critical/High SCA findings. This is enforced by an automated status check, not a manual step:
sca-scan job (.github/workflows/ci.yml) runs
node scripts/audit-prod.mjs --audit-level=high (a wrapper around
npm audit --omit=dev that honours the VEX feed) on every push and pull
request targeting main/release/*. A Critical or High finding in
production dependencies that the VEX feed does not mark not_affected or
fixed fails the job and blocks merge..github/workflows/deploy.yml, ahead of
build-and-push, so a Docker image cannot be published while a Critical or
High vulnerability is present in the dependency tree — independent of
whether main was already gated at merge time (e.g. a new advisory
published after merge, before release).not_affected statement in the VEX feed — never a silent
--force or skipped check.Moderate and Low findings do not block release.
Questarr is licensed under GPL-3.0-only (see COPYING).
Dependency licenses are checked by the sca-scan CI job using
license-checker against an explicit allow-list; anything outside it fails
the build and must be triaged before merge:
GPL-3.0-only (GPL-3.0,
LGPL-2.1, LGPL-3.0, MPL-2.0). GPLv2-only is deliberately excluded: a
GPLv2-only dependency isn’t combinable into a GPLv3-only distribution
unless it’s dual-licensed or carries an “or later” clause, so it needs a
reviewed exception rather than a blanket allow.UNLICENSED,
source-available-but-restricted licenses (e.g. Commons Clause, BUSL,
SSPL), and packages with no declared license. A disallowed license found
in a new or updated dependency blocks the introducing PR until the
dependency is replaced or an explicit, documented exception is added to
the license-checker allowlist with maintainer sign-off.SAST covers vulnerabilities in Questarr’s own source code (client/,
server/, shared/), as opposed to third-party dependency code (§1).
Two SAST layers run, deliberately kept separate rather than merged into one tool/workflow:
main, and a periodic background schedule GitHub manages automatically.
Default setup is deliberately used instead of a custom codeql.yml
workflow: GitHub does not allow a repository to run both at once (SARIF
uploads from a custom/”advanced” CodeQL workflow are rejected outright
while default setup is enabled — see the note in
docs/SECURITY_ASSESSMENT.md), and
default setup requires no workflow-file maintenance as CodeQL/query
versions evolve.semgrep job in
.github/workflows/sast.yml on every push
and pull request targeting main/release/*. This is a separate SARIF
producer (tool name Semgrep, not CodeQL), so it doesn’t collide with
CodeQL default setup, and it exists specifically to give SAST a per-PR
pass/fail signal (§2.3) that default setup alone cannot provide.Both upload their SARIF output to the repository’s Security → Code scanning alerts tab, which is the source of truth for open SAST findings regardless of which tool produced them.
Findings are triaged by CodeQL’s reported severity
(Critical / High / Medium / Low, alert-native “Error”/”Warning”/”Note”
mapped accordingly):
| Severity | Remediation SLA | Notes |
|---|---|---|
| Critical | 7 days | Fix or explicitly suppress with justification (see below); treat as release-blocking. |
| High | 30 days | Same as Critical. |
| Medium | 90 days | Scheduled into normal development; re-evaluate severity if exploitability changes. |
| Low | Best-effort, no fixed deadline | Track in the code scanning tab; fix opportunistically. |
Remediation process, per finding:
docs/THREAT_MODEL.md for the applicable trust
model).CodeQL’s default setup (§2.1) intentionally has no per-PR blocking signal — alert triage there follows the SLA table above and periodic maintainer review, since it requires human judgment about exploitability that a hard gate would short-circuit.
Semgrep closes that gap with an actual merge-blocking check:
semgrep job (.github/workflows/sast.yml)
runs semgrep scan with the p/security-audit, p/secrets,
p/owasp-top-ten, p/javascript, p/typescript, and p/react rulesets,
unfiltered by severity, writing both a JSON and a SARIF copy of every
finding. The SARIF copy is uploaded to the Code scanning tab as-is, so
WARNING/INFO findings stay visible there (matching the Medium/Low rows
in §2.2) instead of being dropped from the scan entirely. A separate step
then reads the JSON output and fails the job — blocking merge — if any
ERROR-severity finding is present; this dedicated step is the required
status check for OSPS-VM-06.02, kept independent of the scan step so the
blocking condition can never silently filter what reaches Code Scanning.Suppression (declaring a finding non-exploitable): a blocking finding
may be suppressed only via an inline // nosemgrep: <rule-id> comment on
the line immediately preceding the flagged code (or on the same line —
Semgrep does not recognize the directive anywhere else), with a mandatory
trailing justification, e.g.:
// nosemgrep: javascript.lang.security.audit.path-traversal -- path is validated against an allow-list in server/middleware.ts:293 before this line
A suppression without a stated reason is not acceptable and should be rejected in review. Suppressions are visible in the diff, so they go through the same PR review as any other change — no separate approval channel exists for dismissing a finding, unlike CodeQL’s Code Scanning UI dismissal path (§2.2, item 3).
DAST covers vulnerabilities observed by exercising a running instance of Questarr, as opposed to reading its source (§2) or its dependency tree (§1). This is a newer control than §1/§2 — see §3.4 for what’s not yet built out.
The zap-baseline job in
.github/workflows/dast.yml runs an
OWASP ZAP baseline scan against the production
build on every push to main/release/*, weekly, and on manual dispatch
(see the dynamic_analysis evidence for how the
target is booted). Each run’s HTML/JSON/MD report is uploaded as the
zap-baseline-report workflow artifact.
ZAP classifies each alert with its own Risk rating (High / Medium /
Low / Informational), which this policy treats as the CVSS-qualitative
equivalent for triage purposes (High ≈ CVSS High/Critical, Medium ≈ CVSS
Medium, satisfying the “medium or higher” threshold in
dynamic_analysis_fixed):
| Risk | Remediation SLA | Notes |
|---|---|---|
| High | 30 days | Fix the underlying cause (code/config change); treat as release-blocking once §3.4’s gate lands. |
| Medium | 90 days | Same triage rigor as High; scheduled into normal development. |
| Low | Best-effort, no fixed deadline | Track via the artifact; fix opportunistically. |
| Informational | No deadline | Reviewed for context, not treated as a vulnerability on its own. |
Remediation process, per finding, once confirmed exploitable in Questarr’s
context (self-hosted, single/few-user app — see
docs/THREAT_MODEL.md):
docs/SECURITY_ASSESSMENT.md risk-register entry if the
alert recurs across scans, rather than letting it sit unexplained in the
artifact scan after scan.The first completed dast.yml run (2026-07-14, triggered by the merge that
introduced this workflow) found 0 FAIL-level (High) alerts and 5
WARN-level alerts, all Low or Informational under ZAP’s own default risk
ratings except one Medium (CSP: Wildcard Directive). Per §3.2’s SLA that
Medium finding was fixed immediately rather than run out its 90-day window:
| Rule ID | Alert | Risk | Disposition |
|---|---|---|---|
| 10055 | CSP: Wildcard Directive | Medium | Fixed. Helmet’s default font-src/style-src allow any https: origin; Questarr’s fonts/styles are all self-hosted, so both are narrowed to 'self' (plus data: for fonts, 'unsafe-inline' kept for styles since several components render inline style attributes) (server/routes.ts:360-361). |
| 10063 | Permissions Policy Header Not Set | Low | Fixed. Helmet dropped Permissions-Policy support; added directly (server/routes.ts:371-377), denying camera/microphone/geolocation/payment/USB/interest-cohort. |
| 10049 | Storable but Non-Cacheable Content | Low | Accepted — static SPA assets served with standard cache headers, not exploitable. .zap/rules.tsv. |
| 10109 | Modern Web Application | Info | Accepted — informational-only, not a vulnerability. .zap/rules.tsv. |
| 90004 | Cross-Origin-Embedder-Policy Header Missing | Low | Accepted — enabling COEP would break cross-origin IGDB/NexusMods image loading (see docs/SECURITY_ASSESSMENT.md risk register). .zap/rules.tsv. |
With that triage done, dast.yml now runs with fail_action: true and
rules_file_name: .zap/rules.tsv — the three accepted findings above are
downgraded to IGNORE (with the reasoning inline in that file) so they don’t
recur as false failures, while any new WARN or FAIL alert still fails the
build, matching the blocking behavior §1.3 and §2.3 already have for SCA/SAST.
The gate immediately proved itself: the next two scans (2026-07-14/15,
triggered by the merges that landed the fixes above and a follow-up test
refactor) both failed on 10055 — CSP: style-src unsafe-inline. Rule 10055
covers more than the wildcard-directive check fixed above; style-src still
carries 'unsafe-inline' (kept deliberately — several components render real
inline style="..." attributes, confirmed empirically with a Playwright
script that removed 'unsafe-inline' and captured securitypolicyviolation
events: Radix’s Drawer component injects a <style> block that fails to
apply without it, not just a theoretical concern). Removing it would break
rendering, not just tighten policy, per the same reasoning already recorded
for script-src and font-src/style-src above. This is a Low-risk,
already-reviewed tradeoff, not a new confirmed vulnerability, so it’s added
to .zap/rules.tsv as IGNORE rather than reversing the
server/routes.ts decision.
Known limitation of this fix — read before adding another entry here:
zap-baseline.py’s -c config file (and the zaproxy/action-baseline
action’s own report post-processing) both key IGNORE/WARN/FAIL purely
by ZAP plugin ID, with no way to distinguish individual alert messages under
the same plugin. 10055 IGNORE therefore also silently suppresses the
CSP: Wildcard Directive alert already fixed above — if that regressed (e.g.
a future change reintroduced a bare https: scheme into any directive), the
DAST gate would no longer catch it. Rather than leave that gap unaddressed or
take on the much larger effort of eliminating 'unsafe-inline' entirely
(replacing every inline style prop with a CSS-custom-property or nonce-based
approach — out of scope here), the wildcard-directive check now has a
dedicated compensating control: server/__tests__/security.test.ts asserts
directly that no CSP directive contains a bare https:/http:/ftp: scheme
token, verified to both pass on the current config and fail if the wildcard
is reintroduced. This is arguably more precise regression coverage than ZAP’s
plugin-level grouping would have given anyway.
Kept here as a visible record that the newly-enabled gate does catch real signal — it isn’t a rubber stamp — and of the concrete limitation found and compensated for in the process.
One gap remains: allow_issue_writing is still false, so a finding is only
visible in the zap-baseline-report artifact of the run that produced it,
rather than getting a persistent GitHub issue the way Code Scanning provides
for SAST/SCA (§1, §2). Turn it on once there’s a reason to track a DAST
finding across multiple runs instead of fixing/accepting it immediately (as
happened with the first scan above).