Skip to content

Commit bf6fe08

Browse files
authored
Merge pull request #280 from splunk/feature/version-bumping-enforcement
Feature: Adding version enforcement
2 parents 7db0d49 + 094b4bb commit bf6fe08

File tree

10 files changed

+969
-117
lines changed

10 files changed

+969
-117
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ This section is under active development. It will allow you to a [MITRE Map](ht
134134
Choose TYPE {detection, story} to create new content for the Content Pack. The tool will interactively ask a series of questions required for generating a basic piece of content and automatically add it to the Content Pack.
135135

136136
### contentctl inspect
137-
This section is under development. It will enable the user to perform an appinspect of the content pack in preparation for deployment onto a Splunk Instance or via Splunk Cloud.
137+
This section is under development. The inspect action performs a number of post-build validations. Primarily, it will enable the user to perform an appinspect of the content pack in preparation for deployment onto a Splunk Instance or via Splunk Cloud. It also compares detections in the new build against a prior build, confirming that any changed detections have had their versions incremented (this comparison happens at the savedsearch.conf level, which is why it must happen after the build). Please also note that new versions of contentctl may result in the generation of different savedsearches.conf files without any content changes in YML (new keys at the .conf level which will necessitate bumping of the version in the YML file).
138138

139139
### contentctl deploy
140140
The reason to build content is so that it can be deployed to your environment. However, deploying content to multiple servers and different types of infrastructure can be tricky and time-consuming. contentctl makes this easy by supporting a number of different deployment mechanisms. Deployment targets can be defined in [contentctl.yml](/contentctl/templates/contentctl_default.yml).

contentctl/actions/inspect.py

Lines changed: 189 additions & 91 deletions
Large diffs are not rendered by default.

contentctl/helper/splunk_app.py

Lines changed: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
import os
2-
import time
31
import json
2+
from typing import Optional, Collection
3+
from pathlib import Path
44
import xml.etree.ElementTree as ET
5-
from typing import List, Tuple, Optional
65
from urllib.parse import urlencode
76

87
import requests
98
import urllib3
109
import xmltodict
1110
from requests.adapters import HTTPAdapter
12-
from requests.packages.urllib3.util.retry import Retry
11+
from urllib3.util.retry import Retry
1312

1413
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
1514

1615
MAX_RETRY = 3
1716

17+
1818
class APIEndPoint:
1919
"""
2020
Class which contains Static Endpoint
@@ -27,6 +27,7 @@ class APIEndPoint:
2727
SPLUNK_BASE_GET_UID_REDIRECT = "https://apps.splunk.com/apps/id/{app_name_id}"
2828
SPLUNK_BASE_APP_INFO = "https://splunkbase.splunk.com/api/v1/app/{app_uid}"
2929

30+
3031
class RetryConstant:
3132
"""
3233
Class which contains Retry Constant
@@ -53,11 +54,11 @@ class InitializationError(Exception):
5354

5455
@staticmethod
5556
def requests_retry_session(
56-
retries=RetryConstant.RETRY_COUNT,
57-
backoff_factor=1,
58-
status_forcelist=(500, 502, 503, 504),
59-
session=None,
60-
):
57+
retries: int = RetryConstant.RETRY_COUNT,
58+
backoff_factor: int = 1,
59+
status_forcelist: Collection[int] = (500, 502, 503, 504),
60+
session: requests.Session | None = None,
61+
) -> requests.Session:
6162
session = session or requests.Session()
6263
retry = Retry(
6364
total=retries,
@@ -260,4 +261,134 @@ def set_latest_version_info(self) -> None:
260261

261262
# parse out the version number and fetch the download URL
262263
self.latest_version = info_url.split("/")[-1]
263-
self.latest_version_download_url = self.__fetch_url_latest_version_download(info_url)
264+
self.latest_version_download_url = self.__fetch_url_latest_version_download(info_url)
265+
266+
def __get_splunk_base_session_token(self, username: str, password: str) -> str:
267+
"""
268+
This method will generate Splunk base session token
269+
270+
:param username: Splunkbase username
271+
:type username: str
272+
:param password: Splunkbase password
273+
:type password: str
274+
275+
:return: Splunk base session token
276+
:rtype: str
277+
"""
278+
# Data payload for fetch splunk base session token
279+
payload = urlencode(
280+
{
281+
"username": username,
282+
"password": password,
283+
}
284+
)
285+
286+
headers = {
287+
"content-type": "application/x-www-form-urlencoded",
288+
"cache-control": "no-cache",
289+
}
290+
291+
response = requests.request(
292+
"POST",
293+
APIEndPoint.SPLUNK_BASE_AUTH_URL,
294+
data=payload,
295+
headers=headers,
296+
)
297+
298+
token_value = ""
299+
300+
if response.status_code != 200:
301+
msg = (
302+
f"Error occurred while executing the rest call for splunk base authentication api,"
303+
f"{response.content}"
304+
)
305+
raise Exception(msg)
306+
else:
307+
root = ET.fromstring(response.content)
308+
token_value = root.find("{http://www.w3.org/2005/Atom}id").text.strip()
309+
return token_value
310+
311+
def download(
312+
self,
313+
out: Path,
314+
username: str,
315+
password: str,
316+
is_dir: bool = False,
317+
overwrite: bool = False
318+
) -> Path:
319+
"""
320+
Given an output path, download the app to the specified location
321+
322+
:param out: the Path to download the app to
323+
:type out: :class:`pathlib.Path`
324+
:param username: Splunkbase username
325+
:type username: str
326+
:param password: Splunkbase password
327+
:type password: str
328+
:param is_dir: a flag indicating whether out is directory, otherwise a file (default: False)
329+
:type is_dir: bool
330+
:param overwrite: a flag indicating whether we can overwrite the file at out or not
331+
:type overwrite: bool
332+
333+
:returns path: the Path the download was written to (needed when is_dir is True)
334+
:rtype: :class:`pathlib.Path`
335+
"""
336+
# Get the Splunkbase session token
337+
token = self.__get_splunk_base_session_token(username, password)
338+
response = requests.request(
339+
"GET",
340+
self.latest_version_download_url,
341+
cookies={
342+
"sessionid": token
343+
}
344+
)
345+
346+
# If the provided output path was a directory we need to try and pull the filename from the
347+
# response headers
348+
if is_dir:
349+
try:
350+
# Pull 'Content-Disposition' from the headers
351+
content_disposition: str = response.headers['Content-Disposition']
352+
353+
# Attempt to parse the filename as a KV
354+
key, value = content_disposition.strip().split("=")
355+
if key != "attachment;filename":
356+
raise ValueError(f"Unexpected key in 'Content-Disposition' KV pair: {key}")
357+
358+
# Validate the filename is the expected .tgz file
359+
filename = Path(value.strip().strip('"'))
360+
if filename.suffixes != [".tgz"]:
361+
raise ValueError(f"Filename has unexpected extension(s): {filename.suffixes}")
362+
out = Path(out, filename)
363+
except KeyError as e:
364+
raise KeyError(
365+
f"Unable to properly extract 'Content-Disposition' from response headers: {e}"
366+
) from e
367+
except ValueError as e:
368+
raise ValueError(
369+
f"Unable to parse filename from 'Content-Disposition' header: {e}"
370+
) from e
371+
372+
# Ensure the output path is not already occupied
373+
if out.exists() and not overwrite:
374+
msg = (
375+
f"File already exists at {out}, cannot download the app."
376+
)
377+
raise Exception(msg)
378+
379+
# Make any parent directories as needed
380+
out.parent.mkdir(parents=True, exist_ok=True)
381+
382+
# Check for HTTP errors
383+
if response.status_code != 200:
384+
msg = (
385+
f"Error occurred while executing the rest call for splunk base authentication api,"
386+
f"{response.content}"
387+
)
388+
raise Exception(msg)
389+
390+
# Write the app to disk
391+
with open(out, "wb") as file:
392+
file.write(response.content)
393+
394+
return out

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,11 @@ def metadata(self) -> dict[str, str|float]:
388388
# NOTE: we ignore the type error around self.status because we are using Pydantic's
389389
# use_enum_values configuration
390390
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
391-
391+
392+
# NOTE: The `inspect` action is HIGHLY sensitive to the structure of the metadata line in
393+
# the detection stanza in savedsearches.conf. Additive operations (e.g. a new field in the
394+
# dict below) should not have any impact, but renaming or removing any of these fields will
395+
# break the `inspect` action.
392396
return {
393397
'detection_id': str(self.id),
394398
'deprecated': '1' if self.status == DetectionStatus.deprecated.value else '0', # type: ignore

contentctl/objects/config.py

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
from __future__ import annotations
2+
3+
from os import environ
4+
from datetime import datetime, UTC
5+
from typing import Optional, Any, List, Union, Self
6+
import random
7+
from enum import StrEnum, auto
8+
import pathlib
9+
from urllib.parse import urlparse
10+
from abc import ABC, abstractmethod
11+
from functools import partialmethod
12+
13+
import tqdm
14+
import semantic_version
215
from pydantic import (
316
BaseModel, Field, field_validator,
417
field_serializer, ConfigDict, DirectoryPath,
518
PositiveInt, FilePath, HttpUrl, AnyUrl, model_validator,
619
ValidationInfo
720
)
21+
22+
from contentctl.objects.constants import DOWNLOADS_DIRECTORY
823
from contentctl.output.yml_writer import YmlWriter
9-
from os import environ
10-
from datetime import datetime, UTC
11-
from typing import Optional,Any,Annotated,List,Union, Self
12-
import semantic_version
13-
import random
14-
from enum import StrEnum, auto
15-
import pathlib
1624
from contentctl.helper.utils import Utils
17-
from urllib.parse import urlparse
18-
from abc import ABC, abstractmethod
1925
from contentctl.objects.enums import PostTestBehavior, DetectionTestingMode
2026
from contentctl.objects.detection import Detection
2127
from contentctl.objects.annotated_types import APPID_TYPE
22-
import tqdm
23-
from functools import partialmethod
28+
from contentctl.helper.splunk_app import SplunkApp
2429

2530
ENTERPRISE_SECURITY_UID = 263
2631
COMMON_INFORMATION_MODEL_UID = 1621
@@ -252,11 +257,89 @@ class StackType(StrEnum):
252257
classic = auto()
253258
victoria = auto()
254259

260+
255261
class inspect(build):
256-
splunk_api_username: str = Field(description="Splunk API username used for running appinspect.")
257-
splunk_api_password: str = Field(exclude=True, description="Splunk API password used for running appinspect.")
262+
splunk_api_username: str = Field(
263+
description="Splunk API username used for appinspect and Splunkbase downloads."
264+
)
265+
splunk_api_password: str = Field(
266+
exclude=True,
267+
description="Splunk API password used for appinspect and Splunkbase downloads."
268+
)
269+
enable_metadata_validation: bool = Field(
270+
default=False,
271+
description=(
272+
"Flag indicating whether detection metadata validation and versioning enforcement "
273+
"should be enabled."
274+
)
275+
)
276+
enrichments: bool = Field(
277+
default=True,
278+
description=(
279+
"[NOTE: enrichments must be ENABLED for inspect to run. Please adjust your config "
280+
f"or CLI invocation appropriately] {validate.model_fields['enrichments'].description}"
281+
)
282+
)
283+
# TODO (cmcginley): wording should change here if we want to be able to download any app from
284+
# Splunkbase
285+
previous_build: str | None = Field(
286+
default=None,
287+
description=(
288+
"Local path to the previous app build for metatdata validation and versioning "
289+
"enforcement (defaults to the latest release of the app published on Splunkbase)."
290+
)
291+
)
258292
stack_type: StackType = Field(description="The type of your Splunk Cloud Stack")
259293

294+
@field_validator("enrichments", mode="after")
295+
@classmethod
296+
def validate_needed_flags_metadata_validation(cls, v: bool, info: ValidationInfo) -> bool:
297+
"""
298+
Validates that `enrichments` is True for the inspect action
299+
300+
:param v: the field's value
301+
:type v: bool
302+
:param info: the ValidationInfo to be used
303+
:type info: :class:`pydantic.ValidationInfo`
304+
305+
:returns: bool, for v
306+
:rtype: bool
307+
"""
308+
# Enforce that `enrichments` is True for the inspect action
309+
if v is False:
310+
raise ValueError("Field `enrichments` must be True for the `inspect` action")
311+
312+
return v
313+
314+
def get_previous_package_file_path(self) -> pathlib.Path:
315+
"""
316+
Returns a Path object for the path to the prior package build. If no path was provided, the
317+
latest version is downloaded from Splunkbase and it's filepath is returned, and saved to the
318+
in-memory config (so download doesn't happen twice in the same run).
319+
320+
:returns: Path object to previous app build
321+
:rtype: :class:`pathlib.Path`
322+
"""
323+
previous_build_path = self.previous_build
324+
# Download the previous build as the latest release on Splunkbase if no path was provided
325+
if previous_build_path is None:
326+
print(
327+
f"Downloading latest {self.app.label} build from Splunkbase to serve as previous "
328+
"build during validation..."
329+
)
330+
app = SplunkApp(app_uid=self.app.uid)
331+
previous_build_path = app.download(
332+
out=pathlib.Path(DOWNLOADS_DIRECTORY),
333+
username=self.splunk_api_username,
334+
password=self.splunk_api_password,
335+
is_dir=True,
336+
overwrite=True
337+
)
338+
print(f"Latest release downloaded from Splunkbase to: {previous_build_path}")
339+
self.previous_build = str(previous_build_path)
340+
return pathlib.Path(previous_build_path)
341+
342+
260343
class NewContentType(StrEnum):
261344
detection = auto()
262345
story = auto()

contentctl/objects/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,7 @@
136136
RBA_OBSERVABLE_ROLE_MAPPING = {
137137
"Attacker": 0,
138138
"Victim": 1
139-
}
139+
}
140+
141+
# The relative path to the directory where any apps/packages will be downloaded
142+
DOWNLOADS_DIRECTORY = "downloads"

0 commit comments

Comments
 (0)