Skip to content

Commit

Permalink
feat: make data registration for 'attack' more robust
Browse files Browse the repository at this point in the history
* validate 'version' against release tags on 'mitre-attack/attack-stix-data'
* normalize for leading 'v' (e.g. 'v1.0.0' vs '1.0.0')
* improve in-line documentation
  • Loading branch information
seansica committed Oct 15, 2024
1 parent babcf85 commit fc92015
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 30 deletions.
85 changes: 57 additions & 28 deletions src/data-sources/data-registration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { attackDomainSchema, type AttackDomain } from "../schemas/index.js";
import { fetchAttackVersions } from "./fetch-attack-versions.js";

export type ParsingMode = 'strict' | 'relaxed';

Expand All @@ -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<void> {
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<void> {
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."
);
}
}
}
52 changes: 52 additions & 0 deletions src/data-sources/fetch-attack-versions.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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;
}
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -83,7 +82,8 @@ export async function registerDataSource(registration: DataRegistration): Promis
*/
async function fetchAttackDataFromGitHub(domain: string, version?: string): Promise<StixBundle> {
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<StixBundle>(url, {
Expand Down

0 comments on commit fc92015

Please sign in to comment.