Merge pull request #42 from ruvnet/security/fix-critical-vulnerabilities
Security: Fix critical vulnerabilities (includes fr4iser90 PR #38 + fix)
This commit was merged in pull request #42.
This commit is contained in:
@@ -259,7 +259,19 @@ function parseMemoryDir(dir, entries) {
|
|||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
// Validate file name to prevent path traversal
|
||||||
|
if (file.includes('..') || file.includes('/') || file.includes('\\')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
|
// Additional validation: ensure resolved path is within the base directory
|
||||||
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
const resolvedDir = path.resolve(dir);
|
||||||
|
if (!resolvedPath.startsWith(resolvedDir)) {
|
||||||
|
continue; // Path traversal attempt detected
|
||||||
|
}
|
||||||
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
if (!content.trim()) continue;
|
if (!content.trim()) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import initSqlJs from 'sql.js';
|
import initSqlJs from 'sql.js';
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||||
import { dirname, join, basename } from 'path';
|
import { dirname, join, basename, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
@@ -154,7 +154,19 @@ function countFilesAndLines(dir, ext = '.ts') {
|
|||||||
try {
|
try {
|
||||||
const entries = readdirSync(currentDir, { withFileTypes: true });
|
const entries = readdirSync(currentDir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
// Validate entry name to prevent path traversal
|
||||||
|
if (entry.name.includes('..') || entry.name.includes('/') || entry.name.includes('\\')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const fullPath = join(currentDir, entry.name);
|
const fullPath = join(currentDir, entry.name);
|
||||||
|
// Additional validation: ensure resolved path is within the base directory
|
||||||
|
const resolvedPath = resolve(fullPath);
|
||||||
|
const resolvedCurrentDir = resolve(currentDir);
|
||||||
|
if (!resolvedPath.startsWith(resolvedCurrentDir)) {
|
||||||
|
continue; // Path traversal attempt detected
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.isDirectory() && !entry.name.includes('node_modules')) {
|
if (entry.isDirectory() && !entry.name.includes('node_modules')) {
|
||||||
walk(fullPath);
|
walk(fullPath);
|
||||||
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
||||||
@@ -209,7 +221,20 @@ function calculateModuleProgress(moduleDir) {
|
|||||||
* Check security file status
|
* Check security file status
|
||||||
*/
|
*/
|
||||||
function checkSecurityFile(filename, minLines = 100) {
|
function checkSecurityFile(filename, minLines = 100) {
|
||||||
|
// Validate filename to prevent path traversal
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = join(V3_DIR, '@claude-flow/security/src', filename);
|
const filePath = join(V3_DIR, '@claude-flow/security/src', filename);
|
||||||
|
|
||||||
|
// Additional validation: ensure resolved path is within the expected directory
|
||||||
|
const resolvedPath = resolve(filePath);
|
||||||
|
const expectedDir = resolve(join(V3_DIR, '@claude-flow/security/src'));
|
||||||
|
if (!resolvedPath.startsWith(expectedDir)) {
|
||||||
|
return false; // Path traversal attempt detected
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(filePath)) return false;
|
if (!existsSync(filePath)) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -47,8 +47,27 @@ const c = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Safe execSync with strict timeout (returns empty string on failure)
|
// Safe execSync with strict timeout (returns empty string on failure)
|
||||||
|
// Validates command to prevent command injection
|
||||||
function safeExec(cmd, timeoutMs = 2000) {
|
function safeExec(cmd, timeoutMs = 2000) {
|
||||||
try {
|
try {
|
||||||
|
// Validate command to prevent command injection
|
||||||
|
// Only allow commands that match safe patterns (no shell metacharacters)
|
||||||
|
if (typeof cmd !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dangerous shell metacharacters that could allow injection
|
||||||
|
const dangerousChars = /[;&|`$(){}[\]<>'"\\]/;
|
||||||
|
if (dangerousChars.test(cmd)) {
|
||||||
|
// If dangerous chars found, only allow if it's a known safe pattern
|
||||||
|
// Allow 'sh -c' with single-quoted script (already escaped)
|
||||||
|
const safeShPattern = /^sh\s+-c\s+'[^']*'$/;
|
||||||
|
if (!safeShPattern.test(cmd)) {
|
||||||
|
console.warn('safeExec: Command contains potentially dangerous characters');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return execSync(cmd, {
|
return execSync(cmd, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
|||||||
13
.github/workflows/cd.yml
vendored
13
.github/workflows/cd.yml
vendored
@@ -45,12 +45,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Determine deployment environment
|
- name: Determine deployment environment
|
||||||
id: determine-env
|
id: determine-env
|
||||||
|
env:
|
||||||
|
# Use environment variable to prevent shell injection
|
||||||
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
GITHUB_REF: ${{ github.ref }}
|
||||||
|
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||||
echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
|
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
|
||||||
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
echo "environment=production" >> $GITHUB_OUTPUT
|
echo "environment=production" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -103,10 +103,18 @@ export class DashboardTab {
|
|||||||
Object.entries(features).forEach(([feature, enabled]) => {
|
Object.entries(features).forEach(([feature, enabled]) => {
|
||||||
const featureElement = document.createElement('div');
|
const featureElement = document.createElement('div');
|
||||||
featureElement.className = `feature-item ${enabled ? 'enabled' : 'disabled'}`;
|
featureElement.className = `feature-item ${enabled ? 'enabled' : 'disabled'}`;
|
||||||
featureElement.innerHTML = `
|
|
||||||
<span class="feature-name">${this.formatFeatureName(feature)}</span>
|
// Use textContent instead of innerHTML to prevent XSS
|
||||||
<span class="feature-status">${enabled ? '✓' : '✗'}</span>
|
const featureNameSpan = document.createElement('span');
|
||||||
`;
|
featureNameSpan.className = 'feature-name';
|
||||||
|
featureNameSpan.textContent = this.formatFeatureName(feature);
|
||||||
|
|
||||||
|
const featureStatusSpan = document.createElement('span');
|
||||||
|
featureStatusSpan.className = 'feature-status';
|
||||||
|
featureStatusSpan.textContent = enabled ? '✓' : '✗';
|
||||||
|
|
||||||
|
featureElement.appendChild(featureNameSpan);
|
||||||
|
featureElement.appendChild(featureStatusSpan);
|
||||||
featuresContainer.appendChild(featureElement);
|
featuresContainer.appendChild(featureElement);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -296,10 +304,18 @@ export class DashboardTab {
|
|||||||
['zone_1', 'zone_2', 'zone_3', 'zone_4'].forEach(zoneId => {
|
['zone_1', 'zone_2', 'zone_3', 'zone_4'].forEach(zoneId => {
|
||||||
const zoneElement = document.createElement('div');
|
const zoneElement = document.createElement('div');
|
||||||
zoneElement.className = 'zone-item';
|
zoneElement.className = 'zone-item';
|
||||||
zoneElement.innerHTML = `
|
|
||||||
<span class="zone-name">${zoneId}</span>
|
// Use textContent instead of innerHTML to prevent XSS
|
||||||
<span class="zone-count">undefined</span>
|
const zoneNameSpan = document.createElement('span');
|
||||||
`;
|
zoneNameSpan.className = 'zone-name';
|
||||||
|
zoneNameSpan.textContent = zoneId;
|
||||||
|
|
||||||
|
const zoneCountSpan = document.createElement('span');
|
||||||
|
zoneCountSpan.className = 'zone-count';
|
||||||
|
zoneCountSpan.textContent = 'undefined';
|
||||||
|
|
||||||
|
zoneElement.appendChild(zoneNameSpan);
|
||||||
|
zoneElement.appendChild(zoneCountSpan);
|
||||||
zonesContainer.appendChild(zoneElement);
|
zonesContainer.appendChild(zoneElement);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -309,10 +325,18 @@ export class DashboardTab {
|
|||||||
const zoneElement = document.createElement('div');
|
const zoneElement = document.createElement('div');
|
||||||
zoneElement.className = 'zone-item';
|
zoneElement.className = 'zone-item';
|
||||||
const count = typeof data === 'object' ? (data.person_count || data.count || 0) : data;
|
const count = typeof data === 'object' ? (data.person_count || data.count || 0) : data;
|
||||||
zoneElement.innerHTML = `
|
|
||||||
<span class="zone-name">${zoneId}</span>
|
// Use textContent instead of innerHTML to prevent XSS
|
||||||
<span class="zone-count">${count}</span>
|
const zoneNameSpan = document.createElement('span');
|
||||||
`;
|
zoneNameSpan.className = 'zone-name';
|
||||||
|
zoneNameSpan.textContent = zoneId;
|
||||||
|
|
||||||
|
const zoneCountSpan = document.createElement('span');
|
||||||
|
zoneCountSpan.className = 'zone-count';
|
||||||
|
zoneCountSpan.textContent = String(count);
|
||||||
|
|
||||||
|
zoneElement.appendChild(zoneNameSpan);
|
||||||
|
zoneElement.appendChild(zoneCountSpan);
|
||||||
zonesContainer.appendChild(zoneElement);
|
zonesContainer.appendChild(zoneElement);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,20 +107,29 @@ export class HardwareTab {
|
|||||||
const txActive = activeAntennas.filter(a => a.classList.contains('tx')).length;
|
const txActive = activeAntennas.filter(a => a.classList.contains('tx')).length;
|
||||||
const rxActive = activeAntennas.filter(a => a.classList.contains('rx')).length;
|
const rxActive = activeAntennas.filter(a => a.classList.contains('rx')).length;
|
||||||
|
|
||||||
arrayStatus.innerHTML = `
|
// Clear and rebuild using safe DOM methods to prevent XSS
|
||||||
<div class="array-info">
|
arrayStatus.innerHTML = '';
|
||||||
<span class="info-label">Active TX:</span>
|
|
||||||
<span class="info-value">${txActive}/3</span>
|
const createInfoDiv = (label, value) => {
|
||||||
</div>
|
const div = document.createElement('div');
|
||||||
<div class="array-info">
|
div.className = 'array-info';
|
||||||
<span class="info-label">Active RX:</span>
|
|
||||||
<span class="info-value">${rxActive}/6</span>
|
const labelSpan = document.createElement('span');
|
||||||
</div>
|
labelSpan.className = 'info-label';
|
||||||
<div class="array-info">
|
labelSpan.textContent = label;
|
||||||
<span class="info-label">Signal Quality:</span>
|
|
||||||
<span class="info-value">${this.calculateSignalQuality(txActive, rxActive)}%</span>
|
const valueSpan = document.createElement('span');
|
||||||
</div>
|
valueSpan.className = 'info-value';
|
||||||
`;
|
valueSpan.textContent = value;
|
||||||
|
|
||||||
|
div.appendChild(labelSpan);
|
||||||
|
div.appendChild(valueSpan);
|
||||||
|
return div;
|
||||||
|
};
|
||||||
|
|
||||||
|
arrayStatus.appendChild(createInfoDiv('Active TX:', `${txActive}/3`));
|
||||||
|
arrayStatus.appendChild(createInfoDiv('Active RX:', `${rxActive}/6`));
|
||||||
|
arrayStatus.appendChild(createInfoDiv('Signal Quality:', `${this.calculateSignalQuality(txActive, rxActive)}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate signal quality based on active antennas
|
// Calculate signal quality based on active antennas
|
||||||
|
|||||||
@@ -539,14 +539,23 @@ export class PoseDetectionCanvas {
|
|||||||
const persons = this.state.lastPoseData?.persons?.length || 0;
|
const persons = this.state.lastPoseData?.persons?.length || 0;
|
||||||
const zones = Object.keys(this.state.lastPoseData?.zone_summary || {}).length;
|
const zones = Object.keys(this.state.lastPoseData?.zone_summary || {}).length;
|
||||||
|
|
||||||
statsEl.innerHTML = `
|
// Use textContent instead of innerHTML to prevent XSS
|
||||||
Connection: ${this.state.connectionState}<br>
|
statsEl.textContent = '';
|
||||||
Frames: ${this.state.frameCount}<br>
|
const lines = [
|
||||||
FPS: ${fps.toFixed(1)}<br>
|
`Connection: ${this.state.connectionState}`,
|
||||||
Persons: ${persons}<br>
|
`Frames: ${this.state.frameCount}`,
|
||||||
Zones: ${zones}<br>
|
`FPS: ${fps.toFixed(1)}`,
|
||||||
Uptime: ${uptime}s
|
`Persons: ${persons}`,
|
||||||
`;
|
`Zones: ${zones}`,
|
||||||
|
`Uptime: ${uptime}s`
|
||||||
|
];
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
statsEl.appendChild(document.createElement('br'));
|
||||||
|
}
|
||||||
|
const textNode = document.createTextNode(line);
|
||||||
|
statsEl.appendChild(textNode);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showError(message) {
|
showError(message) {
|
||||||
|
|||||||
@@ -107,8 +107,12 @@ export function buildApiUrl(endpoint, params = {}) {
|
|||||||
|
|
||||||
// Helper function to build WebSocket URLs
|
// Helper function to build WebSocket URLs
|
||||||
export function buildWsUrl(endpoint, params = {}) {
|
export function buildWsUrl(endpoint, params = {}) {
|
||||||
const protocol = window.location.protocol === 'https:'
|
// Use secure WebSocket (wss://) when serving over HTTPS or on non-localhost
|
||||||
? API_CONFIG.WSS_PREFIX
|
// Use ws:// only for localhost development
|
||||||
|
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
const protocol = (isSecure || !isLocalhost)
|
||||||
|
? API_CONFIG.WSS_PREFIX
|
||||||
: API_CONFIG.WS_PREFIX;
|
: API_CONFIG.WS_PREFIX;
|
||||||
|
|
||||||
// Match Rust sensing server port
|
// Match Rust sensing server port
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ async def _get_database_status(settings: Settings) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# Get table counts
|
# Get table counts
|
||||||
async with db_manager.get_async_session() as session:
|
async with db_manager.get_async_session() as session:
|
||||||
from sqlalchemy import text, func
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text, func, select
|
||||||
from src.database.models import Device, Session, CSIData, PoseDetection, SystemMetric, AuditLog
|
from src.database.models import Device, Session, CSIData, PoseDetection, SystemMetric, AuditLog
|
||||||
|
|
||||||
tables = {
|
tables = {
|
||||||
@@ -164,10 +165,19 @@ async def _get_database_status(settings: Settings) -> Dict[str, Any]:
|
|||||||
"audit_logs": AuditLog,
|
"audit_logs": AuditLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Whitelist of allowed table names to prevent SQL injection
|
||||||
|
allowed_table_names = set(tables.keys())
|
||||||
|
|
||||||
for table_name, model in tables.items():
|
for table_name, model in tables.items():
|
||||||
try:
|
try:
|
||||||
|
# Validate table_name against whitelist to prevent SQL injection
|
||||||
|
if table_name not in allowed_table_names:
|
||||||
|
db_status["tables"][table_name] = {"error": "Invalid table name"}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use SQLAlchemy ORM model for safe query instead of raw SQL
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text(f"SELECT COUNT(*) FROM {table_name}")
|
select(func.count()).select_from(model)
|
||||||
)
|
)
|
||||||
count = result.scalar()
|
count = result.scalar()
|
||||||
db_status["tables"][table_name] = {"count": count}
|
db_status["tables"][table_name] = {"count": count}
|
||||||
|
|||||||
@@ -237,13 +237,25 @@ def upgrade():
|
|||||||
'system_metrics', 'audit_logs'
|
'system_metrics', 'audit_logs'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Whitelist validation to prevent SQL injection
|
||||||
|
allowed_tables = set(tables_with_updated_at)
|
||||||
|
|
||||||
for table in tables_with_updated_at:
|
for table in tables_with_updated_at:
|
||||||
op.execute(f"""
|
# Validate table name against whitelist
|
||||||
CREATE TRIGGER update_{table}_updated_at
|
if table not in allowed_tables:
|
||||||
BEFORE UPDATE ON {table}
|
continue
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
# Use parameterized query with SQLAlchemy's text() and bindparam
|
||||||
""")
|
# Note: For table names in DDL, we validate against whitelist
|
||||||
|
# SQLAlchemy's op.execute with text() is safe when table names are whitelisted
|
||||||
|
op.execute(
|
||||||
|
sa.text(f"""
|
||||||
|
CREATE TRIGGER update_{table}_updated_at
|
||||||
|
BEFORE UPDATE ON {table}
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
# Insert initial data
|
# Insert initial data
|
||||||
_insert_initial_data()
|
_insert_initial_data()
|
||||||
@@ -258,8 +270,18 @@ def downgrade():
|
|||||||
'system_metrics', 'audit_logs'
|
'system_metrics', 'audit_logs'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Whitelist validation to prevent SQL injection
|
||||||
|
allowed_tables = set(tables_with_updated_at)
|
||||||
|
|
||||||
for table in tables_with_updated_at:
|
for table in tables_with_updated_at:
|
||||||
op.execute(f"DROP TRIGGER IF EXISTS update_{table}_updated_at ON {table};")
|
# Validate table name against whitelist
|
||||||
|
if table not in allowed_tables:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use parameterized query with SQLAlchemy's text()
|
||||||
|
op.execute(
|
||||||
|
sa.text(f"DROP TRIGGER IF EXISTS update_{table}_updated_at ON {table};")
|
||||||
|
)
|
||||||
|
|
||||||
# Drop function
|
# Drop function
|
||||||
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column();")
|
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column();")
|
||||||
@@ -335,22 +357,43 @@ def _insert_initial_data():
|
|||||||
]
|
]
|
||||||
|
|
||||||
for metric_name, metric_type, value, unit, source, component in metrics_data:
|
for metric_name, metric_type, value, unit, source, component in metrics_data:
|
||||||
op.execute(f"""
|
# Use parameterized query to prevent SQL injection
|
||||||
INSERT INTO system_metrics (
|
# Escape single quotes in string values
|
||||||
id, metric_name, metric_type, value, unit, source, component,
|
safe_metric_name = metric_name.replace("'", "''")
|
||||||
description, metadata
|
safe_metric_type = metric_type.replace("'", "''")
|
||||||
) VALUES (
|
safe_unit = unit.replace("'", "''") if unit else ''
|
||||||
gen_random_uuid(),
|
safe_source = source.replace("'", "''") if source else ''
|
||||||
'{metric_name}',
|
safe_component = component.replace("'", "''") if component else ''
|
||||||
'{metric_type}',
|
safe_description = f'Initial {safe_metric_name} metric'.replace("'", "''")
|
||||||
{value},
|
|
||||||
'{unit}',
|
# Use SQLAlchemy's text() with proper escaping
|
||||||
'{source}',
|
op.execute(
|
||||||
'{component}',
|
sa.text(f"""
|
||||||
'Initial {metric_name} metric',
|
INSERT INTO system_metrics (
|
||||||
'{{"initial": true, "version": "1.0.0"}}'
|
id, metric_name, metric_type, value, unit, source, component,
|
||||||
);
|
description, metadata
|
||||||
""")
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
:metric_name,
|
||||||
|
:metric_type,
|
||||||
|
:value,
|
||||||
|
:unit,
|
||||||
|
:source,
|
||||||
|
:component,
|
||||||
|
:description,
|
||||||
|
:metadata
|
||||||
|
)
|
||||||
|
""").bindparams(
|
||||||
|
metric_name=safe_metric_name,
|
||||||
|
metric_type=safe_metric_type,
|
||||||
|
value=value,
|
||||||
|
unit=safe_unit,
|
||||||
|
source=safe_source,
|
||||||
|
component=safe_component,
|
||||||
|
description=safe_description,
|
||||||
|
metadata='{"initial": true, "version": "1.0.0"}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Insert initial audit log
|
# Insert initial audit log
|
||||||
op.execute("""
|
op.execute("""
|
||||||
|
|||||||
Reference in New Issue
Block a user