diff --git a/api/source/package-lock.json b/api/source/package-lock.json index 71711db01..f48ee26ec 100644 --- a/api/source/package-lock.json +++ b/api/source/package-lock.json @@ -1,12 +1,12 @@ { "name": "stig-management-api", - "version": "1.6.8", + "version": "1.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stig-management-api", - "version": "1.6.8", + "version": "1.6.9", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/api/source/package.json b/api/source/package.json index 6d9d2a81e..a732c6e03 100644 --- a/api/source/package.json +++ b/api/source/package.json @@ -1,6 +1,6 @@ { "name": "stig-management-api", - "version": "1.6.8", + "version": "1.6.9", "description": "An API for managing evaluations of Security Technical Implementation Guide (STIG) assessments.", "main": "index.js", "scripts": { diff --git a/release-notes.rst b/release-notes.rst index 6d3d405e5..2f07d83e2 100644 --- a/release-notes.rst +++ b/release-notes.rst @@ -1,3 +1,21 @@ +1.6.9 +------- + +Changes: + + - (API) Added guard to prevent elevated requests from modifying collection ``settings``, ``labels``, or ``metadata`` on create/replace/update + - (API) Simplified asset collection retrieval in controllers + - (API) Refactored JWKS cache error logging + - (API) Replaced direct string interpolation in SQL query construction with parameterized binds in MetricsService and JobService + - (UI) New ``STIGMAN_CLIENT_CONSOLE_MODE`` environment variable to suppress console output in non-development environments + - (UI) Various escaping and DOM insertion improvements across multiple SM components + - (UI) Updated OIDC worker initialization + - (Docs) Clarified data and permissions documentation for elevated actions + - (Tests) Added regression tests for cross-collection write access; updated test utilities and collection test fixtures to align with API behavior + - (Dependencies) Update ``fast-xml-parser`` to v5.7.1 and remove the ``uuid`` runtime dependency from the API + - (Dependencies) Update ``@nuwcdivnpt/stig-manager-client-modules`` to v1.6.7 + - (Dependencies) Various security and maintenance updates + 1.6.8 ------- diff --git a/test/state/mocha/bootstrap.test.js b/test/state/mocha/bootstrap.test.js index 4170dbeb7..9325ac365 100644 --- a/test/state/mocha/bootstrap.test.js +++ b/test/state/mocha/bootstrap.test.js @@ -21,8 +21,9 @@ describe('Boot with no dependencies', function () { }) after(async function () { - await api.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('GET /op/state', function () { @@ -113,10 +114,11 @@ describe('Boot with both dependencies', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('GET /op/state', function () { @@ -127,7 +129,7 @@ describe('Boot with both dependencies', function () { expect(res.body.dependencies).to.eql({db: true, oidc: true}) }) }) - + describe('GET /op/configuration', function () { it('should return 200 when dependencies are available', async function () { const res = await simpleRequest(`${apiOrigin}/api/op/configuration`) @@ -189,10 +191,11 @@ describe('Boot with old mysql', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('exit code', function () { @@ -249,10 +252,11 @@ describe('Boot with insecure kid - allow insecure tokens false', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('exit code', function () { @@ -312,10 +316,11 @@ describe('Boot without insecure kid - request with insecure token' , function () }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('GET /op/state', function () { @@ -361,10 +366,11 @@ describe('Boot with STIGMAN_JWKS_CACHE_MAX_AGE out of range', function () { }) after(async function () { - await mysql.stop() - await oidc.stop() - await api.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) await api.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('Mimimum value enforced', function () { @@ -382,7 +388,8 @@ describe('Boot with STIGMAN_JWKS_CACHE_MAX_AGE out of range', function () { }) }) after(async function () { - await api.stop() + this.timeout(60000) + if (api) await api.stop().catch(() => {}) }) it('should return minimum oauth.maxCacheAge (1)', async function () { const configLog = api.logRecords.filter(r => r.type === 'configuration')[0] @@ -405,7 +412,8 @@ describe('Boot with STIGMAN_JWKS_CACHE_MAX_AGE out of range', function () { }) }) after(async function () { - await api.stop() + this.timeout(60000) + if (api) await api.stop().catch(() => {}) }) it('should return maximum oauth.maxCacheAge (35791)', async function () { const configLog = api.logRecords.filter(r => r.type === 'configuration')[0] @@ -428,7 +436,8 @@ describe('Boot with STIGMAN_JWKS_CACHE_MAX_AGE out of range', function () { }) }) after(async function () { - await api.stop() + this.timeout(60000) + if (api) await api.stop().catch(() => {}) }) it('should return default oauth.maxCacheAge (10)', async function () { const configLog = api.logRecords.filter(r => r.type === 'configuration')[0] diff --git a/test/state/mocha/db.test.js b/test/state/mocha/db.test.js index a611c439a..ce4be4bab 100644 --- a/test/state/mocha/db.test.js +++ b/test/state/mocha/db.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { getPorts, spawnApiPromise, spawnMySQL, simpleRequest, execIpTables } from './lib.js' +import { getPorts, spawnApiPromise, spawnMySQL, simpleRequest, execIpTables, waitForLog } from './lib.js' import MockOidc from '../../utils/mockOidc.js' import addContext from 'mochawesome/addContext.js' @@ -10,16 +10,7 @@ describe('DB outage: shutdown', function () { let mysql let oidc - async function waitLogEvent(type, count = 1) { - let seen = 0 - return new Promise((resolve) => { - api.logEvents.on(type, function (log) { - seen++ - if (seen >= count) resolve(log) - }) - }) - } - + before(async function () { this.timeout(60000) oidc = new MockOidc({keyCount: 1, includeInsecureKid: false}) @@ -47,10 +38,11 @@ describe('DB outage: shutdown', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('DB up', function () { @@ -64,8 +56,10 @@ describe('DB outage: shutdown', function () { }) describe('DB shutdown', function () { + let logMark before(async function () { this.timeout(30000) + logMark = api.logRecords.length await mysql.stop() console.log(' mysql shutdown') }) @@ -74,21 +68,23 @@ describe('DB outage: shutdown', function () { const res = await simpleRequest(`${apiOrigin}/api/op/state`) expect(res.status).to.equal(200) expect(res.body.currentState).to.equal('unavailable') - expect(res.body.dependencies).to.eql({db: false, oidc: true}) + expect(res.body.dependencies).to.eql({db: false, oidc: true}) }) it('should log retry fail', async function () { this.timeout(30000) console.log(' wait for log: restore (2)') - const log = await waitLogEvent('restore', 2) + const log = await waitForLog(api, 'restore', {count: 2, since: logMark}) expect(log.data.message).to.equal(`connect ECONNREFUSED 127.0.0.1:${dbPort}`) }) }) describe('DB restarted', function() { + let logMark before( async function() { this.timeout(30000) console.log(' try mysql restart') + logMark = api.logRecords.length mysql = await spawnMySQL({tag: '8.0.24', port: dbPort}) console.log(' ✔ mysql restarted') }) @@ -96,7 +92,7 @@ describe('DB outage: shutdown', function () { it('should return state "available"', async function () { this.timeout(60000) console.log(' wait for log: state-changed') - const log = await waitLogEvent('state-changed') + const log = await waitForLog(api, 'state-changed', {since: logMark}) expect(log.data.currentState).to.equal('available') expect(log.data.previousState).to.equal('unavailable') const res = await simpleRequest(`${apiOrigin}/api/op/state`) @@ -112,16 +108,6 @@ describe('DB outage: network/host down', function () { let mysql let oidc - async function waitLogEvent(type, count = 1) { - let seen = 0 - return new Promise((resolve) => { - api.logEvents.on(type, function (log) { - seen++ - if (seen >= count) resolve(log) - }) - }) - } - before(async function () { this.timeout(60000) oidc = new MockOidc({keyCount: 1, includeInsecureKid: false}) @@ -149,10 +135,11 @@ describe('DB outage: network/host down', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('Network/host up', function () { @@ -166,33 +153,37 @@ describe('DB outage: network/host down', function () { }) describe('Network/host down', function () { + let logMark before(async function () { + logMark = api.logRecords.length execIpTables(`-A OUTPUT -p tcp --dport ${dbPort} -j DROP`) console.log(' iptables dropping packets') }) it('should return state "unavailable"', async function () { this.timeout(30000) console.log(' wait for log: state-changed') - const log = await waitLogEvent('state-changed') + const log = await waitForLog(api, 'state-changed', {since: logMark}) expect(log.data.currentState).to.equal('unavailable') expect(log.data.previousState).to.equal('available') const res = await simpleRequest(`${apiOrigin}/api/op/state`) expect(res.status).to.equal(200) expect(res.body.currentState).to.equal('unavailable') - expect(res.body.dependencies).to.eql({db: false, oidc: true}) + expect(res.body.dependencies).to.eql({db: false, oidc: true}) }) it('should log retry fail', async function () { this.timeout(45000) console.log(' wait for log: restore (2)') - const log = await waitLogEvent('restore', 2) + const log = await waitForLog(api, 'restore', {count: 2, since: logMark}) expect(log.data.message).to.equal('connect ETIMEDOUT') }) }) describe('Network/host up', function() { + let logMark before( async function() { this.timeout(30000) + logMark = api.logRecords.length execIpTables(`-D OUTPUT -p tcp --dport ${dbPort} -j DROP`) console.log(' iptables accepting packets') }) @@ -200,7 +191,7 @@ describe('DB outage: network/host down', function () { it('should return state "available"', async function () { this.timeout(60000) console.log(' wait for log: state-changed') - const log = await waitLogEvent('state-changed') + const log = await waitForLog(api, 'state-changed', {since: logMark}) expect(log.data.currentState).to.equal('available') expect(log.data.previousState).to.equal('unavailable') const res = await simpleRequest(`${apiOrigin}/api/op/state`) diff --git a/test/state/mocha/jwks.test.js b/test/state/mocha/jwks.test.js index 113abb8e2..c429348d1 100644 --- a/test/state/mocha/jwks.test.js +++ b/test/state/mocha/jwks.test.js @@ -15,7 +15,7 @@ describe('JWKS Tests', function () { const {apiPort, dbPort, oidcPort, apiOrigin, oidcOrigin} = getPorts(54020) before(async function () { - this.timeout(30000) + this.timeout(60000) oidc = new MockOidc({keyCount: 1, includeInsecureKid: false}) tokens.rotation0 = oidc.getToken({username: 'prerotation', privileges:['create_collection']}) // default privileges oidc.rotateKeys({keyCount: 1, includeInsecureKid: false}) @@ -46,10 +46,11 @@ describe('JWKS Tests', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('Create user according to token', function () { diff --git a/test/state/mocha/lib.js b/test/state/mocha/lib.js index 73302ecb4..6baceedc3 100644 --- a/test/state/mocha/lib.js +++ b/test/state/mocha/lib.js @@ -113,6 +113,35 @@ export function spawnApi ({ } } +/** + * Resolves with a log record matching `type` once `count` such records have + * been seen at index >= `since` in `api.logRecords`. Race-safe: counts past + * records so the helper still resolves if the trigger fired before the caller + * awaited. Capture `api.logRecords.length` into `since` *before* triggering + * the action that produces the event(s). + * @param {Object} api - Result of spawnApiPromise; must have logRecords + logEvents. + * @param {string} type - The log record type to wait for. + * @param {Object} [opts] + * @param {number} [opts.count=1] - Number of matching records before resolving. + * @param {number} [opts.since=0] - Index in api.logRecords at which to start counting. + * @param {(log: Object) => boolean} [opts.predicate] - Optional filter applied to each record. + * @returns {Promise} Resolves with the count-th matching log record. + */ +export function waitForLog (api, type, { count = 1, since = 0, predicate = null } = {}) { + const past = api.logRecords.slice(since).filter(r => + r.type === type && (!predicate || predicate(r)) + ) + if (past.length >= count) return Promise.resolve(past[count - 1]) + let seen = past.length + return new Promise((resolve) => { + api.logEvents.on(type, (log) => { + if (predicate && !predicate(log)) return + seen++ + if (seen >= count) resolve(log) + }) + }) +} + /** * Waits for a child process to close. * @param {ChildProcess} child - The child process to wait for. diff --git a/test/state/mocha/oidc.test.js b/test/state/mocha/oidc.test.js index 9d65bead2..15b2dafca 100644 --- a/test/state/mocha/oidc.test.js +++ b/test/state/mocha/oidc.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { getPorts, spawnApiPromise, spawnMySQL, simpleRequest } from './lib.js' +import { getPorts, spawnApiPromise, spawnMySQL, simpleRequest, waitForLog } from './lib.js' import MockOidc from '../../utils/mockOidc.js' import addContext from 'mochawesome/addContext.js' @@ -11,17 +11,7 @@ describe('OIDC state', function () { let cachedKid const {apiPort, dbPort, oidcPort, apiOrigin, oidcOrigin} = getPorts(54030) - - async function waitLogType(type, count = 1) { - let seen = 0 - return new Promise((resolve) => { - api.logEvents.on(type, function (log) { - seen++ - if (seen >= count) resolve(log) - }) - }) - } - + before(async function () { this.timeout(60000) oidc = new MockOidc({keyCount: 1, includeInsecureKid: false}) @@ -49,17 +39,18 @@ describe('OIDC state', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) describe('OIDC up', function () { it('should log cacheUpdate with 1 kid', async function () { this.timeout(20000) console.log(' wait for log: jwksCacheEvent/cacheUpdate') - const log = await waitLogType('jwksCacheEvent') + const log = await waitForLog(api, 'jwksCacheEvent') expect(log.data.event).to.equal('cacheUpdate') const kids = Object.keys(log.data.kids) cachedKid = kids[0] @@ -67,7 +58,7 @@ describe('OIDC state', function () { }) it('should return state "available"', async function () { this.timeout(20000) - await waitLogType('started') + await waitForLog(api, 'started') const res = await simpleRequest(`${apiOrigin}/api/op/state`) expect(res.status).to.equal(200) expect(res.body.currentState).to.equal('available') @@ -76,40 +67,44 @@ describe('OIDC state', function () { }) describe('OIDC down', function () { + let logMark before(async function () { + logMark = api.logRecords.length await oidc.stop() console.log(' oidc shutdown') }) it('should log cache update attempt', async function () { this.timeout(45000) console.log(' wait for log: refreshing cache') - const log = await waitLogType('refreshing cache') + const log = await waitForLog(api, 'refreshing cache', {since: logMark}) expect(log.data.uri).to.equal(`${oidcOrigin}/jwks`) }) it('should log refresh error', async function () { this.timeout(15000) console.log(' wait for log: refresh error') - const log = await waitLogType('refresh error') + const log = await waitForLog(api, 'refresh error', {since: logMark}) expect(log.data.message).to.equal('updateCache returned false') }) it('should return state "unavailable"', async function () { this.timeout(75000) console.log(' wait for log: state-changed') - const log = await waitLogType('state-changed') + const log = await waitForLog(api, 'state-changed', {since: logMark}) expect(log.data.currentState).to.equal('unavailable') expect(log.data.previousState).to.equal('available') }) }) describe('OIDC restarted', function () { + let logMark before(async function () { + logMark = api.logRecords.length await oidc.start({port: oidcPort}) console.log(' ✔ oidc started') }) it('should log cacheUpdate with same kid as bootstrap', async function () { this.timeout(20000) console.log(' wait for log: jwksCacheEvent/cacheUpdate') - const log = await waitLogType('jwksCacheEvent') + const log = await waitForLog(api, 'jwksCacheEvent', {since: logMark}) expect(log.data.event).to.equal('cacheUpdate') const kids = Object.keys(log.data.kids) expect(kids).to.have.lengthOf(1) @@ -120,19 +115,21 @@ describe('OIDC state', function () { const res = await simpleRequest(`${apiOrigin}/api/op/state`) expect(res.status).to.equal(200) expect(res.body.currentState).to.equal('available') - expect(res.body.dependencies).to.eql({db: true, oidc: true}) + expect(res.body.dependencies).to.eql({db: true, oidc: true}) }) }) describe('OIDC rekeyed', function () { + let logMark before(async function () { + logMark = api.logRecords.length await oidc.rotateKeys({keyCount: 1, includeInsecureKid: false}) console.log(' ✔ oidc rekeyed') }) it('should log cacheUpdate with different kid than bootstrap', async function () { this.timeout(40000) console.log(' wait for log: jwksCacheEvent/cacheUpdate') - const log = await waitLogType('jwksCacheEvent') + const log = await waitForLog(api, 'jwksCacheEvent', {since: logMark}) expect(log.data.event).to.equal('cacheUpdate') const kids = Object.keys(log.data.kids) expect(kids).to.have.lengthOf(1) diff --git a/test/state/mocha/tokenValidation.test.js b/test/state/mocha/tokenValidation.test.js index ce3e9899f..141a8a6b1 100644 --- a/test/state/mocha/tokenValidation.test.js +++ b/test/state/mocha/tokenValidation.test.js @@ -41,10 +41,11 @@ describe('Token validation', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) it('should accept token having correct audience (string)', async function () { @@ -139,10 +140,11 @@ describe('Token validation', function () { }) after(async function () { - await api.stop() - await mysql.stop() - await oidc.stop() - addContext(this, {title: 'api-log', value: api.logRecords}) + this.timeout(60000) + if (api) await api.stop().catch(() => {}) + if (mysql) await mysql.stop().catch(() => {}) + if (oidc) await oidc.stop().catch(() => {}) + if (api) addContext(this, {title: 'api-log', value: api.logRecords}) }) it('should accept top-level scope "stig-manager"', async function () { diff --git a/test/utils/mockOidc.js b/test/utils/mockOidc.js index 4a3fc363d..f0d979d68 100644 --- a/test/utils/mockOidc.js +++ b/test/utils/mockOidc.js @@ -724,20 +724,16 @@ class MockOidc { } stop () { + // Order matters: drop active connections first, then await close() so the + // returned promise doesn't resolve until the listening socket has been + // released. Resolving early lets a follow-up start() on the same port + // race with kernel teardown and intermittently throw EADDRINUSE. return new Promise((resolve, reject) => { - if (this.server) { - this.server.close((err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - this.server.closeAllConnections() - this.server = null - } else { - resolve() - } + if (!this.server) return resolve() + const server = this.server + this.server = null + server.closeAllConnections() + server.close((err) => err ? reject(err) : resolve()) }) } }