flowforge-map 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Key finders (D31, CLAUDE.md §6.4 step 1): pure functions over one file's text, no I/O.
|
|
2
|
+
|
|
3
|
+
Two ways a key shows: a known shape (`KEY_PATTERNS`, on any line, comments included), or a high-entropy string given
|
|
4
|
+
to a key-like name (Python by the AST, JS/TS and Dart by patterns). A hit holds the line, what kind of key it is and
|
|
5
|
+
the name it is given to, never the value. Pattern-based: known key formats and these shapes, not everything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from flowforge.security.logfilter import KEY_PATTERNS
|
|
17
|
+
|
|
18
|
+
# Names for KEY_PATTERNS, in the same order
|
|
19
|
+
KEY_SHAPES = list(zip(
|
|
20
|
+
("private key", "Stripe key", "Razorpay key", "Google API key", "NVIDIA key", "Anthropic key", "OpenAI-style key",
|
|
21
|
+
"token (JWT)"),
|
|
22
|
+
KEY_PATTERNS, strict=True))
|
|
23
|
+
PEM_START = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
|
|
24
|
+
SECRET_PARTS = frozenset({"key", "apikey", "token", "secret", "password", "passwd", "pwd", "credential", "credentials"})
|
|
25
|
+
MIN_LENGTH = 16
|
|
26
|
+
MIN_ENTROPY = 3.5 # bits per character
|
|
27
|
+
_PLACEHOLDER = re.compile(r"your|example|sample|placeholder|changeme|change_me|replace|dummy|todo|<[^>]*>|\$\{|^\$|"
|
|
28
|
+
r"^(.)\1+$|x{4,}|\*{3,}|\.\.\.", re.IGNORECASE)
|
|
29
|
+
_KEY_CHARS = re.compile(r"^[A-Za-z0-9_\-+=]+$")
|
|
30
|
+
_ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
31
|
+
_WORDS = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+")
|
|
32
|
+
# JS/TS/Dart: a name, then `=` or `:`, then a quoted string on the same line
|
|
33
|
+
_GIVEN = re.compile(r"""\b([A-Za-z_$][\w$]*)["']?\s*(?::\s*[\w<>?, ]+\s*)?[:=]\s*(['"`])((?:(?!\2).)*)\2""")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class KeyHit:
|
|
38
|
+
line: int
|
|
39
|
+
label: str # "Google API key", or "secret" when only entropy and the name tell
|
|
40
|
+
name: str | None = None # the variable, key or argument it is given to
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def entropy(value: str) -> float:
|
|
44
|
+
counts = Counter(value)
|
|
45
|
+
return -sum(n / len(value) * math.log2(n / len(value)) for n in counts.values())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_placeholder(value: str) -> bool:
|
|
49
|
+
return bool(_PLACEHOLDER.search(value))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_secret_name(name: str) -> bool:
|
|
53
|
+
"""`apiKey`, `api_key`, `SUPABASE_KEY`, `accessToken`: a part of the name is a key word; `keyboard` is not."""
|
|
54
|
+
parts = [p.lower() for p in _WORDS.findall(name)]
|
|
55
|
+
return any(p in SECRET_PARTS for p in parts) or name.lower() in SECRET_PARTS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def looks_secret(value: str) -> bool:
|
|
59
|
+
"""Long, random-looking, one run of key characters: not a sentence, URL, path, reference (`env:NAME`), pattern,
|
|
60
|
+
template, env var name (`GITHUB_TOKEN`) or placeholder."""
|
|
61
|
+
if len(value) < MIN_LENGTH or not _KEY_CHARS.match(value) or _ENV_NAME.match(value) or is_placeholder(value):
|
|
62
|
+
return False
|
|
63
|
+
classes = sum(bool(re.search(p, value)) for p in (r"[a-z]", r"[A-Z]", r"\d"))
|
|
64
|
+
return classes >= 2 and entropy(value) >= MIN_ENTROPY
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _shape(text: str) -> str | None:
|
|
68
|
+
if PEM_START.search(text):
|
|
69
|
+
return "private key"
|
|
70
|
+
return next((label for label, pattern in KEY_SHAPES if pattern.search(text)), None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _python_names(text: str) -> dict[int, list[tuple[str, str]]]:
|
|
74
|
+
"""line -> (name, value) for each string given to a name: assignments, keywords, dict keys. Values stay here."""
|
|
75
|
+
try:
|
|
76
|
+
tree = ast.parse(text)
|
|
77
|
+
except SyntaxError:
|
|
78
|
+
return {}
|
|
79
|
+
out: dict[int, list[tuple[str, str]]] = {}
|
|
80
|
+
|
|
81
|
+
def strings(node: ast.AST) -> list[str]:
|
|
82
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
83
|
+
return [node.value]
|
|
84
|
+
if isinstance(node, ast.JoinedStr):
|
|
85
|
+
return [v.value for v in node.values if isinstance(v, ast.Constant) and isinstance(v.value, str)]
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
def target_name(t: ast.AST) -> str | None:
|
|
89
|
+
return t.id if isinstance(t, ast.Name) else t.attr if isinstance(t, ast.Attribute) else None
|
|
90
|
+
|
|
91
|
+
for node in ast.walk(tree):
|
|
92
|
+
pairs: list[tuple[str | None, ast.AST]] = []
|
|
93
|
+
if isinstance(node, ast.Assign):
|
|
94
|
+
pairs = [(target_name(t), node.value) for t in node.targets]
|
|
95
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
96
|
+
pairs = [(target_name(node.target), node.value)]
|
|
97
|
+
elif isinstance(node, ast.keyword) and node.arg:
|
|
98
|
+
pairs = [(node.arg, node.value)]
|
|
99
|
+
elif isinstance(node, ast.Dict):
|
|
100
|
+
pairs = [(k.value, v) for k, v in zip(node.keys, node.values, strict=True)
|
|
101
|
+
if isinstance(k, ast.Constant) and isinstance(k.value, str)]
|
|
102
|
+
for name, value in pairs:
|
|
103
|
+
if name:
|
|
104
|
+
for s in strings(value):
|
|
105
|
+
out.setdefault(value.lineno, []).append((name, s))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _given(line: str) -> list[tuple[str, str]]:
|
|
110
|
+
return [(m.group(1), m.group(3)) for m in _GIVEN.finditer(line)]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_keys(text: str, language: str | None) -> list[KeyHit]:
|
|
114
|
+
"""Every line that holds a key, once, in line order."""
|
|
115
|
+
python = _python_names(text) if language == "python" else {}
|
|
116
|
+
hits: list[KeyHit] = []
|
|
117
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
118
|
+
given = python.get(n, []) if language == "python" else _given(line) if language else []
|
|
119
|
+
label = _shape(line)
|
|
120
|
+
if label is not None:
|
|
121
|
+
name = next((name for name, value in given if _shape(value) is not None), None)
|
|
122
|
+
hits.append(KeyHit(n, label, name))
|
|
123
|
+
continue
|
|
124
|
+
name = next((name for name, value in given if is_secret_name(name) and looks_secret(value)), None)
|
|
125
|
+
if name is not None:
|
|
126
|
+
hits.append(KeyHit(n, "secret", name))
|
|
127
|
+
return hits
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Security findings from what the scan read (D31): keys in code and configs, plain http, unpinned or over-broad MCP
|
|
2
|
+
servers. Each with the dependency it belongs to, a masked excerpt, a one-sentence fix and a prompt for a coding agent.
|
|
3
|
+
|
|
4
|
+
Called by the scanner with the text it already has; nothing is read again, run or sent (D20).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
from flowforge.scan.models import Dependency, Finding
|
|
15
|
+
from flowforge.scan.urls import is_local
|
|
16
|
+
from flowforge.security.finders import KeyHit, find_keys
|
|
17
|
+
|
|
18
|
+
Excerpt = Callable[[str, int], tuple[int, list[str]]] # (path, line) -> (first line, masked lines)
|
|
19
|
+
CODE = frozenset({"python", "javascript", "typescript", "dart"})
|
|
20
|
+
NEAR = 3 # a key belongs to the dependency called within this many lines of where its name is used
|
|
21
|
+
RUNNERS = frozenset({"npx", "uvx", "bunx", "pnpx"})
|
|
22
|
+
BROAD = re.compile(r"^(?:/|~/?|\$\{?HOME\}?/?|[A-Za-z]:[\\/]?)$")
|
|
23
|
+
_HTTP = re.compile(r"""\bhttp://([^/\s'"`:?#]+)""")
|
|
24
|
+
LEVEL_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConfigKey:
|
|
29
|
+
"""A key-like env name whose value is written in an MCP config (the value itself is never passed here)."""
|
|
30
|
+
|
|
31
|
+
dep_id: str
|
|
32
|
+
server: str
|
|
33
|
+
name: str
|
|
34
|
+
path: str
|
|
35
|
+
line: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _id(kind: str, path: str, line: int) -> str:
|
|
39
|
+
return f"{kind}-{re.sub(r'[^a-z0-9]+', '-', path.lower()).strip('-')}-{line}"[:120]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env_name(name: str | None, label: str) -> str:
|
|
43
|
+
"""`apiKey` → API_KEY, `GOOGLE_STATIC_MAPS_KEY` stays; no name → one from the kind of key."""
|
|
44
|
+
base = name or label
|
|
45
|
+
snake = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", base)
|
|
46
|
+
return re.sub(r"[^A-Za-z0-9]+", "_", snake).strip("_").upper() or "API_KEY"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lookup(language: str | None, name: str) -> str:
|
|
50
|
+
return {"python": f'os.environ["{name}"]', "javascript": f"process.env.{name}", "typescript": f"process.env.{name}",
|
|
51
|
+
"dart": f"String.fromEnvironment('{name}') (or your env package)"}.get(language or "", "the environment")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Builder:
|
|
55
|
+
def __init__(self, texts: dict[str, list[str]], deps: list[Dependency],
|
|
56
|
+
places: dict[str, list[tuple[str, int, str]]], excerpt: Excerpt):
|
|
57
|
+
self.texts, self.deps, self.places, self.excerpt = texts, deps, places, excerpt
|
|
58
|
+
self.out: list[Finding] = []
|
|
59
|
+
|
|
60
|
+
def add(self, kind: str, level: str, path: str, line: int, title: str, fix: str, prompt: str,
|
|
61
|
+
dep_id: str | None) -> None:
|
|
62
|
+
first, lines = self.excerpt(path, line)
|
|
63
|
+
self.out.append(Finding(id=_id(kind, path, line), kind=kind, level=level, title=title[:160],
|
|
64
|
+
ref=f"{path}:{line}", line=line, dep_id=dep_id, first_line=first, excerpt=lines,
|
|
65
|
+
fix=fix, prompt=prompt))
|
|
66
|
+
|
|
67
|
+
def dep_at(self, path: str, line: int, near: int = 0) -> str | None:
|
|
68
|
+
for dep in self.deps:
|
|
69
|
+
if any(p == path and abs(n - line) <= near for p, n, _ in self.places.get(dep.id, [])):
|
|
70
|
+
return dep.id
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def key_owner(self, path: str, hit: KeyHit) -> str | None:
|
|
74
|
+
"""The dependency on the key's own line; else the one called near a line that uses the key's name."""
|
|
75
|
+
if dep := self.dep_at(path, hit.line):
|
|
76
|
+
return dep
|
|
77
|
+
if not hit.name:
|
|
78
|
+
return None
|
|
79
|
+
used = re.compile(rf"(?<![\w$]){re.escape(hit.name)}(?![\w$])")
|
|
80
|
+
for p, lines in sorted(self.texts.items()):
|
|
81
|
+
for n, text in enumerate(lines, 1):
|
|
82
|
+
if (p, n) != (path, hit.line) and used.search(text) and (dep := self.dep_at(p, n, NEAR)):
|
|
83
|
+
return dep
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def keys(self, path: str, language: str | None, skip: set[int]) -> None:
|
|
87
|
+
for hit in find_keys("\n".join(self.texts[path]), language if language in CODE else None):
|
|
88
|
+
if hit.line in skip:
|
|
89
|
+
continue
|
|
90
|
+
dep_id = self.key_owner(path, hit)
|
|
91
|
+
used = next((f" for {d.name}" for d in self.deps if d.id == dep_id), "")
|
|
92
|
+
article = "An" if hit.label[0].lower() in "aeiou" else "A"
|
|
93
|
+
name = env_name(hit.name, "API_KEY" if hit.label == "secret" else hit.label)
|
|
94
|
+
self.add("key_in_code", "high", path, hit.line, f"{article} {hit.label} is written in the code",
|
|
95
|
+
"Move it to .env and read it from the environment. Then replace the key: it may be in your git "
|
|
96
|
+
"history.",
|
|
97
|
+
f"In {path}:{hit.line}, a {hit.label}{used} is written in the code. Move it to an environment "
|
|
98
|
+
f"variable named {name}: add it to .env (check that .env is in .gitignore) and read it with "
|
|
99
|
+
f"{_lookup(language, name)} wherever it is used. Remove the written value. Never print or log it. "
|
|
100
|
+
"Then remind me to replace the key with the provider, because the old one may be in git history.",
|
|
101
|
+
dep_id)
|
|
102
|
+
|
|
103
|
+
def config_key(self, c: ConfigKey) -> None:
|
|
104
|
+
self.add("key_in_config", "high", c.path, c.line, f"{c.server}'s config holds {c.name} as plain text",
|
|
105
|
+
f'Reference it instead ("${{{c.name}}}") and keep the value in your environment. '
|
|
106
|
+
"Then replace the key.",
|
|
107
|
+
f'In {c.path}:{c.line}, the MCP server "{c.server}" gets {c.name} as a value written in the file. '
|
|
108
|
+
f'Change it to "${{{c.name}}}" so the value comes from the environment, and tell me to set {c.name} '
|
|
109
|
+
"there and to replace the old key, because it may be in git history. Never print the value.",
|
|
110
|
+
c.dep_id)
|
|
111
|
+
|
|
112
|
+
def plain_http(self, dep: Dependency) -> None:
|
|
113
|
+
for path, line, _ in self.places.get(dep.id, []):
|
|
114
|
+
text = self.texts.get(path, [""] * line)[line - 1] if line <= len(self.texts.get(path, [])) else ""
|
|
115
|
+
host = next((h for h in _HTTP.findall(text) if not is_local(h)), None)
|
|
116
|
+
if host:
|
|
117
|
+
self.add("plain_http", "medium", path, line, f"{dep.name} is called over http://",
|
|
118
|
+
"Use https:// if the service offers it: anyone on the network can read or change plain http.",
|
|
119
|
+
f"In {path}:{line}, {dep.name} is called over plain http://{host}. Check that {host} serves "
|
|
120
|
+
"https, and if it does, change the address to https://. If it doesn't, tell me, and don't "
|
|
121
|
+
"send keys or personal data to it.", dep.id)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
def server(self, dep: Dependency) -> None:
|
|
125
|
+
if not dep.command or not dep.evidence:
|
|
126
|
+
return
|
|
127
|
+
path, _, line = dep.evidence[0].ref.rpartition(":")
|
|
128
|
+
program, args = PurePosixPath(dep.command[0]).name, dep.command[1:]
|
|
129
|
+
if program in RUNNERS:
|
|
130
|
+
package = args[args.index("--from") + 1] if "--from" in args[:-1] else next(
|
|
131
|
+
(a for a in args if not a.startswith("-")), None)
|
|
132
|
+
if package and not _pinned(program, package):
|
|
133
|
+
pin = f"{package}=={{version}}" if program == "uvx" else f"{package}@{{version}}"
|
|
134
|
+
self.add("unpinned_server", "medium", path, int(line), f"{dep.name} runs whatever version is newest",
|
|
135
|
+
f"Pin a version ({pin.format(version='<version>')}) so a new release can't change it "
|
|
136
|
+
"under you.",
|
|
137
|
+
f'In {dep.evidence[0].ref}, the MCP server "{dep.name}" starts {package} with no version, so '
|
|
138
|
+
f"each start may run a new release. Find the version in use now and pin it: "
|
|
139
|
+
f"{pin.format(version='<that version>')}. Change nothing else.", dep.id)
|
|
140
|
+
if any("server-filesystem" in a for a in args) and (wide := next((a for a in args if BROAD.match(a)), None)):
|
|
141
|
+
self.add("broad_files", "medium", path, int(line), f"{dep.name} can read every file under {wide}",
|
|
142
|
+
"Give it only the folder it needs.",
|
|
143
|
+
f'In {dep.evidence[0].ref}, the filesystem MCP server "{dep.name}" is given {wide}, so it can '
|
|
144
|
+
"read and change everything under it. Replace it with the one folder the app needs. Ask me which "
|
|
145
|
+
"folder if it isn't clear.", dep.id)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pinned(program: str, package: str) -> bool:
|
|
149
|
+
if program == "uvx":
|
|
150
|
+
return "==" in package or "@" in package
|
|
151
|
+
return package.count("@") >= 2 if package.startswith("@") else "@" in package
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def findings_for(texts: dict[str, list[str]], languages: dict[str, str | None], deps: list[Dependency],
|
|
155
|
+
places: dict[str, list[tuple[str, int, str]]], config_keys: list[ConfigKey],
|
|
156
|
+
excerpt: Excerpt) -> list[Finding]:
|
|
157
|
+
"""Every finding, high first, then by file and line."""
|
|
158
|
+
b = _Builder(texts, deps, places, excerpt)
|
|
159
|
+
for c in config_keys:
|
|
160
|
+
b.config_key(c)
|
|
161
|
+
in_configs = {(c.path, c.line) for c in config_keys}
|
|
162
|
+
for path in sorted(texts):
|
|
163
|
+
b.keys(path, languages.get(path), {n for p, n in in_configs if p == path})
|
|
164
|
+
for dep in deps:
|
|
165
|
+
if dep.kind in ("api", "llm"):
|
|
166
|
+
b.plain_http(dep)
|
|
167
|
+
elif dep.kind == "mcp":
|
|
168
|
+
b.server(dep)
|
|
169
|
+
return sorted(b.out, key=lambda f: (LEVEL_ORDER[f.level], f.ref.rsplit(":", 1)[0], f.line))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Minimal redaction, pulled forward into Phase 2 (D16; full spec in CLAUDE.md §6.4–6.5).
|
|
2
|
+
|
|
3
|
+
Masks two things as "[REDACTED]":
|
|
4
|
+
- strings shaped like known keys (Stripe, Razorpay, Google, NVIDIA, Anthropic, JWTs, PEM blocks)
|
|
5
|
+
- the current values of env vars that connectors reference (secret_ref / env_refs), plus
|
|
6
|
+
NVIDIA_API_KEY, and their Basic-auth base64 form. Values under 8 characters are skipped.
|
|
7
|
+
|
|
8
|
+
It is applied to run events and results before they are stored or served, and to every log
|
|
9
|
+
record. It catches known key formats and configured keys, not everything; Phase 4 replaces
|
|
10
|
+
it with the vault and redact/verify/scan/restore.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable, Iterable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
REDACTED = "[REDACTED]"
|
|
23
|
+
MIN_SECRET_LEN = 8
|
|
24
|
+
|
|
25
|
+
KEY_PATTERNS = [re.compile(p) for p in (
|
|
26
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
27
|
+
r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{8,}",
|
|
28
|
+
r"\brzp_(?:live|test)_[A-Za-z0-9]{8,}",
|
|
29
|
+
r"\bAIza[0-9A-Za-z_\-]{20,}",
|
|
30
|
+
r"\bnvapi-[A-Za-z0-9_\-]{8,}",
|
|
31
|
+
r"\bsk-ant-[A-Za-z0-9_\-]{8,}",
|
|
32
|
+
r"\bsk-[A-Za-z0-9]{20,}",
|
|
33
|
+
r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
|
|
34
|
+
)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Names whose values are secrets (CLAUDE.md §6.4 b): `--api-key X`, `TOKEN=X`, `password = "..."`
|
|
38
|
+
SECRET_WORDS = "key|token|secret|password|passwd|credential"
|
|
39
|
+
SECRET_NAME = re.compile(SECRET_WORDS, re.IGNORECASE)
|
|
40
|
+
# In code shown back to the user (scan evidence, D21/D22): what a key of an unknown shape could hide in
|
|
41
|
+
_SECRET_VALUE = re.compile(rf"""(\b\w*(?:{SECRET_WORDS})\w*\b["']?\s*(?::\s*[\w\[\], |]+\s*)?"""
|
|
42
|
+
r"""[:=]\s*)(['"`])[^'"`\n]*\2""", re.IGNORECASE)
|
|
43
|
+
_URL_CREDENTIALS = re.compile(r"""(\b[a-z][a-z0-9+.-]*://)[^/\s:@'"`]+:[^@\s'"`/]+@""", re.IGNORECASE)
|
|
44
|
+
_URL_QUERY = re.compile(r"""(\bhttps?://[^\s'"`?#]+)\?[^\s'"`#]+""", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def redact_code(line: str, redactor: Redactor | None = None) -> str:
|
|
48
|
+
"""A line of the user's code, safe to store and show: known key shapes, URL credentials and query strings,
|
|
49
|
+
and string values given to key/token/secret/password names are masked. Pattern-based: it catches known
|
|
50
|
+
key formats and these shapes, not everything."""
|
|
51
|
+
line = (redactor or Redactor()).redact_text(line)
|
|
52
|
+
line = _URL_CREDENTIALS.sub(rf"\1{REDACTED}@", line)
|
|
53
|
+
line = _URL_QUERY.sub(rf"\1?{REDACTED}", line)
|
|
54
|
+
return _SECRET_VALUE.sub(rf"\1\2{REDACTED}\2", line)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Redactor:
|
|
58
|
+
def __init__(self, values: Iterable[str] = ()) -> None:
|
|
59
|
+
self._values: list[str] = []
|
|
60
|
+
self.set_values(values)
|
|
61
|
+
|
|
62
|
+
def set_values(self, values: Iterable[str]) -> None:
|
|
63
|
+
"""Replace the configured secret values (call again when connectors change)."""
|
|
64
|
+
found: set[str] = set()
|
|
65
|
+
for value in values:
|
|
66
|
+
if value and len(value) >= MIN_SECRET_LEN:
|
|
67
|
+
found.add(value)
|
|
68
|
+
found.add(base64.b64encode(value.encode()).decode())
|
|
69
|
+
self._values = sorted(found, key=len, reverse=True) # longest first: no partial leftovers
|
|
70
|
+
|
|
71
|
+
def redact_text(self, text: str) -> str:
|
|
72
|
+
for value in self._values:
|
|
73
|
+
if value in text:
|
|
74
|
+
text = text.replace(value, REDACTED)
|
|
75
|
+
for pattern in KEY_PATTERNS:
|
|
76
|
+
text = pattern.sub(REDACTED, text)
|
|
77
|
+
return text
|
|
78
|
+
|
|
79
|
+
def redact(self, value: Any) -> Any:
|
|
80
|
+
"""A copy of `value` with every string inside it redacted (dicts, lists, tuples)."""
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return self.redact_text(value)
|
|
83
|
+
if isinstance(value, dict):
|
|
84
|
+
return {self.redact(k) if isinstance(k, str) else k: self.redact(v) for k, v in value.items()}
|
|
85
|
+
if isinstance(value, (list, tuple)):
|
|
86
|
+
return [self.redact(v) for v in value]
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def secret_values_for(connectors: Iterable[Any]) -> set[str]:
|
|
91
|
+
"""Current values of every env var the connectors reference, plus NVIDIA_API_KEY (D8)."""
|
|
92
|
+
names = {"NVIDIA_API_KEY"}
|
|
93
|
+
for c in connectors:
|
|
94
|
+
refs = [c.secret_ref] + list(getattr(c.connection, "env_refs", {}).values())
|
|
95
|
+
names.update(ref.split(":", 1)[1] for ref in refs if ref and ref.startswith("env:"))
|
|
96
|
+
return {os.environ[n] for n in names if os.environ.get(n)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def install_log_redaction(redactor: Redactor) -> Callable[[], None]:
|
|
100
|
+
"""Redact every log record at creation, whichever logger or handler it goes to.
|
|
101
|
+
|
|
102
|
+
Uses the log-record factory, because logger-level filters miss records propagated from
|
|
103
|
+
child loggers. Returns a function that restores the previous factory.
|
|
104
|
+
"""
|
|
105
|
+
previous = logging.getLogRecordFactory()
|
|
106
|
+
|
|
107
|
+
def redact_arg(value: Any) -> Any:
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
return redactor.redact_text(value)
|
|
110
|
+
if value is None or isinstance(value, (int, float, bool)):
|
|
111
|
+
return value # keeps %d and friends working
|
|
112
|
+
return redactor.redact_text(str(value))
|
|
113
|
+
|
|
114
|
+
def factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
|
|
115
|
+
record = previous(*args, **kwargs)
|
|
116
|
+
# Redact in place and keep the shape: some formatters (uvicorn's access log) unpack args.
|
|
117
|
+
if isinstance(record.msg, str):
|
|
118
|
+
record.msg = redactor.redact_text(record.msg)
|
|
119
|
+
if isinstance(record.args, tuple):
|
|
120
|
+
record.args = tuple(redact_arg(a) for a in record.args)
|
|
121
|
+
elif isinstance(record.args, dict):
|
|
122
|
+
record.args = {k: redact_arg(v) for k, v in record.args.items()}
|
|
123
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
124
|
+
# formatters reuse exc_text when it is set, so the traceback is printed redacted
|
|
125
|
+
record.exc_text = redactor.redact_text(logging.Formatter().formatException(record.exc_info))
|
|
126
|
+
return record
|
|
127
|
+
|
|
128
|
+
logging.setLogRecordFactory(factory)
|
|
129
|
+
return lambda: logging.setLogRecordFactory(previous)
|
flowforge/spa.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Serve the built dashboard (frontend/dist) next to the API (D15).
|
|
2
|
+
|
|
3
|
+
Page routes and API paths overlap (/runs/:id, /connectors/:id), so a GET whose Accept header
|
|
4
|
+
asks for HTML gets the app's index.html, and everything else reaches the API unchanged. curl,
|
|
5
|
+
CI and fetch() calls (Accept: application/json or */*) keep getting JSON.
|
|
6
|
+
|
|
7
|
+
/classic the V1 status page, kept until the new dashboard reaches parity
|
|
8
|
+
/assets/* hashed build assets (cached hard)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import FileResponse, Response
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from flowforge.home import resource
|
|
18
|
+
|
|
19
|
+
DIST = resource("dashboard") # inside the package when installed, frontend/dist in a checkout (D34)
|
|
20
|
+
CLASSIC = resource("classic")
|
|
21
|
+
API_DOCS = ("/docs", "/redoc", "/openapi.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def wants_html(request: Request) -> bool:
|
|
25
|
+
accept = request.headers.get("accept", "")
|
|
26
|
+
return request.method == "GET" and "text/html" in accept
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install(app: FastAPI) -> None:
|
|
30
|
+
@app.middleware("http")
|
|
31
|
+
async def spa_fallback(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
32
|
+
path = request.url.path
|
|
33
|
+
index = DIST / "index.html"
|
|
34
|
+
if wants_html(request) and index.is_file() and not path.startswith(API_DOCS) and path != "/classic":
|
|
35
|
+
return FileResponse(index, headers={"Cache-Control": "no-cache"})
|
|
36
|
+
return await call_next(request)
|
|
37
|
+
|
|
38
|
+
app.mount("/assets", StaticFiles(directory=DIST / "assets", check_dir=False), name="assets")
|
|
39
|
+
|
|
40
|
+
@app.get("/classic", include_in_schema=False)
|
|
41
|
+
def classic() -> FileResponse:
|
|
42
|
+
return FileResponse(CLASSIC)
|
|
43
|
+
|
|
44
|
+
@app.get("/", include_in_schema=False)
|
|
45
|
+
def index() -> Response:
|
|
46
|
+
built = DIST / "index.html"
|
|
47
|
+
return FileResponse(built if built.is_file() else CLASSIC, headers={"Cache-Control": "no-cache"})
|
|
48
|
+
|
|
49
|
+
@app.get("/favicon.svg", include_in_schema=False)
|
|
50
|
+
def favicon() -> Response:
|
|
51
|
+
icon = DIST / "favicon.svg"
|
|
52
|
+
return FileResponse(icon, media_type="image/svg+xml") if icon.is_file() else Response(status_code=404)
|
flowforge/state.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""App state shared by the API routers: nodes, buckets, storage, redaction, live runs (D16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
|
|
12
|
+
from flowforge.connectors.registry import Registry
|
|
13
|
+
from flowforge.nodes.base import Node
|
|
14
|
+
from flowforge.scheduler.executor import Event
|
|
15
|
+
from flowforge.scheduler.rate_limit import TokenBucket
|
|
16
|
+
from flowforge.schema import Workflow
|
|
17
|
+
from flowforge.security.logfilter import Redactor
|
|
18
|
+
from flowforge.storage import Storage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plain(value: Any, redactor: Redactor) -> Any:
|
|
22
|
+
"""Redacted, JSON-shaped copy, identical to what a later read from storage returns."""
|
|
23
|
+
return json.loads(json.dumps(redactor.redact(value), default=str))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Run:
|
|
28
|
+
"""A run while this server process is executing it. History lives in storage."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
workflow: Workflow
|
|
32
|
+
storage: Storage
|
|
33
|
+
redactor: Redactor
|
|
34
|
+
events: list[Event] = field(default_factory=list)
|
|
35
|
+
status: str = "running"
|
|
36
|
+
result: dict[str, Any] | None = None
|
|
37
|
+
error: str | None = None
|
|
38
|
+
done: bool = False
|
|
39
|
+
changed: asyncio.Event = field(default_factory=asyncio.Event)
|
|
40
|
+
task: asyncio.Task[Any] | None = None
|
|
41
|
+
|
|
42
|
+
def push(self, event: Event) -> None:
|
|
43
|
+
"""Every event is redacted before it is kept, stored or streamed (D16)."""
|
|
44
|
+
event = plain(event, self.redactor)
|
|
45
|
+
self.events.append(event)
|
|
46
|
+
self.storage.append_event(self.id, len(self.events) - 1, event)
|
|
47
|
+
self.changed.set()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AppState:
|
|
52
|
+
nodes: dict[str, Node]
|
|
53
|
+
rate_limits: dict[str, TokenBucket]
|
|
54
|
+
storage: Storage
|
|
55
|
+
registry: Registry
|
|
56
|
+
redactor: Redactor
|
|
57
|
+
file_managed: set[str] = field(default_factory=set) # ids from connectors.json: read-only (D16)
|
|
58
|
+
connector_tests: dict[str, dict[str, Any]] = field(default_factory=dict) # last "Test connection"
|
|
59
|
+
retired_nodes: list[Node] = field(default_factory=list) # replaced nodes, closed at shutdown
|
|
60
|
+
runs: dict[str, Run] = field(default_factory=dict)
|
|
61
|
+
tasks: set[asyncio.Task[Any]] = field(default_factory=set)
|
|
62
|
+
shutting_down: bool = False
|
|
63
|
+
example: bool = False # example-data mode (D16): the UI labels everything as example data
|
|
64
|
+
flows: dict[str, Any] = field(default_factory=dict) # map id -> FlowService (D17, D21); Any avoids a cycle
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
current: AppState | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def app_state() -> AppState:
|
|
71
|
+
if current is None:
|
|
72
|
+
raise HTTPException(503, "app not started")
|
|
73
|
+
return current
|