hackscan 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.
@@ -0,0 +1,76 @@
1
+ """Stable, vendor-independent finding ids.
2
+
3
+ id = H(vuln_class, path, enclosing function, normalized sink expression)
4
+
5
+ The sink expression comes from `Finding.sink` (text taken from the source file), falling
6
+ back to `snippet` only when no sink was recorded. Line numbers are deliberately excluded
7
+ so ids survive unrelated edits that shift code.
8
+ Identical sinks in the same function are disambiguated by occurrence order.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import hashlib
15
+ import re
16
+ from collections import defaultdict
17
+ from collections.abc import Iterable
18
+ from dataclasses import replace
19
+ from functools import lru_cache
20
+
21
+ from hackscan.core.models import Finding
22
+
23
+ ID_PREFIX = "hs1-" # bump if the fingerprint recipe changes
24
+ _HEX_LEN = 32
25
+ _WS_RE = re.compile(r"\s+")
26
+
27
+
28
+ @lru_cache(maxsize=65536)
29
+ def normalize_expression(code: str) -> str:
30
+ """Canonical form of a code snippet: formatting- and comment-insensitive when parseable."""
31
+ text = code.strip()
32
+ if not text:
33
+ return ""
34
+ for mode in ("eval", "exec"):
35
+ try:
36
+ return ast.unparse(ast.parse(text, mode=mode))
37
+ except SyntaxError:
38
+ continue
39
+ return _WS_RE.sub(" ", text)
40
+
41
+
42
+ def compute_fingerprint(
43
+ vuln_class: str, path: str, function: str | None, sink_expression: str, occurrence: int = 0
44
+ ) -> str:
45
+ parts = [vuln_class, path, function or "<module>", normalize_expression(sink_expression)]
46
+ if occurrence:
47
+ parts.append(f"#{occurrence}")
48
+ digest = hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
49
+ return ID_PREFIX + digest[:_HEX_LEN]
50
+
51
+
52
+ def assign_ids(findings: Iterable[Finding]) -> list[Finding]:
53
+ """Return findings with `id` set. Input order does not affect the result."""
54
+ groups: dict[str, list[Finding]] = defaultdict(list)
55
+ for f in findings:
56
+ base = compute_fingerprint(f.vuln_class, f.location.path, f.function, sink_text(f))
57
+ groups[base].append(f)
58
+
59
+ result: list[Finding] = []
60
+ for base, members in groups.items():
61
+ members.sort(key=lambda f: (*f.location.sort_key(), f.canonical_json()))
62
+ for occurrence, f in enumerate(members):
63
+ new_id = (
64
+ base
65
+ if occurrence == 0
66
+ else compute_fingerprint(
67
+ f.vuln_class, f.location.path, f.function, sink_text(f), occurrence
68
+ )
69
+ )
70
+ result.append(replace(f, id=new_id))
71
+ result.sort(key=Finding.sort_key)
72
+ return result
73
+
74
+
75
+ def sink_text(f: Finding) -> str:
76
+ return f.sink or f.snippet
@@ -0,0 +1,270 @@
1
+ """Finding schema v1. Frozen at M0: changes require bumping SCHEMA_VERSION.
2
+
3
+ Every pass and importer produces or transforms `Finding` objects. Passes never delete
4
+ findings; they return updated copies (see `dataclasses.replace`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from functools import cached_property
14
+ from typing import Any
15
+
16
+ SCHEMA_VERSION = 1
17
+
18
+ OWN_SOURCE = "hackscan"
19
+
20
+
21
+ class Severity(str, Enum):
22
+ LOW = "low"
23
+ MEDIUM = "medium"
24
+ HIGH = "high"
25
+ CRITICAL = "critical"
26
+
27
+ @property
28
+ def rank(self) -> int:
29
+ return _SEVERITY_RANK[self]
30
+
31
+
32
+ _SEVERITY_RANK = {Severity.LOW: 0, Severity.MEDIUM: 1, Severity.HIGH: 2, Severity.CRITICAL: 3}
33
+
34
+
35
+ class Status(str, Enum):
36
+ CANDIDATE = "candidate"
37
+ CONFIRMED = "confirmed"
38
+ SUPPRESSED = "suppressed"
39
+
40
+ @property
41
+ def rank(self) -> int:
42
+ """Merge precedence: confirmed > candidate > suppressed."""
43
+ return _STATUS_RANK[self]
44
+
45
+
46
+ _STATUS_RANK = {Status.SUPPRESSED: 0, Status.CANDIDATE: 1, Status.CONFIRMED: 2}
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Region:
51
+ """Source location. Lines and columns are 1-based (SARIF convention).
52
+
53
+ `path` is a POSIX path relative to the scan root. `end_column` is exclusive (SARIF
54
+ `endColumn`); None means "to end of line".
55
+ """
56
+
57
+ path: str
58
+ start_line: int
59
+ start_column: int = 1
60
+ end_line: int | None = None
61
+ end_column: int | None = None
62
+
63
+ def __post_init__(self) -> None:
64
+ if self.start_line < 1 or self.start_column < 1:
65
+ raise ValueError(f"Region lines/columns are 1-based: {self}")
66
+ if "\\" in self.path:
67
+ raise ValueError(f"Region path must be POSIX-style: {self.path!r}")
68
+ if self.end_line is None:
69
+ object.__setattr__(self, "end_line", self.start_line)
70
+ if self.end_line < self.start_line:
71
+ raise ValueError(f"Region ends before it starts: {self}")
72
+ if self.end_column is not None and (
73
+ self.end_column < 1
74
+ or (self.end_line == self.start_line and self.end_column < self.start_column)
75
+ ):
76
+ raise ValueError(f"Region end column is invalid: {self}")
77
+
78
+ @property
79
+ def start_pos(self) -> tuple[int, float]:
80
+ return (self.start_line, self.start_column)
81
+
82
+ @property
83
+ def end_pos(self) -> tuple[int, float]:
84
+ """Exclusive end position; whole-line when `end_column` is None."""
85
+ if self.end_column is None:
86
+ return (self.end_line, math.inf)
87
+ # Zero-width regions still cover the character they point at.
88
+ if self.end_line == self.start_line and self.end_column == self.start_column:
89
+ return (self.end_line, self.end_column + 1)
90
+ return (self.end_line, self.end_column)
91
+
92
+ @property
93
+ def span(self) -> tuple[int, float]:
94
+ """Ordering heuristic preferring precise regions: (extra lines, column delta).
95
+
96
+ Only used to pick visit order; correctness of dedupe does not depend on it.
97
+ """
98
+ return (self.end_line - self.start_line, self.end_pos[1] - self.start_column)
99
+
100
+ def overlaps(self, other: Region) -> bool:
101
+ """Position overlap (line and column aware) within the same file."""
102
+ return (
103
+ self.path == other.path
104
+ and self.start_pos < other.end_pos
105
+ and other.start_pos < self.end_pos
106
+ )
107
+
108
+ def sort_key(self) -> tuple[str, int, int]:
109
+ return (self.path, self.start_line, self.start_column)
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Evidence:
114
+ """One piece of supporting evidence, tagged with the producer (pass or tool)."""
115
+
116
+ producer: str
117
+ kind: str # e.g. "taint_step", "tool_message", "llm_rationale"
118
+ message: str
119
+ region: Region | None = None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class FixEdit:
124
+ region: Region
125
+ replacement: str
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Fix:
130
+ description: str
131
+ edits: tuple[FixEdit, ...]
132
+ producer: str
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Finding:
137
+ id: str
138
+ vuln_class: str
139
+ rule_id: str
140
+ severity: Severity
141
+ location: Region
142
+ message: str
143
+ snippet: str = ""
144
+ cwe: tuple[str, ...] = ()
145
+ related_rules: tuple[str, ...] = ()
146
+ sources: tuple[str, ...] = (OWN_SOURCE,)
147
+ status: Status = Status.CANDIDATE
148
+ suppression: str | None = None
149
+ confidence: int = 50
150
+ evidence: tuple[Evidence, ...] = ()
151
+ fix: Fix | None = None
152
+ # Enclosing function qualname. Like `sink`, producers derive it from the parsed file
153
+ # (importers look it up by location), so it does not depend on which tool reported.
154
+ function: str | None = None
155
+ # Source text of the sink expression at `location`, used for fingerprinting.
156
+ # Producers set it from the parsed file (own engine: the sink AST node; importers:
157
+ # the file text covered by the region), never from a tool-provided display snippet.
158
+ sink: str = ""
159
+
160
+ def __post_init__(self) -> None:
161
+ if not 0 <= self.confidence <= 100:
162
+ raise ValueError(f"confidence must be 0-100, got {self.confidence}")
163
+ if (self.status is Status.SUPPRESSED) != (self.suppression is not None):
164
+ raise ValueError("suppression reason is required iff status is suppressed")
165
+
166
+ def sort_key(self) -> tuple[str, int, int, str, str, str]:
167
+ """Deterministic output ordering: path, line, column, rule, id, then full content."""
168
+ return (*self.location.sort_key(), self.rule_id, self.id, self.canonical_json())
169
+
170
+ def canonical_json(self) -> str:
171
+ """Stable serialization; a total-order tie-breaker for equal-looking findings."""
172
+ return self._canonical_json
173
+
174
+ @cached_property
175
+ def _canonical_json(self) -> str:
176
+ # Safe to cache: Finding is frozen, and cached_property writes to __dict__
177
+ # directly (not a dataclass field, so eq/hash/replace are unaffected).
178
+ return json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
179
+
180
+ def to_dict(self) -> dict[str, Any]:
181
+ return {
182
+ "schema_version": SCHEMA_VERSION,
183
+ "id": self.id,
184
+ "vuln_class": self.vuln_class,
185
+ "rule_id": self.rule_id,
186
+ "related_rules": list(self.related_rules),
187
+ "cwe": list(self.cwe),
188
+ "severity": self.severity.value,
189
+ "confidence": self.confidence,
190
+ "status": self.status.value,
191
+ "suppression": self.suppression,
192
+ "sources": list(self.sources),
193
+ "message": self.message,
194
+ "location": _region_to_dict(self.location),
195
+ "function": self.function,
196
+ "sink": self.sink,
197
+ "snippet": self.snippet,
198
+ "evidence": [
199
+ {
200
+ "producer": e.producer,
201
+ "kind": e.kind,
202
+ "message": e.message,
203
+ "region": _region_to_dict(e.region) if e.region else None,
204
+ }
205
+ for e in self.evidence
206
+ ],
207
+ "fix": None
208
+ if self.fix is None
209
+ else {
210
+ "description": self.fix.description,
211
+ "producer": self.fix.producer,
212
+ "edits": [
213
+ {"region": _region_to_dict(ed.region), "replacement": ed.replacement}
214
+ for ed in self.fix.edits
215
+ ],
216
+ },
217
+ }
218
+
219
+ @classmethod
220
+ def from_dict(cls, data: dict[str, Any]) -> Finding:
221
+ version = data.get("schema_version")
222
+ if version != SCHEMA_VERSION:
223
+ raise ValueError(f"unsupported Finding schema_version {version!r}")
224
+ fix = data.get("fix")
225
+ return cls(
226
+ id=data["id"],
227
+ vuln_class=data["vuln_class"],
228
+ rule_id=data["rule_id"],
229
+ related_rules=tuple(data.get("related_rules", ())),
230
+ cwe=tuple(data.get("cwe", ())),
231
+ severity=Severity(data["severity"]),
232
+ confidence=data["confidence"],
233
+ status=Status(data["status"]),
234
+ suppression=data.get("suppression"),
235
+ sources=tuple(data["sources"]),
236
+ message=data["message"],
237
+ location=Region(**data["location"]),
238
+ function=data.get("function"),
239
+ sink=data.get("sink", ""),
240
+ snippet=data.get("snippet", ""),
241
+ evidence=tuple(
242
+ Evidence(
243
+ producer=e["producer"],
244
+ kind=e["kind"],
245
+ message=e["message"],
246
+ region=Region(**e["region"]) if e.get("region") else None,
247
+ )
248
+ for e in data.get("evidence", ())
249
+ ),
250
+ fix=None
251
+ if fix is None
252
+ else Fix(
253
+ description=fix["description"],
254
+ producer=fix["producer"],
255
+ edits=tuple(
256
+ FixEdit(region=Region(**ed["region"]), replacement=ed["replacement"])
257
+ for ed in fix["edits"]
258
+ ),
259
+ ),
260
+ )
261
+
262
+
263
+ def _region_to_dict(region: Region) -> dict[str, Any]:
264
+ return {
265
+ "path": region.path,
266
+ "start_line": region.start_line,
267
+ "start_column": region.start_column,
268
+ "end_line": region.end_line,
269
+ "end_column": region.end_column,
270
+ }
@@ -0,0 +1,164 @@
1
+ """Scan pipeline: discover -> analyze (passes 1-2, parallel) -> import -> merge -> ids.
2
+
3
+ Pipeline order per PROJECT.md: collect (own engine + importers) -> `merge_findings` ->
4
+ `assign_ids`. Output is deterministic regardless of worker scheduling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ import time
12
+ from collections.abc import Iterator
13
+ from concurrent.futures import ProcessPoolExecutor
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from hackscan.analyzers.ast_pass import FileResult, analyze_file
18
+ from hackscan.config import DEFAULT_IGNORES, HackScanConfig
19
+ from hackscan.core.dedupe import merge_findings
20
+ from hackscan.core.fingerprint import assign_ids
21
+ from hackscan.core.models import Finding
22
+ from hackscan.core.redact import redact_findings, redact_messages
23
+ from hackscan.importers.common import SourceIndex
24
+ from hackscan.importers.runner import ToolRun, collect
25
+ from hackscan.plugins.base import RulePlugin
26
+ from hackscan.plugins.loader import resolve_plugins
27
+
28
+ PARALLEL_THRESHOLD = 16 # below this many files, process start-up costs more than it saves
29
+
30
+
31
+ @dataclass
32
+ class ScanResult:
33
+ root: Path
34
+ findings: list[Finding]
35
+ files_scanned: int
36
+ duration_seconds: float
37
+ errors: list[str] = field(default_factory=list) # files we could not analyze
38
+ warnings: list[str] = field(default_factory=list) # external tool problems
39
+ tool_runs: list[ToolRun] = field(default_factory=list)
40
+
41
+
42
+ def scan(target: Path, config: HackScanConfig) -> ScanResult:
43
+ started = time.perf_counter()
44
+ target = target.resolve()
45
+ root = target if target.is_dir() else target.parent
46
+ plugins = resolve_plugins(config.plugins)
47
+ files = list(discover_files(target, root, config.ignore))
48
+
49
+ results = _analyze(files, root, plugins, config)
50
+ findings: list[Finding] = []
51
+ errors: list[str] = []
52
+ for result in results:
53
+ findings.extend(result.findings)
54
+ errors.extend(result.errors)
55
+
56
+ external = collect(
57
+ root,
58
+ target,
59
+ config.with_tools,
60
+ config.imports,
61
+ config.tool_timeout,
62
+ SourceIndex(root),
63
+ )
64
+ # Imported findings obey the same ignores as discovery (defaults included).
65
+ findings.extend(
66
+ f for f in external.findings if not is_ignored_path(f.location.path, config.ignore)
67
+ )
68
+
69
+ final = assign_ids(redact_findings(merge_findings(findings), external.secrets))
70
+ return ScanResult(
71
+ root=root,
72
+ findings=final,
73
+ files_scanned=len(files),
74
+ duration_seconds=time.perf_counter() - started,
75
+ errors=redact_messages([*sorted(errors), *external.errors], external.secrets),
76
+ warnings=redact_messages(external.warnings, external.secrets),
77
+ tool_runs=external.runs,
78
+ )
79
+
80
+
81
+ # -- discovery ----------------------------------------------------------------------------
82
+
83
+
84
+ def discover_files(target: Path, root: Path, ignore: tuple[str, ...]) -> Iterator[Path]:
85
+ """Python files under `target`, skipping default and configured ignores (sorted)."""
86
+ if target.is_file():
87
+ if target.suffix in (".py", ".pyw") and not _is_ignored(target.name, ignore):
88
+ yield target
89
+ return
90
+ for dirpath, dirnames, filenames in os.walk(target):
91
+ current = Path(dirpath)
92
+ rel_dir = current.relative_to(root).as_posix()
93
+ dirnames[:] = sorted(
94
+ d
95
+ for d in dirnames
96
+ if not _is_ignored(f"{rel_dir}/{d}" if rel_dir != "." else d, ignore, is_dir=True)
97
+ )
98
+ for name in sorted(filenames):
99
+ if not name.endswith((".py", ".pyw")):
100
+ continue
101
+ rel = f"{rel_dir}/{name}" if rel_dir != "." else name
102
+ if not _is_ignored(rel, ignore):
103
+ yield current / name
104
+
105
+
106
+ def is_ignored_path(rel_path: str, patterns: tuple[str, ...]) -> bool:
107
+ """Whether a file path is excluded: any directory component matching a default ignore
108
+ (`.venv`, `node_modules`, ...) or a configured pattern."""
109
+ parts = rel_path.split("/")
110
+ if any(fnmatch.fnmatch(part, p) for part in parts[:-1] for p in DEFAULT_IGNORES):
111
+ return True
112
+ return _is_ignored(rel_path, patterns)
113
+
114
+
115
+ def _is_ignored(rel_path: str, patterns: tuple[str, ...], is_dir: bool = False) -> bool:
116
+ """Default ignores match any path component; configured globs match the relative path
117
+ (`tests/*`, `**/migrations/**`) or any single component (`legacy`)."""
118
+ parts = rel_path.split("/")
119
+ if is_dir and any(fnmatch.fnmatch(parts[-1], p) for p in DEFAULT_IGNORES):
120
+ return True
121
+ for pattern in patterns:
122
+ pattern = pattern.strip().rstrip("/")
123
+ if not pattern:
124
+ continue
125
+ if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path, pattern + "/*"):
126
+ return True
127
+ if pattern.startswith("**/") and fnmatch.fnmatch(rel_path, pattern[3:]):
128
+ return True
129
+ if "/" not in pattern and any(fnmatch.fnmatch(part, pattern) for part in parts):
130
+ return True
131
+ return False
132
+
133
+
134
+ # -- analysis -----------------------------------------------------------------------------
135
+
136
+ _WORKER_STATE: dict = {}
137
+
138
+
139
+ def _worker_init(plugins_dir: str | None, taint: bool) -> None:
140
+ _WORKER_STATE["plugins"] = resolve_plugins(Path(plugins_dir) if plugins_dir else None)
141
+ _WORKER_STATE["taint"] = taint
142
+
143
+
144
+ def _worker_analyze(args: tuple[str, str]) -> FileResult:
145
+ path, root = args
146
+ return analyze_file(
147
+ Path(path), Path(root), _WORKER_STATE["plugins"], taint=_WORKER_STATE["taint"]
148
+ )
149
+
150
+
151
+ def _analyze(
152
+ files: list[Path], root: Path, plugins: list[RulePlugin], config: HackScanConfig
153
+ ) -> list[FileResult]:
154
+ jobs = config.jobs or min(8, os.cpu_count() or 1)
155
+ if jobs <= 1 or len(files) < PARALLEL_THRESHOLD:
156
+ return [analyze_file(f, root, plugins, taint=config.taint) for f in files]
157
+ plugins_dir = str(config.plugins) if config.plugins else None
158
+ with ProcessPoolExecutor(
159
+ max_workers=jobs, initializer=_worker_init, initargs=(plugins_dir, config.taint)
160
+ ) as pool:
161
+ chunk = max(1, len(files) // (jobs * 4))
162
+ return list(
163
+ pool.map(_worker_analyze, [(str(f), str(root)) for f in files], chunksize=chunk)
164
+ )
@@ -0,0 +1,84 @@
1
+ """Secret redaction for every user-facing field of a finding.
2
+
3
+ Applied after merging, before ids are assigned, so no output format (text, JSON, SARIF)
4
+ can carry a secret regardless of which tool reported it or which contributor became the
5
+ primary of a merged finding.
6
+
7
+ - Known secret values (e.g. Gitleaks `Secret`) are replaced wherever they appear.
8
+ - Findings of class `secret` never carry tool-provided text: their message and evidence
9
+ become generic, and in snippet/sink every string literal and every assigned value
10
+ (`token = abc`, `password: abc`) is replaced. A tool may describe a secret in any
11
+ form (quoted, unquoted, inside a message), so content-based redaction is not enough.
12
+ - `redact_messages` applies known-secret replacement to warnings and errors too.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterable
19
+ from dataclasses import replace
20
+
21
+ from hackscan.core.models import Finding
22
+ from hackscan.core.taxonomy import SECRET
23
+
24
+ REDACTED = "<REDACTED>"
25
+ MIN_SECRET_LENGTH = 4 # shorter "secrets" would redact ordinary text
26
+ _LITERAL_RE = re.compile(r"""(?P<q>['"])(?:\\.|(?!(?P=q)).)+(?P=q)""")
27
+ # `key = value` / `key: value` / `key=value` (value up to end of line or comment).
28
+ _ASSIGNED_RE = re.compile(r"(?P<key>[=:]\s*)(?P<value>[^\s#'\"][^#]*?)(?P<tail>\s*(?:#.*)?)$")
29
+
30
+
31
+ def redact_text(text: str, secrets: Iterable[str], literals: bool) -> str:
32
+ for secret in sorted(set(secrets), key=len, reverse=True):
33
+ if len(secret) >= MIN_SECRET_LENGTH:
34
+ text = text.replace(secret, REDACTED)
35
+ if literals:
36
+ text = _LITERAL_RE.sub(lambda m: f"{m.group('q')}{REDACTED}{m.group('q')}", text)
37
+ return text
38
+
39
+
40
+ def redact_code(text: str, secrets: Iterable[str]) -> str:
41
+ """Secret-class snippet/sink: literals and assigned values removed, line by line."""
42
+ lines = []
43
+ for line in redact_text(text, secrets, literals=True).split("\n"):
44
+ lines.append(
45
+ _ASSIGNED_RE.sub(lambda m: f"{m.group('key')}{REDACTED}{m.group('tail')}", line)
46
+ )
47
+ return "\n".join(lines)
48
+
49
+
50
+ def redact_messages(messages: Iterable[str], secrets: Iterable[str]) -> list[str]:
51
+ secrets = list(secrets)
52
+ return [redact_text(m, secrets, literals=False) for m in messages]
53
+
54
+
55
+ def redact_findings(findings: list[Finding], secrets: Iterable[str]) -> list[Finding]:
56
+ secrets = [s for s in set(secrets) if len(s) >= MIN_SECRET_LENGTH]
57
+ out = []
58
+ for f in findings:
59
+ if f.vuln_class == SECRET:
60
+ generic = f"Possible hard-coded secret reported by {', '.join(f.sources)}."
61
+ new = replace(
62
+ f,
63
+ message=generic,
64
+ snippet=redact_code(f.snippet, secrets),
65
+ sink=redact_code(f.sink, secrets),
66
+ evidence=tuple(
67
+ replace(e, message=f"{e.producer} reported a secret ({f.rule_id}).")
68
+ for e in f.evidence
69
+ ),
70
+ )
71
+ else:
72
+
73
+ def clean(text: str) -> str:
74
+ return redact_text(text, secrets, literals=False)
75
+
76
+ new = replace(
77
+ f,
78
+ message=clean(f.message),
79
+ snippet=clean(f.snippet),
80
+ sink=clean(f.sink),
81
+ evidence=tuple(replace(e, message=clean(e.message)) for e in f.evidence),
82
+ )
83
+ out.append(new if new != f else f)
84
+ return out