diff --git a/src/data-sources/data-registration.ts b/src/data-sources/data-registration.ts index 8573c20..abd722a 100644 --- a/src/data-sources/data-registration.ts +++ b/src/data-sources/data-registration.ts @@ -1,4 +1,5 @@ import { attackDomainSchema, type AttackDomain } from "../schemas/index.js"; +import { fetchAttackVersions } from "./fetch-attack-versions.js"; export type ParsingMode = 'strict' | 'relaxed'; @@ -25,59 +26,87 @@ export type DataSourceOptions = parsingMode?: ParsingMode; }; -// DataRegistration class with validation logic +/** + * Represents a data source registration with validation logic. + */ export class DataRegistration { - constructor(public options: DataSourceOptions) { - // Validate the provided options when an instance is created + /** + * Creates a new DataRegistration instance. + * @param options - The data source options to register. + */ + constructor(public readonly options: DataSourceOptions) { this.validateOptions(); } /** * Validates the data source options to ensure the correct fields are provided for each source type. - * Throws an error if validation fails. + * @throws An error if validation fails. */ - private validateOptions(): void { + private async validateOptions(): Promise { const { source, parsingMode } = this.options; - // Validate parsingMode + // Validate parsing mode if (parsingMode && !['strict', 'relaxed'].includes(parsingMode)) { throw new Error(`Invalid parsingMode: ${parsingMode}. Expected 'strict' or 'relaxed'.`); } switch (source) { case 'attack': { - const { domain } = this.options as { domain: AttackDomain }; - if (!domain || !Object.values(attackDomainSchema.enum).includes(domain)) { - throw new Error( - `Invalid domain provided for 'attack' source. Expected one of: ${Object.values( - attackDomainSchema.enum - ).join(', ')}` - ); - } + await this.validateAttackOptions(); break; } case 'file': { - const { path } = this.options as { path: string }; - if (!path) { - throw new Error( - "The 'file' source requires a 'path' field to specify the file location." - ); - } + this.validateFileOptions(); break; } case 'url': case 'taxii': { - const { url } = this.options as { url: string }; - if (!url) { - throw new Error( - `The '${source}' source requires a 'url' field to specify the data location.` - ); - } - break; + throw new Error(`The ${source} source is not implemented yet.`); } default: { throw new Error(`Unsupported data source type: ${source}`); } } } -} + + /** + * Validates options specific to the 'attack' source type. + * @throws An error if validation fails. + */ + private async validateAttackOptions(): Promise { + const { domain, version } = this.options as { domain: AttackDomain; version?: string }; + + // Validate domain + if (!domain || !Object.values(attackDomainSchema.enum).includes(domain)) { + throw new Error( + `Invalid domain provided for 'attack' source. Expected one of: ${Object.values( + attackDomainSchema.enum + ).join(', ')}` + ); + } + + // Validate version if provided + if (version) { + const supportedVersions = await fetchAttackVersions(); + const normalizedVersion = version.replace(/^v/, ''); // Remove leading 'v' if present + if (!supportedVersions.includes(normalizedVersion)) { + throw new Error( + `Invalid version: ${version}. Supported versions are: ${supportedVersions.join(', ')}` + ); + } + } + } + + /** + * Validates options specific to the 'file' source type. + * @throws An error if validation fails. + */ + private validateFileOptions(): void { + const { path } = this.options as { path: string }; + if (!path) { + throw new Error( + "The 'file' source requires a 'path' field to specify the file location." + ); + } + } +} \ No newline at end of file diff --git a/src/data-sources/fetch-attack-versions.ts b/src/data-sources/fetch-attack-versions.ts new file mode 100644 index 0000000..228494e --- /dev/null +++ b/src/data-sources/fetch-attack-versions.ts @@ -0,0 +1,52 @@ +/** + * Represents a GitHub release object. + */ +interface GitHubRelease { + tag_name: string; + name: string; + published_at: string; +} + +/** + * Normalizes a version string by removing any leading 'v' character. + * @param version - The version string to normalize. + * @returns The normalized version string. + */ +function normalizeVersion(version: string): string { + return version.replace(/^v/, ''); +} + +/** + * Fetches the list of ATT&CK versions from the MITRE ATT&CK STIX data GitHub repository. + * @returns A promise that resolves to an array of version strings. + * @throws An error if the HTTP request fails. + */ +export async function fetchAttackVersions(): Promise { + const url = 'https://api.github.com/repos/mitre-attack/attack-stix-data/releases'; + + // Make a GET request to the GitHub API + const response = await fetch(url, { + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const releases: GitHubRelease[] = await response.json(); + + // Extract and normalize version numbers, then sort them in descending order + const versions = releases + .map(release => normalizeVersion(release.tag_name)) + .sort((a, b) => { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + if (bMajor !== aMajor) return bMajor - aMajor; + return bMinor - aMinor; + }); + + return versions; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index b022afa..beeaf33 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ import axios from 'axios'; import fs from 'fs'; -import path from 'path'; import { promisify } from 'util'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; @@ -83,7 +82,8 @@ export async function registerDataSource(registration: DataRegistration): Promis */ async function fetchAttackDataFromGitHub(domain: string, version?: string): Promise { let url = `${GITHUB_BASE_URL}/${domain}/`; - url += version ? `${domain}-${version}.json` : `${domain}.json`; + const normalizedVersion = version ? version.replace(/^v/, '') : version; // Remove leading 'v' if present + url += normalizedVersion ? `${domain}-${normalizedVersion}.json` : `${domain}.json`; try { const response = await axios.get(url, {