Vulnerability Management: SCA, SAST & DAST
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.
1. Software Composition Analysis (SCA)
SCA covers vulnerabilities in third-party npm dependencies and the licenses those dependencies are distributed under. Seedocs/DEPENDENCIES.md for how dependencies are
selected and tracked; this section covers what happens once a vulnerability
or license problem is found in one.
1.1 Identification
Findings are surfaced through two automated channels, both required, not optional add-ons:- Dependabot alerts — GitHub’s continuous vulnerability scanning against
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 thesca-scanjob in.github/workflows/ci.ymlon every push and pull request againstmain/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 check —
license-checkerruns in the samesca-scanjob against every production dependency to catch newly introduced disallowed licenses (see §1.4).
1.2 Prioritization & remediation thresholds
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:
Remediation, in order of preference:
- Upgrade the dependency (direct or transitive, via Dependabot PR or
manual
npm install) to a patched version. - Pin via
overridesinpackage.jsonwhen the vulnerable package is a transitive dependency and no direct upgrade path exists yet (seedocs/DEPENDENCIES.md). - Accept the risk, only when neither of the above is possible (e.g. no
fix released yet and the vulnerable code path is unreachable in Questarr’s
usage). Acceptance must be recorded as a
not_affectedstatement 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.
1.3 Pre-release gate
Every release must be clean of unresolved Critical/High SCA findings. This is enforced by an automated status check, not a manual step:- The
sca-scanjob (.github/workflows/ci.yml) runsnode scripts/audit-prod.mjs --audit-level=high(a wrapper aroundnpm audit --omit=devthat honours the VEX feed) on every push and pull request targetingmain/release/*. A Critical or High finding in production dependencies that the VEX feed does not marknot_affectedorfixedfails the job and blocks merge. - The same check runs again as a required job in
.github/workflows/deploy.yml, ahead ofbuild-and-push, so a Docker image cannot be published while a Critical or High vulnerability is present in the dependency tree — independent of whethermainwas already gated at merge time (e.g. a new advisory published after merge, before release). - The only way to release with a known Critical/High finding open is the
documented risk-acceptance path in §1.2 (item 3), which requires a
reviewed
not_affectedstatement in the VEX feed — never a silent--forceor skipped check.
1.4 License policy
Questarr is licensed underGPL-3.0-only (see LICENSE).
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:
- Allowed: MIT, ISC, BSD-2-Clause, BSD-3-Clause, Apache-2.0, CC0-1.0,
0BSD, Python-2.0, BlueOak-1.0.0, OFL-1.1, Unlicense, and copyleft licenses
compatible with distributing Questarr under
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. - Disallowed / requires manual review before use: any license not on the
allow-list, including unrecognized/custom licenses,
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 thelicense-checkerallowlist with maintainer sign-off.
2. Static Application Security Testing (SAST)
SAST covers vulnerabilities in Questarr’s own source code (client/,
server/, shared/), as opposed to third-party dependency code (§1).
2.1 Identification
Two SAST layers run, deliberately kept separate rather than merged into one tool/workflow:- CodeQL analysis runs via GitHub’s default
setup (Settings → Code security → Code scanning), covering JavaScript/
TypeScript (client + server) sources on every pull request, push to
main, and a periodic background schedule GitHub manages automatically. Default setup is deliberately used instead of a customcodeql.ymlworkflow: 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 indocs/SECURITY_ASSESSMENT.md), and default setup requires no workflow-file maintenance as CodeQL/query versions evolve. - Semgrep runs as the
semgrepjob in.github/workflows/sast.ymlon every push and pull request targetingmain/release/*. This is a separate SARIF producer (tool nameSemgrep, notCodeQL), 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.
2.2 Prioritization & remediation thresholds
Findings are triaged by CodeQL’s reported severity (Critical / High / Medium / Low, alert-native “Error”/“Warning”/“Note”
mapped accordingly):
Remediation process, per finding:
- Triage — a maintainer reviews the alert in the Security tab within 5
business days of it appearing, to confirm severity and exploitability in
Questarr’s context (self-hosted, single/few-user app — see
docs/THREAT_MODEL.mdfor the applicable trust model). - Fix — the underlying code is changed to eliminate the flagged pattern (preferred outcome for all severities).
- Dismiss with reason — if a finding is a false positive or genuinely not exploitable given Questarr’s architecture, it is dismissed directly in the Code scanning UI with a reason and comment, not silently ignored. A pattern of dismissals in the same area should prompt a query-suppression review rather than repeated one-off dismissals.
2.3 Pre-merge gate
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:-
The
semgrepjob (.github/workflows/sast.yml) runssemgrep scanwith thep/security-audit,p/secrets,p/owasp-top-ten,p/javascript,p/typescript, andp/reactrulesets, 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, soWARNING/INFOfindings 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 anyERROR-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.: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).
3. Dynamic Application Security Testing (DAST)
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.3.1 Identification
Thezap-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.
3.2 Prioritization & remediation thresholds
ZAP classifies each alert with its ownRisk 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):
Remediation process, per finding, once confirmed exploitable in Questarr’s
context (self-hosted, single/few-user app — see
docs/THREAT_MODEL.md):
- Fix — change the code/config that causes the alert (preferred outcome for all severities).
- Accept as a false positive / non-exploitable — record the reasoning
in the PR or a
docs/SECURITY_ASSESSMENT.mdrisk-register entry if the alert recurs across scans, rather than letting it sit unexplained in the artifact scan after scan.
3.3 First scan and current enforcement status
The first completeddast.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:
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.
3.4 Planned follow-up
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).