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.
Files changed (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. 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