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
39 changes: 39 additions & 0 deletions app/api/definitions/components/release-tracks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ components:
type: string
format: date-time
description: 'Version pin: the modified timestamp of this object version'
attack_id:
type: string
description: 'ATT&CK ID, if found'
example: 'T1234'
name:
type: string
description: 'Object name, if found'
description:
type: string
description: 'Object description, if found'
modified_by_user:
type: object
description: 'User who last modified this object version, if found'
properties:
id:
type: string
description: 'User ID'
username:
type: string
description: 'Username'
displayName:
type: string
description: 'Display name'
name:
type: string
description: 'Display name, or username if display name is missing'

candidate-entry:
allOf:
Expand Down Expand Up @@ -302,6 +328,19 @@ components:
tagged_release_count:
type: number
description: 'Number of tagged releases'
summary:
type: object
description: 'Counts of objects in each release track tier for the latest snapshot'
properties:
members_count:
type: number
description: 'Number of objects in the members tier'
staged_count:
type: number
description: 'Number of objects in the staged tier'
candidates_count:
type: number
description: 'Number of objects in the candidates tier'
created_at:
type: string
format: date-time
Expand Down
39 changes: 24 additions & 15 deletions app/api/definitions/paths/release-tracks-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ paths:
example: 'enterprise-attack'
- name: format
in: query
description: 'Output format (bundle, workbench, or filesystem-store)'
description: 'Output format. filesystemstore is not yet implemented.'
schema:
type: string
enum:
- bundle
- workbench
- filesystem-store
- filesystemstore
default: bundle
responses:
'200':
description: 'Ephemeral bundle generated successfully'
'501':
description: 'Not yet implemented'
description: 'Requested format is not yet implemented'

# =============================================================================
# Track management
Expand Down Expand Up @@ -154,7 +154,8 @@ paths:
operationId: 'release-tracks-get-latest'
description: |
Retrieve the most recent snapshot for a release track.
By default returns only members; use include query param for other tiers.
By default returns the Workbench snapshot shape with all tiers present.
Use the include query parameter to narrow tier arrays when desired.
tags:
- 'Release Tracks'
parameters:
Expand All @@ -174,19 +175,19 @@ paths:
- members
- staged
- candidates
- quarantine
- all
default: members
default: all
- name: format
in: query
description: 'Output format: snapshot (raw), bundle (STIX 2.1), workbench (with metadata), filesystemstore (not implemented)'
description: 'Output format. filesystemstore is not yet implemented.'
schema:
type: string
enum:
- snapshot
- bundle
- workbench
- filesystemstore
default: snapshot
default: workbench
responses:
'200':
description: 'Latest snapshot retrieved successfully'
Expand All @@ -196,6 +197,8 @@ paths:
$ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot'
'404':
description: 'Release track not found'
'501':
description: 'Requested format is not yet implemented'

delete:
summary: 'Delete a release track'
Expand Down Expand Up @@ -326,17 +329,19 @@ paths:
type: string
- name: format
in: query
description: 'Output format. filesystemstore is not yet implemented.'
schema:
type: string
enum:
- summary
- detailed
default: summary
- bundle
- workbench
- filesystemstore
default: workbench
responses:
'200':
description: 'Release preview generated'
'501':
description: 'Not yet implemented'
description: 'Requested format is not yet implemented'

# =============================================================================
# Candidate management
Expand Down Expand Up @@ -728,22 +733,26 @@ paths:
- members
- staged
- candidates
- quarantine
- all
default: members
default: all
- name: format
in: query
description: 'Output format. filesystemstore is not yet implemented.'
schema:
type: string
enum:
- snapshot
- bundle
- filesystemstore
- workbench
default: snapshot
default: workbench
responses:
'200':
description: 'Snapshot retrieved successfully'
'404':
description: 'Snapshot not found'
'501':
description: 'Requested format is not yet implemented'

delete:
summary: 'Delete a specific snapshot'
Expand Down
62 changes: 51 additions & 11 deletions app/controllers/release-tracks-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,31 @@ function parseOptionalQuery(value, schema, defaultValue) {
return result.success ? result.data : defaultValue;
}

function parseOptionalQueryStrict(value, schema, defaultValue, parameterName) {
if (value === undefined || value === null) return defaultValue;
const result = schema.safeParse(value);
if (result.success) return result.data;

throw new InvalidQueryStringParameterError({
parameterName,
message: `Invalid ${parameterName} parameter`,
});
}

function rejectFilesystemStoreFormat(format, methodName) {
if (format !== 'filesystemstore') return null;

return new NotImplementedError('release-tracks-controller', methodName, {
message: 'The filesystemstore format is not yet implemented',
});
}

/**
* Parse common query parameters shared across GET snapshot endpoints.
*/
function parseSnapshotQueryParams(query) {
return {
format: parseOptionalQuery(query.format, formatQuerySchema, 'snapshot'),
format: parseOptionalQueryStrict(query.format, formatQuerySchema, 'workbench', 'format'),
include: parseOptionalQuery(query.include, includeQuerySchema, undefined),
releases: query.releases === 'only' ? 'only' : undefined,
version: parseOptionalQuery(query.version, xMitreVersionSchema, undefined),
Expand All @@ -88,7 +107,17 @@ exports.retrieveEphemeralByDomain = async function retrieveEphemeralByDomain(req
);
}

const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'bundle');
const format = parseOptionalQueryStrict(
req.query.format,
formatQuerySchema,
'bundle',
'format',
);
const formatError = rejectFilesystemStoreFormat(format, 'retrieveEphemeralByDomain');
if (formatError) {
return next(formatError);
}

const result = await releaseTracksService.getEphemeralBundle(domainResult.data, format);
logger.debug(`Success: Retrieved ephemeral ${domainResult.data} bundle`);
return res.status(200).send(result);
Expand Down Expand Up @@ -182,14 +211,9 @@ exports.importReleaseTrack = async function importReleaseTrack(_req, _res, next)
exports.retrieveLatestSnapshot = async function retrieveLatestSnapshot(req, res, next) {
try {
const queryOptions = parseSnapshotQueryParams(req.query);

// filesystemstore format is not yet implemented
if (queryOptions.format === 'filesystemstore') {
return next(
new NotImplementedError('release-tracks-controller', 'retrieveLatestSnapshot', {
message: 'The filesystemstore format is not yet implemented',
}),
);
const formatError = rejectFilesystemStoreFormat(queryOptions.format, 'retrieveLatestSnapshot');
if (formatError) {
return next(formatError);
}

const result = await releaseTracksService.getLatestSnapshot(req.params.id, queryOptions);
Expand Down Expand Up @@ -323,6 +347,13 @@ exports.deleteReleaseTrack = async function deleteReleaseTrack(req, res, next) {
exports.retrieveSnapshotByModified = async function retrieveSnapshotByModified(req, res, next) {
try {
const queryOptions = parseSnapshotQueryParams(req.query);
const formatError = rejectFilesystemStoreFormat(
queryOptions.format,
'retrieveSnapshotByModified',
);
if (formatError) {
return next(formatError);
}

const result = await releaseTracksService.getSnapshotByModified(
req.params.id,
Expand Down Expand Up @@ -686,7 +717,16 @@ exports.updateConfig = async function updateConfig(req, res, next) {
/** GET /api/release-tracks/:id/bump/preview */
exports.previewBump = async function previewBump(req, res, next) {
try {
const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'workbench');
const format = parseOptionalQueryStrict(
req.query.format,
formatQuerySchema,
'workbench',
'format',
);
const formatError = rejectFilesystemStoreFormat(format, 'previewBump');
if (formatError) {
return next(formatError);
}

const result = await releaseTracksService.previewBump(req.params.id, format);
logger.debug(`Success: Generated bump preview for track ${req.params.id}`);
Expand Down
4 changes: 2 additions & 2 deletions app/lib/release-tracks/release-track-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ const cronSchema = z

const domainParamSchema = z.enum(['enterprise', 'ics', 'mobile']);

const formatQuerySchema = z.enum(['snapshot', 'bundle', 'filesystemstore', 'workbench']);
const formatQuerySchema = z.enum(['bundle', 'filesystemstore', 'workbench']);

const includeQuerySchema = z.enum(['staged', 'candidates', 'all']);
const includeQuerySchema = z.enum(['members', 'staged', 'candidates', 'quarantine', 'all']);

const trackTypeQuerySchema = z.enum(['standard', 'virtual']);

Expand Down
23 changes: 23 additions & 0 deletions app/repository/release-tracks/release-track-dynamic.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ class ReleaseTrackDynamicRepository {
}
}

async getLatestSnapshotTierSummary(trackId) {
try {
const Model = this._getModel(trackId);
const [summary] = await Model.aggregate([
{ $match: { id: trackId } },
{ $sort: { modified: -1 } },
{ $limit: 1 },
{
$project: {
_id: 0,
members_count: { $size: { $ifNull: ['$members', []] } },
staged_count: { $size: { $ifNull: ['$staged', []] } },
candidates_count: { $size: { $ifNull: ['$candidates', []] } },
},
},
]).exec();

return summary || null;
} catch (err) {
throw new DatabaseError(err);
}
}

async getSnapshotByModified(trackId, modified) {
try {
const Model = this._getModel(trackId);
Expand Down
28 changes: 7 additions & 21 deletions app/services/release-tracks/export-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,17 @@ exports.formatAsFilesystemStore = function formatAsFilesystemStore(snapshot, hyd
// =============================================================================

/**
* Export a snapshot in the specified format.
* Export a snapshot in the specified STIX-oriented format.
*
* This is the primary entry point called by the facade when a `format`
* query parameter is provided on snapshot retrieval endpoints.
* Workbench snapshot retrieval is handled by release-tracks-service because it
* returns the release-track snapshot shape with UI-friendly tier entry details.
*
* @param {Object} snapshot - The raw snapshot document from the dynamic repo
* @param {string} format - One of: 'bundle', 'workbench', 'filesystemstore'
* @param {string} format - One of: 'bundle', 'filesystemstore'
* @param {Object} [options] - Additional options
* @param {string} [options.include] - For workbench format: 'staged', 'candidates', or 'all'
* @returns {Promise<Object>} The formatted export
*/
// eslint-disable-next-line no-unused-vars
exports.exportSnapshot = async function exportSnapshot(snapshot, format, options = {}) {
const members = snapshot.members || [];

Expand All @@ -170,26 +170,12 @@ exports.exportSnapshot = async function exportSnapshot(snapshot, format, options
return exports.formatAsBundle(snapshot, hydratedMembers);
}

if (format === 'workbench') {
// Workbench format optionally includes staged and/or candidate objects
const allRefs = [...members];
if (options.include === 'staged' || options.include === 'all') {
allRefs.push(...(snapshot.staged || []));
}
if (options.include === 'candidates' || options.include === 'all') {
allRefs.push(...(snapshot.candidates || []));
}

const hydratedAll = await exports.hydrateMembers(allRefs);
return exports.formatAsWorkbench(snapshot, hydratedAll);
}

if (format === 'filesystemstore') {
const hydratedMembers = await exports.hydrateMembers(members);
return exports.formatAsFilesystemStore(snapshot, hydratedMembers);
}

// Unknown format return raw snapshot unchanged
logger.warn(`ExportService: Unknown format "${format}", returning raw snapshot`);
// Unknown format -- return the snapshot unchanged.
logger.warn(`ExportService: Unknown format "${format}", returning snapshot unchanged`);
return snapshot;
};
Loading
Loading