mirror of
https://github.com/reconurge/flowsint.git
synced 2026-07-11 08:14:09 -05:00
Compare commits
42 Commits
fix/prebui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d092c44f0 | ||
|
|
e02d548578 | ||
|
|
d049cb1e3e | ||
|
|
db4ab1b5ca | ||
|
|
0c5a6bfabc | ||
|
|
c84b96cced | ||
|
|
1f57cb0904 | ||
|
|
65c7ba2c01 | ||
|
|
f06989447e | ||
|
|
2a378954ae | ||
|
|
0485fc1746 | ||
|
|
ee66f8e0fc | ||
|
|
7ea36bb9af | ||
|
|
9218948b35 | ||
|
|
48d1df13d6 | ||
|
|
fe8a4a3210 | ||
|
|
ddc943790b | ||
|
|
80969367e6 | ||
|
|
67c7c4a833 | ||
|
|
38c7a72cb1 | ||
|
|
cf2bc1ec07 | ||
|
|
3885f8b4d1 | ||
|
|
8de27796b0 | ||
|
|
eedbeb4013 | ||
|
|
2e12e42bba | ||
|
|
63a97a76f7 | ||
|
|
b4e5102592 | ||
|
|
25b735b2a2 | ||
|
|
a7d5861d5b | ||
|
|
86b254bbf9 | ||
|
|
fc9208865d | ||
|
|
f0b0b9bf82 | ||
|
|
0a3567af96 | ||
|
|
12bf2937c1 | ||
|
|
612d39eadf | ||
|
|
dc36ef633e | ||
|
|
22c6908a0e | ||
|
|
71e3a68830 | ||
|
|
a24551ed55 | ||
|
|
17e36c9162 | ||
|
|
07584ebc4a | ||
|
|
db9f50d914 |
37
.github/workflows/tests.yml
vendored
Normal file
37
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
# Same entrypoint as local development — keeps CI and `make test` in sync.
|
||||
- name: Run tests
|
||||
run: make test
|
||||
env:
|
||||
# flowsint_core hard-requires these at import time (provided by .env
|
||||
# locally). Dummy values — no service is reached during tests:
|
||||
# connections (redis.from_url, create_engine) are lazy.
|
||||
AUTH_SECRET: ci-only-dummy-secret
|
||||
REDIS_URL: redis://127.0.0.1:6379/0
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
!flowsint-app/src/lib/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit ${1}
|
||||
|
||||
1
Makefile
1
Makefile
@@ -140,6 +140,7 @@ test:
|
||||
cd flowsint-types && uv run pytest
|
||||
cd flowsint-core && uv run pytest
|
||||
cd flowsint-enrichers && uv run pytest
|
||||
cd flowsint-api && uv run pytest
|
||||
|
||||
install:
|
||||
$(MAKE) infra-dev
|
||||
|
||||
@@ -102,6 +102,8 @@ Anyone on the network can then access Flowsint at `http://<server-ip>:5173`.
|
||||
- `MASTER_VAULT_KEY_V1` — encrypts stored API keys. Generate one: `python3 -c "import os, base64; print('base64:' + base64.b64encode(os.urandom(32)).decode())"`
|
||||
- `NEO4J_PASSWORD` — Neo4j database password.
|
||||
|
||||
**Also add your server hostname/IP to the Host-header allowlist** in `flowsint-app/nginx.conf` (look for `map $http_host $is_flowsint_host`). The default allowlist accepts only `localhost` / `127.0.0.1` / `[::1]`, which defends single-user installs against DNS rebinding; LAN and public deploys must opt-in their own hostname. Two commented-out template lines in the same map block show the format.
|
||||
|
||||
Only port `5173` is exposed to the network. PostgreSQL, Redis, Neo4j and the API are bound to `127.0.0.1` on the server and reachable only through the frontend proxy.
|
||||
|
||||
To pin a specific version instead of `latest`, set `FLOWSINT_VERSION` in `.env` (e.g. `FLOWSINT_VERSION=1.2.10`).
|
||||
|
||||
@@ -149,6 +149,9 @@ services:
|
||||
ports:
|
||||
# The only network-facing port. Serves the UI and proxies /api/ to the API.
|
||||
- "5173:8080"
|
||||
volumes:
|
||||
# Override the baked-in nginx.conf to use system resolver (Podman-compatible).
|
||||
- ./flowsint-app/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- flowsint_network
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add_category_to_custom_types
|
||||
|
||||
Revision ID: f4d42260273d
|
||||
Revises: a1f2b3c4d5e6
|
||||
Create Date: 2026-06-06 14:47:31.692222
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f4d42260273d"
|
||||
down_revision: Union[str, None] = "a1f2b3c4d5e6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"custom_types",
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.String(),
|
||||
server_default="custom_types_category",
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("custom_types", "category")
|
||||
# ### end Alembic commands ###
|
||||
@@ -46,6 +46,7 @@ def create_custom_type(
|
||||
user_id=current_user.id,
|
||||
description=custom_type.description,
|
||||
status=custom_type.status,
|
||||
category=custom_type.category,
|
||||
validate_schema_func=validate_json_schema,
|
||||
calculate_checksum_func=calculate_schema_checksum,
|
||||
)
|
||||
@@ -116,6 +117,7 @@ def update_custom_type(
|
||||
json_schema=update_data.json_schema,
|
||||
description=update_data.description,
|
||||
status=update_data.status,
|
||||
category=update_data.category,
|
||||
validate_schema_func=validate_json_schema,
|
||||
calculate_checksum_func=calculate_schema_checksum,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@ class CustomTypeCreate(BaseModel):
|
||||
None, description="Optional description of the custom type"
|
||||
)
|
||||
status: str = Field("draft", description="Status of the custom type")
|
||||
category: str = Field(
|
||||
"custom_types_category", description="Category of the custom type"
|
||||
)
|
||||
color: str = Field("#8E9E8C", description="Default color")
|
||||
icon: str = Field("Minus", description="Default icon")
|
||||
|
||||
@@ -40,6 +43,7 @@ class CustomTypeUpdate(BaseModel):
|
||||
json_schema: Optional[Dict[str, Any]] = Field(None, alias="schema")
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
@@ -64,6 +68,7 @@ class CustomTypeRead(ORMBase):
|
||||
icon: Optional[str]
|
||||
json_schema: Dict[str, Any] = Field(..., alias="schema")
|
||||
status: str
|
||||
category: str
|
||||
checksum: Optional[str]
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
@@ -17,7 +17,7 @@ ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:1.27-alpine AS production
|
||||
FROM nginx:1.29-alpine AS production
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
|
||||
LABEL org.opencontainers.image.description="Flowsint Frontend"
|
||||
|
||||
@@ -45,12 +45,39 @@ http {
|
||||
# Security
|
||||
server_tokens off;
|
||||
|
||||
# Host-header allowlist — defends against DNS rebinding.
|
||||
# The CORSMiddleware on the API blocks cross-origin reads, but after a
|
||||
# DNS rebinding flip the browser treats the request as same-origin and
|
||||
# skips the CORS check entirely, so a server-side Host check is the
|
||||
# canonical defense. Without it, attacker JS on evil.com can drive a
|
||||
# victim's browser to POST /api/auth/register against a flowsint instance
|
||||
# bound to localhost or a LAN IP, planting attacker-controlled accounts.
|
||||
#
|
||||
# Default allowlist covers single-user local deploys (localhost / 127.0.0.1
|
||||
# / ::1, any port). When deploying on a network (see README "Deploy on a
|
||||
# network"), add the server hostname/IP below — and add the public
|
||||
# hostname too when fronting with a reverse proxy like Caddy.
|
||||
map $http_host $is_flowsint_host {
|
||||
default 0;
|
||||
"~*^localhost(:[0-9]+)?$" 1;
|
||||
"~*^127\.0\.0\.1(:[0-9]+)?$" 1;
|
||||
"~*^\[::1\](:[0-9]+)?$" 1;
|
||||
# "~*^flowsint\.example\.com$" 1;
|
||||
# "~*^192\.168\.1\.42(:[0-9]+)?$" 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Default-deny unknown Host headers (DNS rebinding defense).
|
||||
# See $is_flowsint_host map above for the allowlist.
|
||||
if ($is_flowsint_host = 0) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
@@ -58,13 +85,11 @@ http {
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# API reverse proxy — same-origin, no CORS needed.
|
||||
# Expects the API service to be reachable as "api:5001" on the Docker network.
|
||||
# Expects the API service to be reachable as "api:5001" on the compose network.
|
||||
# Uses system resolver (/etc/resolv.conf) — compatible with Docker and Podman.
|
||||
# Trade-off: hostname resolved once at startup; restart app if api IP changes.
|
||||
location /api/ {
|
||||
# Resolve at request time via Docker's embedded DNS: survives api
|
||||
# container restarts and lets nginx boot before api is up.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $api_upstream http://api:5001;
|
||||
proxy_pass $api_upstream;
|
||||
proxy_pass http://api:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -88,6 +113,13 @@ http {
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
# Re-declare server-level security headers — nginx's add_header
|
||||
# inheritance is replace-all, not merge, so a location block that
|
||||
# sets any add_header drops every header set higher up.
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
@@ -100,6 +132,12 @@ http {
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
# Re-declare server-level security headers (see static-assets
|
||||
# location above for why).
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"d3": "^7.9.0",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.0.6",
|
||||
"input-otp": "^1.4.2",
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface CustomType {
|
||||
owner_id: string
|
||||
schema: Record<string, any>
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
category: string
|
||||
checksum?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
@@ -21,6 +22,7 @@ export interface CustomTypeCreate {
|
||||
icon?: string
|
||||
color?: string
|
||||
status?: 'draft' | 'published'
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface CustomTypeUpdate {
|
||||
@@ -30,6 +32,7 @@ export interface CustomTypeUpdate {
|
||||
icon?: string
|
||||
color?: string
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface ValidatePayload {
|
||||
|
||||
@@ -9,9 +9,18 @@ interface TypePreviewProps {
|
||||
color: string
|
||||
fields: SchemaField[]
|
||||
status: 'draft' | 'published'
|
||||
category: string
|
||||
}
|
||||
|
||||
export function TypePreview({ name, description, icon, color, fields, status }: TypePreviewProps) {
|
||||
export function TypePreview({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
fields,
|
||||
status,
|
||||
category
|
||||
}: TypePreviewProps) {
|
||||
const Icon = (LucideIcons as any)[icon] || LucideIcons.FileQuestion
|
||||
const validFields = fields.filter((f) => f.key.trim())
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function SketchesSection({ sketches, canCreate = true }: SketchesSectionP
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
canCreate ? <EmptySketches onAction={() => console.log("Create sketch")} /> : <div className="text-sm text-muted-foreground py-4">No sketches yet.</div>
|
||||
canCreate ? <EmptySketches /> : <div className="text-sm text-muted-foreground py-4">No sketches yet.</div>
|
||||
) : (
|
||||
<div style={{ containerType: 'inline-size' }}>
|
||||
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 cq-lg:grid-cols-4 gap-3">
|
||||
|
||||
@@ -19,8 +19,6 @@ export default function RawMaterial() {
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
|
||||
console.log(materials)
|
||||
|
||||
const filteredEnrichers = useMemo(() => {
|
||||
if (!materials?.items) return {}
|
||||
const result: Record<string, Enricher[]> = {}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
BadgeAlert,
|
||||
Plus,
|
||||
Download,
|
||||
FileJson,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { Enricher, Flow, GraphNode } from '@/types'
|
||||
@@ -423,10 +424,47 @@ type SubActionsProps = {
|
||||
selectedNodes: GraphNode[]
|
||||
}
|
||||
const SubActions = memo(({ selectedNodes }: SubActionsProps) => {
|
||||
const contentToCopy = useMemo(() => selectedNodes.map((node) => node.nodeLabel).join('\n'), [])
|
||||
const contentToCopy = useMemo(
|
||||
() => selectedNodes.map((node) => node.nodeLabel).join('\n'),
|
||||
[selectedNodes]
|
||||
)
|
||||
const edges = useGraphStore((s) => s.edges)
|
||||
const selectedEdges = useMemo(() => {
|
||||
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id))
|
||||
return edges.filter(
|
||||
(edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)
|
||||
)
|
||||
}, [edges, selectedNodes])
|
||||
|
||||
const handleExportJson = useCallback(() => {
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
nodes: selectedNodes,
|
||||
edges: selectedEdges
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const href = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = href
|
||||
link.download = `flowsint-selection-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(href)
|
||||
}, [selectedEdges, selectedNodes])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<CopyButton label={`Copy ${selectedNodes.length} items as txt`} content={contentToCopy} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
<FileJson className="h-4 w-4 opacity-60" />
|
||||
Export {selectedNodes.length} node(s), {selectedEdges.length} edge(s) as JSON
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -103,9 +103,11 @@ const drawClippedImage = (
|
||||
drawHeight = diameter / imgAspect
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
drawNodePath(ctx, x, y, size, shape)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, x - drawWidth / 2, y - drawHeight / 2, drawWidth, drawHeight)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// --- Node visual resolution ---
|
||||
|
||||
@@ -260,7 +260,6 @@ export function TemplateEditor({ templateId, initialContent, importedYaml }: Tem
|
||||
duration: response.duration_ms,
|
||||
url: response.url
|
||||
})
|
||||
console.log(response)
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
|
||||
44
flowsint-app/src/lib/tools.test.ts
Normal file
44
flowsint-app/src/lib/tools.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getListOfAllChildrenFromNodes } from './tools'
|
||||
import { GraphNode } from '@/types'
|
||||
|
||||
const makeNode = (id: string, overrides?: Partial<GraphNode>): GraphNode => ({
|
||||
id,
|
||||
nodeType: 'test',
|
||||
nodeLabel: 'Test Node',
|
||||
nodeProperties: {},
|
||||
nodeSize: 1,
|
||||
nodeColor: null,
|
||||
nodeIcon: null,
|
||||
nodeImage: null,
|
||||
nodeFlag: null,
|
||||
nodeShape: null,
|
||||
nodeMetadata: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('getListOfAllChildrenFromNodes', () => {
|
||||
it('should handle empty array', () => {
|
||||
expect(getListOfAllChildrenFromNodes([])).toEqual(undefined)
|
||||
})
|
||||
|
||||
it('should handle single node without children', () => {
|
||||
expect(getListOfAllChildrenFromNodes([makeNode('node-1')])).toEqual(undefined)
|
||||
})
|
||||
|
||||
it('should handle multiple nodes', () => {
|
||||
expect(getListOfAllChildrenFromNodes([makeNode('node-1'), makeNode('node-2')])).toEqual(undefined)
|
||||
})
|
||||
|
||||
it('should handle nodes with neighbors', () => {
|
||||
const nodes = [makeNode('node-1', { neighbors: [{ id: 'node-2' }, { id: 'node-3' }] })]
|
||||
expect(getListOfAllChildrenFromNodes(nodes)).toEqual(undefined)
|
||||
})
|
||||
|
||||
it('should handle nodes with links', () => {
|
||||
const nodes = [makeNode('node-1', { links: [{ target: 'node-2' }, { target: 'node-3' }] })]
|
||||
expect(getListOfAllChildrenFromNodes(nodes)).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
3
flowsint-app/src/lib/tools.ts
Normal file
3
flowsint-app/src/lib/tools.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GraphNode } from '@/types'
|
||||
|
||||
export function getListOfAllChildrenFromNodes(_nodes: GraphNode[]) {}
|
||||
@@ -22,6 +22,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { clearIconTypeCache } from '@/components/sketches/graph/utils/image-cache'
|
||||
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'
|
||||
import { useActionItems } from '@/hooks/use-action-items'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard/custom-types/$typeId')({
|
||||
component: CustomTypeEditor
|
||||
@@ -40,11 +41,15 @@ function CustomTypeEditor() {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [status, setStatus] = useState<'draft' | 'published'>('draft')
|
||||
const [category, setCategory] = useState<string>('custom_types_category')
|
||||
const [icon, setIcon] = useState(DEFAULT_ICON)
|
||||
const [color, setColor] = useState(DEFAULT_COLOR)
|
||||
const [fields, setFields] = useState<SchemaField[]>([])
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
|
||||
// Hooks
|
||||
const { actionItems, isLoading: actionLoading } = useActionItems()
|
||||
|
||||
// Load existing type if editing
|
||||
const { data: existingType, isLoading } = useQuery<CustomType>({
|
||||
queryKey: ['custom-type', id],
|
||||
@@ -57,6 +62,7 @@ function CustomTypeEditor() {
|
||||
setName(existingType.name)
|
||||
setDescription(existingType.description || '')
|
||||
setStatus(existingType.status === 'archived' ? 'draft' : existingType.status)
|
||||
setCategory(existingType.category || 'custom_types_category')
|
||||
setIcon(existingType.icon || DEFAULT_ICON)
|
||||
setColor(existingType.color || DEFAULT_COLOR)
|
||||
parseSchemaToFields(existingType.schema)
|
||||
@@ -179,7 +185,8 @@ function CustomTypeEditor() {
|
||||
schema: fieldsToSchema(),
|
||||
icon,
|
||||
color,
|
||||
status
|
||||
status,
|
||||
category
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
@@ -287,27 +294,45 @@ function CustomTypeEditor() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground/60">Status</span>
|
||||
<Select value={status} onValueChange={(v: any) => setStatus(v)}>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs border-border/40 bg-transparent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
Draft
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="published">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Published
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-3 items-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground/60">Status</span>
|
||||
<Select value={status} onValueChange={(v: any) => setStatus(v)}>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs border-border/40 bg-transparent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
Draft
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="published">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Published
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground/60">Category</span>
|
||||
<Select value={category} onValueChange={(v: any) => setCategory(v)}>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs border-border/40 bg-transparent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!actionLoading &&
|
||||
actionItems.map((item) => (
|
||||
<SelectItem value={item.type}>
|
||||
<div className="flex items-center gap-2">{item.label}</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="opacity-40" />
|
||||
@@ -400,6 +425,7 @@ function CustomTypeEditor() {
|
||||
color={color}
|
||||
fields={fields}
|
||||
status={status}
|
||||
category={category}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { keyService } from '../api/key-service'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Input } from '../components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '../components/ui/table'
|
||||
import { Loader2, Plus, Trash2, Clock, Shield, Key, Zap, Lock, Sparkles } from 'lucide-react'
|
||||
import { Loader2, Plus, Trash2, KeyRound } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useConfirm } from '../components/use-confirm-dialog'
|
||||
import Loader from '@/components/loader'
|
||||
@@ -175,63 +174,29 @@ function VaultPage() {
|
||||
>
|
||||
<div className="w-full">
|
||||
{keys.length === 0 ? (
|
||||
<Card className="border-2 border-dashed border-primary/20 bg-gradient-to-br from-primary/5 via-background to-accent/5">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 px-8">
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
Your Vault is Empty
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
Add your first API key to unlock the power of third-party services in your
|
||||
investigations.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 pt-4 pb-6">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-primary/10 text-primary border-primary/20 px-4 py-2"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Fast Setup
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-500/10 text-emerald-700 border-emerald-200/40 px-4 py-2"
|
||||
>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
Encrypted
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-violet-500/10 text-violet-700 border-violet-200/40 px-4 py-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Secure
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setIsAddDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Your First Key
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-5">
|
||||
<KeyRound className="w-12 h-12 text-muted-foreground/40" strokeWidth={1.5} />
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-bold text-foreground">No keys yet</h3>
|
||||
<p className="text-muted-foreground max-w-xs leading-relaxed">
|
||||
Add your first API key to use third-party services in your investigations.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="overflow-hidden border bg-gradient-to-br from-background to-muted/20">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-3 text-xl">
|
||||
<div className="p-2 bg-primary rounded-lg">
|
||||
<Shield className="w-5 h-5 text-white" strokeWidth={1.9} />
|
||||
</div>
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="px-3 py-1">
|
||||
<CardTitle className="text-base font-semibold">API Keys</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keys.length} {keys.length === 1 ? 'key' : 'keys'}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="text-base mt-2">
|
||||
<CardDescription>
|
||||
Your encrypted API keys for external services. These keys will be available for your
|
||||
investigations.
|
||||
</CardDescription>
|
||||
@@ -239,58 +204,24 @@ function VaultPage() {
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/30">
|
||||
<TableHead className="py-4 px-6 text-sm font-semibold w-2/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
Name
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="py-4 text-sm font-semibold w-1/3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Created
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="py-4 px-6 text-sm font-semibold text-right w-1/5">
|
||||
Actions
|
||||
</TableHead>
|
||||
<TableRow className="border-b bg-muted/20">
|
||||
<TableHead className="py-3 px-6 w-2/5">Name</TableHead>
|
||||
<TableHead className="py-3 w-1/3">Added</TableHead>
|
||||
<TableHead className="py-3 px-6 text-right w-1/5">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key: KeyType) => (
|
||||
<TableRow
|
||||
key={key.id}
|
||||
className="group hover:bg-gradient-to-r hover:from-primary/5 hover:to-accent/5 transition-all duration-200 border-b border-border/50"
|
||||
>
|
||||
<TableCell className="py-5 px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-gradient-to-r from-primary/10 to-accent/10 border border-primary/20 rounded-lg group-hover:scale-110 transition-transform duration-200">
|
||||
<Key className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{key.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Encrypted & Secure</div>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow key={key.id} className="hover:bg-muted/30 border-b border-border/50">
|
||||
<TableCell className="py-4 px-6 font-medium">{key.name}</TableCell>
|
||||
<TableCell className="py-4 text-sm text-muted-foreground">
|
||||
{new Date(key.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="py-5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="p-1.5 bg-muted rounded-full">
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(key.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-5 px-6">
|
||||
<TableCell className="text-right py-4 px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"redis>=5.0,<6.0",
|
||||
"celery>=5.3,<6.0",
|
||||
"python-dotenv>=1.0,<2.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"requests>=2.31,<3.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"networkx>=2.6.3,<3.0.0",
|
||||
@@ -26,7 +27,7 @@ dependencies = [
|
||||
"python-multipart>=0.0.27,<0.1.0",
|
||||
"docker>=7.1.0,<8.0.0",
|
||||
"pytest>=9.0.3,<10.0.0",
|
||||
"cryptography>=46.0.7,<47.0.0",
|
||||
"cryptography>=48.0.1,<49.0.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from passlib.context import CryptContext
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
@@ -29,7 +29,7 @@ def get_password_hash(password: str) -> str:
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
@@ -361,6 +361,9 @@ class CustomType(Base):
|
||||
icon: Mapped[str] = mapped_column(String, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, server_default="draft", nullable=False)
|
||||
category: Mapped[str] = mapped_column(
|
||||
String, server_default="custom_types_category", nullable=False
|
||||
)
|
||||
checksum: Mapped[str] = mapped_column(String, nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Repository for Log model."""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -26,7 +26,7 @@ class LogRepository(BaseRepository[Log]):
|
||||
query = query.filter(Log.created_at > since)
|
||||
else:
|
||||
query = query.filter(
|
||||
Log.created_at > datetime.utcnow() - timedelta(days=1)
|
||||
Log.created_at > datetime.now(timezone.utc) - timedelta(days=1)
|
||||
)
|
||||
|
||||
return query.limit(limit).all()
|
||||
|
||||
@@ -48,6 +48,7 @@ class CustomTypeService(BaseService):
|
||||
user_id: UUID,
|
||||
description: Optional[str] = None,
|
||||
status: str = "draft",
|
||||
category: str = "custom_types_category",
|
||||
validate_schema_func=None,
|
||||
calculate_checksum_func=None,
|
||||
) -> CustomType:
|
||||
@@ -68,6 +69,7 @@ class CustomTypeService(BaseService):
|
||||
schema=json_schema,
|
||||
description=description,
|
||||
status=status,
|
||||
category=category,
|
||||
checksum=checksum,
|
||||
)
|
||||
|
||||
@@ -85,6 +87,7 @@ class CustomTypeService(BaseService):
|
||||
json_schema: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
validate_schema_func=None,
|
||||
@@ -111,6 +114,9 @@ class CustomTypeService(BaseService):
|
||||
if status is not None:
|
||||
custom_type.status = status
|
||||
|
||||
if category is not None:
|
||||
custom_type.category = category
|
||||
|
||||
if icon is not None:
|
||||
custom_type.icon = icon
|
||||
|
||||
|
||||
@@ -200,29 +200,34 @@ class TypeRegistryService(BaseService):
|
||||
else "value"
|
||||
)
|
||||
|
||||
custom_types_children.append(
|
||||
{
|
||||
"id": custom_type.id,
|
||||
"type": custom_type.name,
|
||||
"key": custom_type.name.lower(),
|
||||
"label_key": label_key,
|
||||
"icon": custom_type.icon or "custom",
|
||||
"color": custom_type.color,
|
||||
"label": custom_type.name,
|
||||
"description": custom_type.description or "",
|
||||
"fields": [
|
||||
{
|
||||
"name": prop,
|
||||
"label": info.get("title", prop),
|
||||
"description": info.get("description", ""),
|
||||
"type": "text",
|
||||
"required": prop in required,
|
||||
}
|
||||
for prop, info in properties.items()
|
||||
],
|
||||
"custom": True,
|
||||
}
|
||||
)
|
||||
custom_type_obj = {
|
||||
"id": custom_type.id,
|
||||
"type": custom_type.name,
|
||||
"key": custom_type.name.lower(),
|
||||
"label_key": label_key,
|
||||
"icon": custom_type.icon or "custom",
|
||||
"color": custom_type.color,
|
||||
"label": custom_type.name,
|
||||
"description": custom_type.description or "",
|
||||
"fields": [
|
||||
{
|
||||
"name": prop,
|
||||
"label": info.get("title", prop),
|
||||
"description": info.get("description", ""),
|
||||
"type": "text",
|
||||
"required": prop in required,
|
||||
}
|
||||
for prop, info in properties.items()
|
||||
],
|
||||
"custom": True,
|
||||
}
|
||||
|
||||
if custom_type.category == "custom_types_category":
|
||||
custom_types_children.append(custom_type_obj)
|
||||
else:
|
||||
for t in types:
|
||||
if t["type"] == custom_type.category:
|
||||
t["children"].append(custom_type_obj)
|
||||
|
||||
types.append(
|
||||
{
|
||||
@@ -435,7 +440,11 @@ class TypeRegistryService(BaseService):
|
||||
):
|
||||
return True
|
||||
# Direct array (non-optional)
|
||||
if schema.get("type") == "array" and isinstance(schema.get("items"), dict) and schema["items"].get("type") == "string":
|
||||
if (
|
||||
schema.get("type") == "array"
|
||||
and isinstance(schema.get("items"), dict)
|
||||
and schema["items"].get("type") == "string"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ def sanitize_url_component(value: str) -> str:
|
||||
class YamlLoader:
|
||||
@staticmethod
|
||||
def load_enricher_yaml(filename: str) -> dict[str, Any] | yaml.YAMLError:
|
||||
with open(filename) as stream:
|
||||
with open(filename, encoding="utf-8") as stream:
|
||||
try:
|
||||
return yaml.safe_load(stream)
|
||||
except yaml.YAMLError as exc:
|
||||
|
||||
@@ -17,6 +17,12 @@ def setup_test_environment(monkeypatch):
|
||||
# Set a test master key for vault tests
|
||||
test_key = "base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM="
|
||||
monkeypatch.setenv("MASTER_VAULT_KEY_V1", test_key)
|
||||
# Dummy Neo4j credentials: the Neo4jConnection singleton requires them
|
||||
# at construction, but driver creation is lazy — nothing connects.
|
||||
# Without these, tests silently depend on the developer's local .env.
|
||||
monkeypatch.setenv("NEO4J_URI_BOLT", "bolt://127.0.0.1:7687")
|
||||
monkeypatch.setenv("NEO4J_USERNAME", "neo4j")
|
||||
monkeypatch.setenv("NEO4J_PASSWORD", "test-password")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -7,6 +7,17 @@ from datetime import datetime, timezone
|
||||
from flowsint_core.core.graph import Neo4jGraphRepository
|
||||
|
||||
|
||||
def repo_without_connection() -> Neo4jGraphRepository:
|
||||
"""Repository with no underlying connection.
|
||||
|
||||
Constructed with a mock to avoid the constructor's singleton fallback,
|
||||
which requires NEO4J_* credentials from the environment.
|
||||
"""
|
||||
repo = Neo4jGraphRepository(neo4j_connection=MagicMock())
|
||||
repo._connection = None
|
||||
return repo
|
||||
|
||||
|
||||
class TestNeo4jGraphRepositoryInit:
|
||||
def test_init_with_connection(self):
|
||||
mock_connection = MagicMock()
|
||||
@@ -43,8 +54,7 @@ class TestCreateNode:
|
||||
mock_connection.query.assert_called_once()
|
||||
|
||||
def test_create_node_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.create_node({"nodeLabel": "test", "nodeType": "domain"}, "sketch-1")
|
||||
|
||||
@@ -80,8 +90,7 @@ class TestCreateRelationship:
|
||||
mock_connection.execute_write.assert_called_once()
|
||||
|
||||
def test_create_relationship_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
rel_obj = {
|
||||
"from_type": "domain",
|
||||
@@ -231,8 +240,7 @@ class TestBatchOperations:
|
||||
mock_connection.execute_batch.assert_not_called()
|
||||
|
||||
def test_flush_batch_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
repo._batch_operations = [("query", {})]
|
||||
|
||||
repo.flush_batch()
|
||||
@@ -285,8 +293,7 @@ class TestBatchCreateNodes:
|
||||
assert result["errors"] == []
|
||||
|
||||
def test_batch_create_nodes_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.batch_create_nodes(
|
||||
[{"nodeLabel": "test", "nodeType": "domain"}], sketch_id="sketch-1"
|
||||
@@ -338,8 +345,7 @@ class TestBatchCreateEdges:
|
||||
assert result["errors"] == []
|
||||
|
||||
def test_batch_create_edges_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.batch_create_edges([{}], sketch_id="sketch-1")
|
||||
|
||||
@@ -385,8 +391,7 @@ class TestBatchCreateEdgesByElementId:
|
||||
assert any("Missing required fields" in e for e in result["errors"])
|
||||
|
||||
def test_batch_create_edges_by_element_id_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.batch_create_edges_by_element_id([{}], sketch_id="sketch-1")
|
||||
|
||||
@@ -409,8 +414,7 @@ class TestUpdateNode:
|
||||
assert result == "elem-1"
|
||||
|
||||
def test_update_node_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.update_node("elem-1", {"nodeLabel": "x"}, "sketch-1")
|
||||
|
||||
@@ -428,8 +432,7 @@ class TestDeleteNodes:
|
||||
assert result == 3
|
||||
|
||||
def test_delete_nodes_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.delete_nodes(["id-1"], sketch_id="sketch-1")
|
||||
|
||||
@@ -456,8 +459,7 @@ class TestDeleteRelationships:
|
||||
assert result == 2
|
||||
|
||||
def test_delete_relationships_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.delete_relationships(["rel-1"], sketch_id="sketch-1")
|
||||
|
||||
@@ -483,8 +485,7 @@ class TestDeleteAllSketchNodes:
|
||||
assert result == 10
|
||||
|
||||
def test_delete_all_sketch_nodes_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.delete_all_sketch_nodes(sketch_id="sketch-1")
|
||||
|
||||
@@ -519,8 +520,7 @@ class TestGetSketchGraph:
|
||||
assert len(result["edges"]) == 1
|
||||
|
||||
def test_get_sketch_graph_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.get_sketch_graph(sketch_id="sketch-1")
|
||||
|
||||
@@ -553,8 +553,7 @@ class TestUpdateRelationship:
|
||||
assert result["id"] == "rel-1"
|
||||
|
||||
def test_update_relationship_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.update_relationship("rel-1", {}, "sketch-1")
|
||||
|
||||
@@ -577,8 +576,7 @@ class TestCreateRelationshipByElementId:
|
||||
assert result["sketch_id"] == "sketch-1"
|
||||
|
||||
def test_create_relationship_by_element_id_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.create_relationship_by_element_id(
|
||||
"elem-1", "elem-2", "CONNECTS", "sketch-1"
|
||||
@@ -598,8 +596,7 @@ class TestQuery:
|
||||
assert result == [{"count": 5}]
|
||||
|
||||
def test_query_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.query("MATCH (n) RETURN n", {})
|
||||
|
||||
@@ -622,8 +619,7 @@ class TestUpdateNodesPositions:
|
||||
assert result == 2
|
||||
|
||||
def test_update_nodes_positions_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.update_nodes_positions([{"nodeId": "x", "x": 0, "y": 0}], "s")
|
||||
|
||||
@@ -652,8 +648,7 @@ class TestGetNodesByIds:
|
||||
assert len(result) == 2
|
||||
|
||||
def test_get_nodes_by_ids_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.get_nodes_by_ids(["id-1"], sketch_id="sketch-1")
|
||||
|
||||
@@ -706,8 +701,7 @@ class TestMergeNodes:
|
||||
assert result == "old-1"
|
||||
|
||||
def test_merge_nodes_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.merge_nodes(["old-1"], {}, None, "sketch-1")
|
||||
|
||||
@@ -764,8 +758,7 @@ class TestGetNeighbors:
|
||||
assert len(result["edges"]) == 0
|
||||
|
||||
def test_get_neighbors_no_connection(self):
|
||||
repo = Neo4jGraphRepository(neo4j_connection=None)
|
||||
repo._connection = None
|
||||
repo = repo_without_connection()
|
||||
|
||||
result = repo.get_neighbors("node-1", "sketch-1")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flowsint_types import Domain, Ip, Phone
|
||||
from flowsint_types import Domain, File, Ip, Phone
|
||||
|
||||
from flowsint_core.imports import detect_type
|
||||
|
||||
@@ -40,6 +40,20 @@ def test_detection_ips():
|
||||
assert results[1].address == "12.43.23.12"
|
||||
|
||||
|
||||
def test_detection_file_hashes():
|
||||
hashes = {
|
||||
"d41d8cd98f00b204e9800998ecf8427e": "hash_md5",
|
||||
"da39a3ee5e6b4b0d3255bfef95601890afd80709": "hash_sha1",
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "hash_sha256",
|
||||
}
|
||||
for value, field in hashes.items():
|
||||
DetectedType = detect_type(value)
|
||||
assert DetectedType is File, f"{value} should be detected as File"
|
||||
obj = DetectedType.from_string(value)
|
||||
assert isinstance(obj, File)
|
||||
assert getattr(obj, field) == value
|
||||
|
||||
|
||||
def test_detection_mixed():
|
||||
raw_obj = ["240.123.123.234", "my.domain.io", "+33632233223"]
|
||||
results = []
|
||||
|
||||
135
flowsint-enrichers/src/flowsint_enrichers/domain/to_dns.py
Normal file
135
flowsint-enrichers/src/flowsint_enrichers/domain/to_dns.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from flowsint_core.core.logger import Logger
|
||||
from flowsint_core.core.enricher_base import Enricher
|
||||
from flowsint_enrichers.registry import flowsint_enricher
|
||||
from flowsint_types.domain import Domain
|
||||
from flowsint_types.ip import Ip
|
||||
from tools.network.dnsx import DnsxTool
|
||||
|
||||
|
||||
@flowsint_enricher
|
||||
class DomainToDnsEnricher(Enricher):
|
||||
"""[DNSX] Resolve a domain's A (IPv4) and AAAA (IPv6) records using dnsx."""
|
||||
|
||||
InputType = Domain
|
||||
OutputType = Ip
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sketch_id: Optional[str] = None,
|
||||
scan_id: Optional[str] = None,
|
||||
vault=None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
sketch_id=sketch_id,
|
||||
scan_id=scan_id,
|
||||
params_schema=self.get_params_schema(),
|
||||
vault=vault,
|
||||
params=params,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "domain_to_dns"
|
||||
|
||||
@classmethod
|
||||
def category(cls) -> str:
|
||||
return "Domain"
|
||||
|
||||
@classmethod
|
||||
def key(cls) -> str:
|
||||
return "domain"
|
||||
|
||||
@classmethod
|
||||
def get_params_schema(cls) -> List[Dict[str, Any]]:
|
||||
"""Declare parameters for this enricher."""
|
||||
return [
|
||||
{
|
||||
"name": "ipv6",
|
||||
"type": "select",
|
||||
"description": "Also resolve AAAA (IPv6) records",
|
||||
"required": False,
|
||||
"default": "true",
|
||||
"options": [
|
||||
{"label": "Enabled", "value": "true"},
|
||||
{"label": "Disabled", "value": "false"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PDCP_API_KEY",
|
||||
"type": "vaultSecret",
|
||||
"description": "ProjectDiscovery Cloud Platform API key (optional)",
|
||||
"required": False,
|
||||
},
|
||||
]
|
||||
|
||||
async def scan(self, data: List[InputType]) -> List[OutputType]:
|
||||
results: List[OutputType] = []
|
||||
|
||||
try:
|
||||
dnsx = DnsxTool()
|
||||
except Exception as e:
|
||||
Logger.error(
|
||||
self.sketch_id,
|
||||
{"message": f"[DNSX] Failed to initialize dnsx: {e}"},
|
||||
)
|
||||
return results
|
||||
|
||||
aaaa = self.params.get("ipv6", "true") == "true"
|
||||
api_key = self.get_secret("PDCP_API_KEY", None)
|
||||
|
||||
for d in data:
|
||||
try:
|
||||
ips = dnsx.resolve_domain(d.domain, aaaa=aaaa, api_key=api_key)
|
||||
except Exception as e:
|
||||
Logger.error(
|
||||
self.sketch_id,
|
||||
{"message": f"[DNSX] Error resolving {d.domain}: {e}"},
|
||||
)
|
||||
continue
|
||||
|
||||
for ip in ips:
|
||||
try:
|
||||
ip_obj = Ip(address=ip)
|
||||
except Exception:
|
||||
# dnsx occasionally emits non-address artifacts; skip them.
|
||||
continue
|
||||
# Carry the source domain through to postprocess for graph wiring.
|
||||
setattr(ip_obj, "_source_domain", d.domain)
|
||||
results.append(ip_obj)
|
||||
Logger.info(
|
||||
self.sketch_id,
|
||||
{"message": f"[DNSX] {d.domain} -> {ip}"},
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def postprocess(
|
||||
self, results: List[OutputType], original_input: List[InputType] = None
|
||||
) -> List[OutputType]:
|
||||
for ip_obj in results:
|
||||
if not self._graph_service:
|
||||
continue
|
||||
source_domain = getattr(ip_obj, "_source_domain", None)
|
||||
if not source_domain:
|
||||
continue
|
||||
|
||||
domain_obj = Domain(domain=source_domain)
|
||||
self.create_node(domain_obj)
|
||||
self.create_node(ip_obj)
|
||||
self.create_relationship(domain_obj, ip_obj, "RESOLVES_TO")
|
||||
self.log_graph_message(
|
||||
f"IP found for domain {source_domain} -> {ip_obj.address}"
|
||||
)
|
||||
|
||||
# Clean up the temporary attribute used to thread context.
|
||||
delattr(ip_obj, "_source_domain")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
InputType = DomainToDnsEnricher.InputType
|
||||
OutputType = DomainToDnsEnricher.OutputType
|
||||
@@ -124,7 +124,13 @@ class IpToFraudScore(Enricher):
|
||||
proxy_flags.append(label)
|
||||
|
||||
risk_profile = RiskProfile(
|
||||
entity_id=ip.address, entity_type="IP address", overall_risk_score=fraud_score, risk_level=fraud_risk, last_updated=datetime.datetime.utcnow().isoformat(), risk_factors=proxy_flags, source="Scamalytics"
|
||||
entity_id=ip.address,
|
||||
entity_type="IP address",
|
||||
overall_risk_score=fraud_score,
|
||||
risk_level=fraud_risk,
|
||||
last_updated=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
risk_factors=proxy_flags,
|
||||
source="Scamalytics",
|
||||
)
|
||||
results.append(risk_profile)
|
||||
self.ip_risk_mapping.append((ip, risk_profile))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from flowsint_core.core.logger import Logger
|
||||
from flowsint_core.core.enricher_base import Enricher
|
||||
from flowsint_enrichers.registry import flowsint_enricher
|
||||
from flowsint_types.website import Website
|
||||
from flowsint_types.technology import Technology
|
||||
from tools.network.httpx import HttpxTool
|
||||
|
||||
|
||||
@flowsint_enricher
|
||||
class TechDetectEnricher(Enricher):
|
||||
"""[HTTPX] Detect web technologies on a website via httpx (wappalyzer)."""
|
||||
|
||||
InputType = Website
|
||||
OutputType = Technology
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sketch_id: Optional[str] = None,
|
||||
scan_id: Optional[str] = None,
|
||||
vault=None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
sketch_id=sketch_id,
|
||||
scan_id=scan_id,
|
||||
params_schema=self.get_params_schema(),
|
||||
vault=vault,
|
||||
params=params,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "tech_detect"
|
||||
|
||||
@classmethod
|
||||
def category(cls) -> str:
|
||||
return "Website"
|
||||
|
||||
@classmethod
|
||||
def key(cls) -> str:
|
||||
return "website"
|
||||
|
||||
@staticmethod
|
||||
def _parse_tech(entry: str) -> Optional[Technology]:
|
||||
"""Turn an httpx tech entry into a Technology.
|
||||
|
||||
httpx -td emits plain names ("nginx") and, when wappalyzer knows
|
||||
the version, "name:version" pairs ("PHP:8.1").
|
||||
"""
|
||||
entry = (entry or "").strip()
|
||||
if not entry:
|
||||
return None
|
||||
name, sep, version = entry.partition(":")
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return None
|
||||
return Technology(
|
||||
name=name,
|
||||
version=(version.strip() or None) if sep else None,
|
||||
source="httpx",
|
||||
)
|
||||
|
||||
async def scan(self, data: List[InputType]) -> List[OutputType]:
|
||||
results: List[OutputType] = []
|
||||
|
||||
try:
|
||||
httpx = HttpxTool()
|
||||
except Exception as e:
|
||||
Logger.error(
|
||||
self.sketch_id,
|
||||
{"message": f"[HTTPX] Failed to initialize httpx: {e}"},
|
||||
)
|
||||
return results
|
||||
|
||||
for website in data:
|
||||
url = str(website.url)
|
||||
try:
|
||||
probes = httpx.launch(url, args=["-td"])
|
||||
except Exception as e:
|
||||
Logger.error(
|
||||
self.sketch_id,
|
||||
{"message": f"[HTTPX] Error probing {url}: {e}"},
|
||||
)
|
||||
continue
|
||||
|
||||
seen: set[tuple[str, Optional[str]]] = set()
|
||||
for probe in probes:
|
||||
for entry in probe.get("tech", []) or []:
|
||||
tech = self._parse_tech(entry)
|
||||
if tech is None:
|
||||
continue
|
||||
dedup_key = (tech.name, tech.version)
|
||||
if dedup_key in seen:
|
||||
continue
|
||||
seen.add(dedup_key)
|
||||
# Carry the source website through to postprocess.
|
||||
setattr(tech, "_source_url", url)
|
||||
results.append(tech)
|
||||
Logger.info(
|
||||
self.sketch_id,
|
||||
{"message": f"[HTTPX] {url} -> {tech.nodeLabel}"},
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def postprocess(
|
||||
self, results: List[OutputType], original_input: List[InputType] = None
|
||||
) -> List[OutputType]:
|
||||
for tech in results:
|
||||
if not self._graph_service:
|
||||
continue
|
||||
source_url = getattr(tech, "_source_url", None)
|
||||
if not source_url:
|
||||
continue
|
||||
|
||||
website_obj = Website(url=source_url)
|
||||
self.create_node(website_obj)
|
||||
self.create_node(tech)
|
||||
self.create_relationship(website_obj, tech, "USES_TECHNOLOGY")
|
||||
self.log_graph_message(
|
||||
f"Technology {tech.nodeLabel} detected on {source_url}"
|
||||
)
|
||||
|
||||
delattr(tech, "_source_url")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
InputType = TechDetectEnricher.InputType
|
||||
OutputType = TechDetectEnricher.OutputType
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import Any, List
|
||||
from typing import List
|
||||
from ..dockertool import DockerTool
|
||||
|
||||
|
||||
@@ -86,3 +86,72 @@ class DnsxTool(DockerTool):
|
||||
raise RuntimeError(
|
||||
f"Error running dnsx: {str(e)}. Output: {getattr(e, 'output', 'No output')}"
|
||||
)
|
||||
|
||||
def resolve_domain(
|
||||
self, domain: str, aaaa: bool = True, api_key: str = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
Resolve a domain's A (IPv4) and, optionally, AAAA (IPv6) records.
|
||||
|
||||
Runs ``dnsx -d <domain> -a [-aaaa] -json -silent`` and parses the JSONL
|
||||
output, returning the de-duplicated list of resolved IP addresses.
|
||||
|
||||
Args:
|
||||
domain: Domain name to resolve (e.g., "example.com")
|
||||
aaaa: Whether to also query AAAA (IPv6) records. Defaults to True.
|
||||
api_key: Optional ProjectDiscovery Cloud Platform API key
|
||||
|
||||
Returns:
|
||||
Ordered, de-duplicated list of resolved IP addresses (A then AAAA).
|
||||
"""
|
||||
flags = ["-a"]
|
||||
if aaaa:
|
||||
flags.append("-aaaa")
|
||||
flags += ["-json", "-silent"]
|
||||
|
||||
command = f"-d {domain} {' '.join(flags)}"
|
||||
|
||||
env = {}
|
||||
if api_key:
|
||||
env["PDCP_API_KEY"] = api_key
|
||||
|
||||
try:
|
||||
result = super().launch(command, environment=env)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Error running dnsx: {str(e)}. Output: {getattr(e, 'output', 'No output')}"
|
||||
)
|
||||
|
||||
return self._parse_resolved_ips(result)
|
||||
|
||||
@staticmethod
|
||||
def _parse_resolved_ips(result: str) -> List[str]:
|
||||
"""
|
||||
Parse dnsx JSONL output into an ordered, de-duplicated list of IPs.
|
||||
|
||||
Each line is a JSON object whose ``a``/``aaaa`` keys hold the resolved
|
||||
IPv4/IPv6 addresses (see retryabledns.DNSData). Malformed lines are
|
||||
skipped so a single bad record never breaks the whole resolution.
|
||||
"""
|
||||
ips: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if not result or not result.strip():
|
||||
return ips
|
||||
|
||||
for line in result.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
for key in ("a", "aaaa"):
|
||||
for ip in record.get(key, []) or []:
|
||||
if ip and ip not in seen:
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
|
||||
return ips
|
||||
|
||||
@@ -2,6 +2,19 @@ import pytest
|
||||
from tests.logger import TestLogger
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_environment(monkeypatch):
|
||||
"""Set up test environment variables.
|
||||
|
||||
Dummy Neo4j credentials: the Neo4jConnection singleton requires them
|
||||
at construction, but driver creation is lazy — nothing connects.
|
||||
Without these, tests silently depend on the developer's local .env.
|
||||
"""
|
||||
monkeypatch.setenv("NEO4J_URI_BOLT", "bolt://127.0.0.1:7687")
|
||||
monkeypatch.setenv("NEO4J_USERNAME", "neo4j")
|
||||
monkeypatch.setenv("NEO4J_PASSWORD", "test-password")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_logger(monkeypatch):
|
||||
"""Automatically replace the production Logger with TestLogger for all tests."""
|
||||
|
||||
131
flowsint-enrichers/tests/enrichers/test_domain_to_dns.py
Normal file
131
flowsint-enrichers/tests/enrichers/test_domain_to_dns.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import pytest
|
||||
|
||||
from flowsint_enrichers import ENRICHER_REGISTRY
|
||||
from flowsint_enrichers.domain.to_dns import DomainToDnsEnricher
|
||||
from flowsint_types.ip import Ip
|
||||
from tools.network.dnsx import DnsxTool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_domain_to_dns_is_registered():
|
||||
enricher = ENRICHER_REGISTRY.get_enricher("domain_to_dns", "123", "123")
|
||||
assert enricher.name() == "domain_to_dns"
|
||||
|
||||
|
||||
def test_domain_to_dns_metadata():
|
||||
assert DomainToDnsEnricher.category() == "Domain"
|
||||
assert DomainToDnsEnricher.key() == "domain"
|
||||
assert DomainToDnsEnricher.input_schema()["type"] == "Domain"
|
||||
assert DomainToDnsEnricher.output_schema()["type"] == "Ip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dnsx JSONL parsing (no Docker required)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_resolved_ips_extracts_a_and_aaaa():
|
||||
output = (
|
||||
'{"host":"example.com","a":["93.184.216.34"],'
|
||||
'"aaaa":["2606:2800:220:1:248:1893:25c8:1946"]}'
|
||||
)
|
||||
assert DnsxTool._parse_resolved_ips(output) == [
|
||||
"93.184.216.34",
|
||||
"2606:2800:220:1:248:1893:25c8:1946",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_resolved_ips_dedupes_and_preserves_order():
|
||||
output = "\n".join(
|
||||
[
|
||||
'{"host":"a.com","a":["1.1.1.1","1.0.0.1"]}',
|
||||
'{"host":"a.com","a":["1.1.1.1"],"aaaa":["2606:4700:4700::1111"]}',
|
||||
]
|
||||
)
|
||||
assert DnsxTool._parse_resolved_ips(output) == [
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_resolved_ips_skips_blank_and_malformed_lines():
|
||||
output = "\n".join(
|
||||
[
|
||||
"",
|
||||
"not-json",
|
||||
'{"host":"a.com","a":["8.8.8.8"]}',
|
||||
" ",
|
||||
]
|
||||
)
|
||||
assert DnsxTool._parse_resolved_ips(output) == ["8.8.8.8"]
|
||||
|
||||
|
||||
def test_parse_resolved_ips_handles_empty_output():
|
||||
assert DnsxTool._parse_resolved_ips("") == []
|
||||
assert DnsxTool._parse_resolved_ips(" \n ") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scan() — dnsx tool mocked, no Docker daemon touched
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeDnsx:
|
||||
def __init__(self, mapping):
|
||||
self._mapping = mapping
|
||||
self.calls = []
|
||||
|
||||
def resolve_domain(self, domain, aaaa=True, api_key=None):
|
||||
self.calls.append((domain, aaaa, api_key))
|
||||
return self._mapping.get(domain, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_returns_ip_objects_tagged_with_source_domain(monkeypatch):
|
||||
fake = _FakeDnsx({"example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]})
|
||||
monkeypatch.setattr(
|
||||
"flowsint_enrichers.domain.to_dns.DnsxTool", lambda: fake
|
||||
)
|
||||
|
||||
enricher = DomainToDnsEnricher(sketch_id="s", scan_id="t", graph_service=None)
|
||||
from flowsint_types.domain import Domain
|
||||
|
||||
results = await enricher.scan([Domain(domain="example.com")])
|
||||
|
||||
assert {ip.address for ip in results} == {
|
||||
"93.184.216.34",
|
||||
"2606:2800:220:1:248:1893:25c8:1946",
|
||||
}
|
||||
assert all(isinstance(ip, Ip) for ip in results)
|
||||
assert all(getattr(ip, "_source_domain") == "example.com" for ip in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_ipv6_param_disables_aaaa(monkeypatch):
|
||||
fake = _FakeDnsx({"example.com": ["93.184.216.34"]})
|
||||
monkeypatch.setattr(
|
||||
"flowsint_enrichers.domain.to_dns.DnsxTool", lambda: fake
|
||||
)
|
||||
|
||||
enricher = DomainToDnsEnricher(
|
||||
sketch_id="s", scan_id="t", graph_service=None, params={"ipv6": "false"}
|
||||
)
|
||||
from flowsint_types.domain import Domain
|
||||
|
||||
await enricher.scan([Domain(domain="example.com")])
|
||||
|
||||
# aaaa flag forwarded to the tool as False
|
||||
assert fake.calls == [("example.com", False, None)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_skips_unresolvable_domain(monkeypatch):
|
||||
fake = _FakeDnsx({}) # no records for anything
|
||||
monkeypatch.setattr(
|
||||
"flowsint_enrichers.domain.to_dns.DnsxTool", lambda: fake
|
||||
)
|
||||
|
||||
enricher = DomainToDnsEnricher(sketch_id="s", scan_id="t", graph_service=None)
|
||||
from flowsint_types.domain import Domain
|
||||
|
||||
results = await enricher.scan([Domain(domain="example.com")])
|
||||
assert results == []
|
||||
112
flowsint-enrichers/tests/enrichers/test_tech_detect.py
Normal file
112
flowsint-enrichers/tests/enrichers/test_tech_detect.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
|
||||
from flowsint_enrichers import ENRICHER_REGISTRY
|
||||
from flowsint_enrichers.website.to_technologies import TechDetectEnricher
|
||||
from flowsint_types.technology import Technology
|
||||
from flowsint_types.website import Website
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Technology type
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_technology_label_without_version():
|
||||
assert Technology(name="nginx").nodeLabel == "nginx"
|
||||
|
||||
|
||||
def test_technology_label_with_version():
|
||||
assert Technology(name="PHP", version="8.1").nodeLabel == "PHP 8.1"
|
||||
|
||||
|
||||
def test_technology_from_string_plain_and_versioned():
|
||||
assert Technology.from_string("nginx").version is None
|
||||
t = Technology.from_string("PHP:8.1")
|
||||
assert (t.name, t.version) == ("PHP", "8.1")
|
||||
|
||||
|
||||
def test_technology_is_in_type_registry():
|
||||
from flowsint_types import TYPE_REGISTRY
|
||||
|
||||
assert TYPE_REGISTRY.get_lowercase("technology") is Technology
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tech_detect_is_registered():
|
||||
enricher = ENRICHER_REGISTRY.get_enricher("tech_detect", "123", "123")
|
||||
assert enricher.name() == "tech_detect"
|
||||
|
||||
|
||||
def test_tech_detect_metadata():
|
||||
assert TechDetectEnricher.category() == "Website"
|
||||
assert TechDetectEnricher.key() == "website"
|
||||
assert TechDetectEnricher.input_schema()["type"] == "Website"
|
||||
assert TechDetectEnricher.output_schema()["type"] == "Technology"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_tech
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_tech_plain():
|
||||
t = TechDetectEnricher._parse_tech("nginx")
|
||||
assert (t.name, t.version, t.source) == ("nginx", None, "httpx")
|
||||
|
||||
|
||||
def test_parse_tech_versioned():
|
||||
t = TechDetectEnricher._parse_tech("PHP:8.1")
|
||||
assert (t.name, t.version) == ("PHP", "8.1")
|
||||
|
||||
|
||||
def test_parse_tech_blank_returns_none():
|
||||
assert TechDetectEnricher._parse_tech("") is None
|
||||
assert TechDetectEnricher._parse_tech(" ") is None
|
||||
assert TechDetectEnricher._parse_tech(":1.0") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scan() — httpx mocked, no Docker daemon touched
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeHttpx:
|
||||
def __init__(self, by_url):
|
||||
self._by_url = by_url
|
||||
self.calls = []
|
||||
|
||||
def launch(self, target, args=None):
|
||||
self.calls.append((target, args))
|
||||
return self._by_url.get(target, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_extracts_and_dedupes_technologies(monkeypatch):
|
||||
fake = _FakeHttpx(
|
||||
{
|
||||
"https://example.com/": [
|
||||
{"tech": ["nginx", "PHP:8.1", "nginx"]}, # duplicate nginx
|
||||
]
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"flowsint_enrichers.website.to_technologies.HttpxTool", lambda: fake
|
||||
)
|
||||
|
||||
enricher = TechDetectEnricher(sketch_id="s", scan_id="t", graph_service=None)
|
||||
results = await enricher.scan([Website(url="https://example.com/")])
|
||||
|
||||
labels = sorted(t.nodeLabel for t in results)
|
||||
assert labels == ["PHP 8.1", "nginx"]
|
||||
assert all(isinstance(t, Technology) for t in results)
|
||||
assert all(getattr(t, "_source_url") == "https://example.com/" for t in results)
|
||||
# -td flag forwarded to httpx
|
||||
assert fake.calls == [("https://example.com/", ["-td"])]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_handles_no_tech_field(monkeypatch):
|
||||
fake = _FakeHttpx({"https://example.com/": [{"status_code": 200}]})
|
||||
monkeypatch.setattr(
|
||||
"flowsint_enrichers.website.to_technologies.HttpxTool", lambda: fake
|
||||
)
|
||||
|
||||
enricher = TechDetectEnricher(sketch_id="s", scan_id="t", graph_service=None)
|
||||
results = await enricher.scan([Website(url="https://example.com/")])
|
||||
assert results == []
|
||||
@@ -43,6 +43,7 @@ from .script import Script
|
||||
from .session import Session
|
||||
from .social_account import SocialAccount
|
||||
from .ssl_certificate import SSLCertificate
|
||||
from .technology import Technology
|
||||
from .username import Username
|
||||
from .wallet import CryptoNFT, CryptoWallet, CryptoWalletTransaction
|
||||
from .weapon import Weapon
|
||||
@@ -87,6 +88,7 @@ __all__ = [
|
||||
"Session",
|
||||
"SocialAccount",
|
||||
"SSLCertificate",
|
||||
"Technology",
|
||||
"Username",
|
||||
"CryptoWallet",
|
||||
"CryptoWalletTransaction",
|
||||
@@ -134,6 +136,7 @@ TYPE_TO_MODEL: Dict[str, Type[BaseModel]] = {
|
||||
"file": File,
|
||||
"malware": Malware,
|
||||
"sslcertificate": SSLCertificate,
|
||||
"technology": Technology,
|
||||
"location": Location,
|
||||
"affiliation": Affiliation,
|
||||
"alias": Alias,
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import re
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from typing import Optional, List, Self
|
||||
from typing import Optional, Self
|
||||
|
||||
from .flowsint_base import FlowsintType
|
||||
from .registry import flowsint_type
|
||||
|
||||
# Map of hex-hash length -> the File field that should hold it.
|
||||
_HASH_LENGTH_TO_FIELD = {
|
||||
32: "hash_md5",
|
||||
40: "hash_sha1",
|
||||
64: "hash_sha256",
|
||||
}
|
||||
_HASH_RE = re.compile(r"^[0-9a-fA-F]+$")
|
||||
|
||||
|
||||
def _hash_field_for(value: str) -> Optional[str]:
|
||||
"""Return the File hash field name for an MD5/SHA1/SHA256 hex string, else None."""
|
||||
value = value.strip()
|
||||
if not _HASH_RE.match(value):
|
||||
return None
|
||||
return _HASH_LENGTH_TO_FIELD.get(len(value))
|
||||
|
||||
|
||||
@flowsint_type
|
||||
class File(FlowsintType):
|
||||
@@ -73,10 +91,22 @@ class File(FlowsintType):
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, line: str):
|
||||
"""Parse a file from a raw string."""
|
||||
return cls(filename=line.strip())
|
||||
"""Parse a file from a raw string.
|
||||
|
||||
If the value is an MD5/SHA1/SHA256 hash, store it in the matching
|
||||
hash field as well as the filename (so the node keeps a label).
|
||||
"""
|
||||
line = line.strip()
|
||||
hash_field = _hash_field_for(line)
|
||||
if hash_field:
|
||||
return cls(filename=line, **{hash_field: line.lower()})
|
||||
return cls(filename=line)
|
||||
|
||||
@classmethod
|
||||
def detect(cls, line: str) -> bool:
|
||||
"""File cannot be reliably detected from a single line of text."""
|
||||
return False
|
||||
"""Detect a file hash (MD5 / SHA1 / SHA256) from a single line.
|
||||
|
||||
Only hashes are auto-detected; arbitrary filenames are too ambiguous
|
||||
to detect reliably, so non-hash values still return False.
|
||||
"""
|
||||
return _hash_field_for(line) is not None
|
||||
|
||||
@@ -17,13 +17,15 @@ class SocialAccount(FlowsintType):
|
||||
title="ID",
|
||||
json_schema_extra={"primary": True},
|
||||
)
|
||||
username: Username = Field(
|
||||
..., description="Username associated with this account", title="Username"
|
||||
username: Optional[Username] = Field(
|
||||
None, description="Username associated with this account", title="Username"
|
||||
)
|
||||
|
||||
@field_validator("username", mode="before")
|
||||
@classmethod
|
||||
def convert_username(cls, v: Union[str, Username]) -> Username:
|
||||
if not v:
|
||||
return Username(value="")
|
||||
"""Convert string to Username object if needed."""
|
||||
if isinstance(v, str):
|
||||
return Username(value=v)
|
||||
|
||||
57
flowsint-types/src/flowsint_types/technology.py
Normal file
57
flowsint-types/src/flowsint_types/technology.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Optional, Self
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .flowsint_base import FlowsintType
|
||||
from .registry import flowsint_type
|
||||
|
||||
|
||||
@flowsint_type
|
||||
class Technology(FlowsintType):
|
||||
"""Represents a technology, framework, or software detected during recon."""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Technology name (e.g. nginx, PHP, React)",
|
||||
title="Technology Name",
|
||||
json_schema_extra={"primary": True},
|
||||
)
|
||||
version: Optional[str] = Field(
|
||||
None, description="Detected version, if known", title="Version"
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="Technology category (e.g. web-server, framework, cms)",
|
||||
title="Category",
|
||||
)
|
||||
source: Optional[str] = Field(
|
||||
None,
|
||||
description="Tool or method that detected the technology (e.g. httpx)",
|
||||
title="Source",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_label(self) -> Self:
|
||||
if self.version:
|
||||
self.nodeLabel = f"{self.name} {self.version}"
|
||||
else:
|
||||
self.nodeLabel = self.name
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, line: str):
|
||||
"""Parse a technology from a raw string.
|
||||
|
||||
Accepts either a bare name ("nginx") or a wappalyzer-style
|
||||
"name:version" pair ("PHP:8.1"), as emitted by httpx -td.
|
||||
"""
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
name, _, version = line.partition(":")
|
||||
return cls(name=name.strip(), version=version.strip() or None)
|
||||
return cls(name=line)
|
||||
|
||||
@classmethod
|
||||
def detect(cls, line: str) -> bool:
|
||||
"""Technology cannot be reliably detected from a single line of text."""
|
||||
return False
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Any, Optional, Self
|
||||
from typing import Optional, Self
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
@@ -60,6 +60,11 @@ class Username(FlowsintType):
|
||||
if not line:
|
||||
return False
|
||||
|
||||
# Don't claim file hashes (MD5/SHA1/SHA256) — those are File entities.
|
||||
# This keeps hash detection independent of type-registration order.
|
||||
if len(line) in (32, 40, 64) and re.fullmatch(r"[0-9a-fA-F]+", line):
|
||||
return False
|
||||
|
||||
# Username pattern: 3-80 characters, only letters, numbers, underscores, hyphens
|
||||
# Note: This is intentionally restrictive to avoid false positives
|
||||
return bool(re.match(r"^[a-zA-Z0-9_-]{3,80}$", line))
|
||||
|
||||
64
flowsint-types/tests/test_file.py
Normal file
64
flowsint-types/tests/test_file.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for File hash detection (issue #45)."""
|
||||
|
||||
from flowsint_types import File, Username
|
||||
|
||||
MD5 = "d41d8cd98f00b204e9800998ecf8427e" # 32 hex
|
||||
SHA1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709" # 40 hex
|
||||
SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" # 64 hex
|
||||
|
||||
|
||||
def test_detect_md5():
|
||||
assert File.detect(MD5) is True
|
||||
|
||||
|
||||
def test_detect_sha1():
|
||||
assert File.detect(SHA1) is True
|
||||
|
||||
|
||||
def test_detect_sha256():
|
||||
assert File.detect(SHA256) is True
|
||||
|
||||
|
||||
def test_detect_is_case_insensitive():
|
||||
assert File.detect(SHA256.upper()) is True
|
||||
|
||||
|
||||
def test_detect_strips_whitespace():
|
||||
assert File.detect(f" {MD5} ") is True
|
||||
|
||||
|
||||
def test_detect_rejects_non_hash():
|
||||
# Plain filename, wrong length, and non-hex strings are not detected.
|
||||
assert File.detect("report.pdf") is False
|
||||
assert File.detect("john_doe") is False
|
||||
assert File.detect("g" * 32) is False # right length, not hex
|
||||
assert File.detect("abc123") is False # hex but not a hash length
|
||||
|
||||
|
||||
def test_from_string_populates_hash_fields():
|
||||
assert File.from_string(MD5).hash_md5 == MD5
|
||||
assert File.from_string(SHA1).hash_sha1 == SHA1
|
||||
assert File.from_string(SHA256).hash_sha256 == SHA256
|
||||
|
||||
|
||||
def test_from_string_normalizes_hash_to_lowercase():
|
||||
f = File.from_string(SHA256.upper())
|
||||
assert f.hash_sha256 == SHA256 # stored lowercase
|
||||
assert f.filename == SHA256.upper() # original kept as label
|
||||
|
||||
|
||||
def test_from_string_plain_filename_unchanged():
|
||||
f = File.from_string("evil.exe")
|
||||
assert f.filename == "evil.exe"
|
||||
assert f.hash_md5 is None
|
||||
assert f.hash_sha1 is None
|
||||
assert f.hash_sha256 is None
|
||||
|
||||
|
||||
def test_username_no_longer_claims_hashes():
|
||||
# Regression: hashes used to be mis-detected as usernames.
|
||||
assert Username.detect(MD5) is False
|
||||
assert Username.detect(SHA1) is False
|
||||
assert Username.detect(SHA256) is False
|
||||
# Real usernames still detected.
|
||||
assert Username.detect("john_doe") is True
|
||||
92
uv.lock
generated
92
uv.lock
generated
@@ -701,55 +701,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1205,6 +1205,7 @@ dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlalchemy" },
|
||||
@@ -1228,7 +1229,7 @@ requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.30,<0.31" },
|
||||
{ name = "bcrypt", specifier = ">=4.0.0,<5.0.0" },
|
||||
{ name = "celery", specifier = ">=5.3,<6.0" },
|
||||
{ name = "cryptography", specifier = ">=46.0.7,<47.0.0" },
|
||||
{ name = "cryptography", specifier = ">=48.0.1,<49.0.0" },
|
||||
{ name = "docker", specifier = ">=7.1.0,<8.0.0" },
|
||||
{ name = "flowsint-enrichers", editable = "flowsint-enrichers" },
|
||||
{ name = "httpx", specifier = ">=0.28,<0.29" },
|
||||
@@ -1243,6 +1244,7 @@ requires-dist = [
|
||||
{ name = "python-dotenv", specifier = ">=1.0,<2.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3,<4.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<0.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "redis", specifier = ">=5.0,<6.0" },
|
||||
{ name = "requests", specifier = ">=2.31,<3.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0,<3.0" },
|
||||
|
||||
@@ -4105,7 +4105,7 @@ dom-helpers@^5.0.1:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
csstype "^3.0.2"
|
||||
|
||||
dompurify@*, dompurify@^3.3.0:
|
||||
dompurify@*:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz"
|
||||
integrity sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==
|
||||
@@ -4119,6 +4119,13 @@ dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
dompurify@^3.4.11:
|
||||
version "3.4.11"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.11.tgz#29c8ba496475f279ef4015784068452fb14a0680"
|
||||
integrity sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
dot-prop@^5.1.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user