cleartoship 0.7.0 → 0.9.0

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.
package/action.yml CHANGED
@@ -27,9 +27,12 @@ inputs:
27
27
  required: false
28
28
  default: 'false'
29
29
  version:
30
- description: Version of the cleartoship npm package to run.
30
+ description: >-
31
+ Version of the cleartoship npm package to run. Defaults to the version this
32
+ action's own ref declares, so `uses: …@v0.9.0` runs cleartoship@0.9.0. Set
33
+ `latest` to always track the newest release, or `local` to build from the checkout.
31
34
  required: false
32
- default: latest
35
+ default: ''
33
36
  working-directory:
34
37
  description: Directory to run the scan from.
35
38
  required: false
@@ -49,9 +52,18 @@ outputs:
49
52
  high:
50
53
  description: Number of high findings.
51
54
  value: ${{ steps.scan.outputs.high }}
55
+ medium:
56
+ description: Number of medium findings.
57
+ value: ${{ steps.scan.outputs.medium }}
58
+ low:
59
+ description: Number of low findings.
60
+ value: ${{ steps.scan.outputs.low }}
52
61
  total:
53
62
  description: Total number of findings.
54
63
  value: ${{ steps.scan.outputs.total }}
64
+ blocking:
65
+ description: Number of findings at or above the fail-on severity.
66
+ value: ${{ steps.scan.outputs.blocking }}
55
67
 
56
68
  runs:
57
69
  using: composite
@@ -59,17 +71,28 @@ runs:
59
71
  - name: Resolve ClearToShip
60
72
  id: resolve
61
73
  shell: bash
74
+ env:
75
+ INPUT_VERSION: ${{ inputs.version }}
62
76
  run: |
63
- # Prefer the published npm package. If it cannot be resolved — which is
64
- # the case before the first publish, or when a pinned version is not on
65
- # the registry — fall back to building from this action's own checkout,
66
- # so `uses: <owner>/cleartoship@ref` works with no npm dependency.
67
- ver="${{ inputs.version }}"
77
+ # With no version pinned, run the exact version this action's checkout
78
+ # declares. That keeps the action ref and the scanner in lockstep:
79
+ # `uses: <owner>/cleartoship@v0.9.0` runs cleartoship@0.9.0 instead of
80
+ # whatever npm happens to tag `latest` at the time.
81
+ ver="$INPUT_VERSION"
82
+ if [ -z "$ver" ]; then
83
+ ver=$(node -p "require('${GITHUB_ACTION_PATH}/package.json').version")
84
+ echo "No version pinned; matching this action's checkout: ${ver}"
85
+ fi
86
+
87
+ # Prefer the published npm package. If it cannot be resolved — an
88
+ # unpublished version, or a pinned version that is not on the registry —
89
+ # fall back to building from this action's own checkout, so
90
+ # `uses: <owner>/cleartoship@ref` works with no npm dependency.
68
91
  if [ "$ver" != "local" ] && npm view "cleartoship@${ver}" version >/dev/null 2>&1; then
69
92
  echo "cmd=npx --yes cleartoship@${ver}" >> "$GITHUB_OUTPUT"
70
93
  echo "Using published cleartoship@${ver}"
71
94
  else
72
- echo "Published package not found; building from the action checkout."
95
+ echo "cleartoship@${ver} not on the registry; building from the action checkout."
73
96
  ( cd "$GITHUB_ACTION_PATH" && npm ci --silent && npm run build --silent )
74
97
  echo "cmd=node ${GITHUB_ACTION_PATH}/dist/cli.js" >> "$GITHUB_OUTPUT"
75
98
  fi
@@ -78,30 +101,56 @@ runs:
78
101
  id: scan
79
102
  shell: bash
80
103
  working-directory: ${{ inputs.working-directory }}
104
+ env:
105
+ # Inputs are read through the environment rather than interpolated into
106
+ # the script body, so a caller's value is never parsed as shell.
107
+ CTS: ${{ steps.resolve.outputs.cmd }}
108
+ INPUT_PATHS: ${{ inputs.paths }}
109
+ INPUT_OFFLINE: ${{ inputs.offline }}
110
+ INPUT_SARIF: ${{ inputs.sarif }}
111
+ INPUT_FAIL_ON: ${{ inputs.fail-on }}
81
112
  run: |
82
113
  set -o pipefail
83
- CTS="${{ steps.resolve.outputs.cmd }}"
84
114
  args=()
85
- [ -n "${{ inputs.paths }}" ] && args+=(${{ inputs.paths }})
86
- [ "${{ inputs.offline }}" = "true" ] && args+=(--offline)
115
+ if [ -n "$INPUT_PATHS" ]; then read -ra args <<< "$INPUT_PATHS"; fi
116
+ if [ "$INPUT_OFFLINE" = "true" ]; then args+=(--offline); fi
87
117
 
88
118
  # The gate is applied at the end so the report and comment are produced
89
119
  # regardless of pass/fail; --fail-on=none keeps this step from aborting early.
90
120
  $CTS "${args[@]}" --json --fail-on=none > cleartoship.json
91
121
  $CTS "${args[@]}" --markdown --fail-on=none > cleartoship.md || true
92
- if [ "${{ inputs.sarif }}" = "true" ]; then
122
+ if [ "$INPUT_SARIF" = "true" ]; then
93
123
  $CTS "${args[@]}" --sarif --fail-on=none > results.sarif || true
94
124
  fi
95
125
 
96
- node -e '
97
- const r = require("./cleartoship.json");
98
- const out = process.env.GITHUB_OUTPUT;
99
- const fs = require("fs");
100
- fs.appendFileSync(out, `verdict=${r.verdict}\n`);
101
- fs.appendFileSync(out, `critical=${r.counts.critical}\n`);
102
- fs.appendFileSync(out, `high=${r.counts.high}\n`);
103
- fs.appendFileSync(out, `total=${r.findings.length}\n`);
104
- '
126
+ # Counting blocking findings here — off the full report, which carries every
127
+ # severity — is what makes `fail-on: medium` and `fail-on: low` mean what they
128
+ # say. A gate rebuilt from the critical/high outputs alone silently cannot.
129
+ # Fed to node over stdin with a quoted heredoc: nothing here is parsed by
130
+ # bash, so an apostrophe in a comment cannot silently truncate the script.
131
+ node <<'NODE'
132
+ const fs = require("fs");
133
+ const r = JSON.parse(fs.readFileSync("cleartoship.json", "utf8"));
134
+ const rank = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
135
+ const gate = process.env.INPUT_FAIL_ON;
136
+ if (gate !== "none" && !(gate in rank)) {
137
+ console.error(`::error::Unknown fail-on value "${gate}". Use critical, high, medium, low or none.`);
138
+ process.exit(2);
139
+ }
140
+ const blocking = gate === "none"
141
+ ? 0
142
+ : r.findings.filter((f) => rank[f.severity] >= rank[gate]).length;
143
+ // Written out one by one rather than in a loop, so every declared output
144
+ // stays greppable and the action test can prove each one is produced.
145
+ const out = process.env.GITHUB_OUTPUT;
146
+ fs.appendFileSync(out, `verdict=${r.verdict}\n`);
147
+ fs.appendFileSync(out, `critical=${r.counts.critical}\n`);
148
+ fs.appendFileSync(out, `high=${r.counts.high}\n`);
149
+ fs.appendFileSync(out, `medium=${r.counts.medium}\n`);
150
+ fs.appendFileSync(out, `low=${r.counts.low}\n`);
151
+ fs.appendFileSync(out, `total=${r.findings.length}\n`);
152
+ fs.appendFileSync(out, `blocking=${blocking}\n`);
153
+ NODE
105
154
 
106
155
  # Job summary — always shown on the run.
107
156
  cat cleartoship.md >> "$GITHUB_STEP_SUMMARY"
@@ -113,6 +162,7 @@ runs:
113
162
  env:
114
163
  GH_TOKEN: ${{ inputs.github-token }}
115
164
  PR: ${{ github.event.pull_request.number }}
165
+ REPO: ${{ github.repository }}
116
166
  run: |
117
167
  # A sticky comment: find a previous ClearToShip comment and edit it, so
118
168
  # re-runs update in place instead of piling up.
@@ -120,12 +170,12 @@ runs:
120
170
  printf '%s\n\n' "$marker" > body.md
121
171
  cat cleartoship.md >> body.md
122
172
 
123
- id=$(gh api "repos/${{ github.repository }}/issues/${PR}/comments" \
173
+ id=$(gh api "repos/${REPO}/issues/${PR}/comments" \
124
174
  --jq "map(select(.body | contains(\"$marker\"))) | .[0].id // empty" 2>/dev/null || true)
125
175
  if [ -n "$id" ]; then
126
- gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${id}" -F body=@body.md >/dev/null
176
+ gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null
127
177
  else
128
- gh api -X POST "repos/${{ github.repository }}/issues/${PR}/comments" -F body=@body.md >/dev/null
178
+ gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null
129
179
  fi
130
180
 
131
181
  - name: Upload SARIF
@@ -136,19 +186,12 @@ runs:
136
186
 
137
187
  - name: Apply severity gate
138
188
  shell: bash
189
+ env:
190
+ GATE: ${{ inputs.fail-on }}
191
+ BLOCKING: ${{ steps.scan.outputs.blocking }}
139
192
  run: |
140
- gate="${{ inputs.fail-on }}"
141
- [ "$gate" = "none" ] && exit 0
142
- declare -A rank=( [critical]=4 [high]=3 [medium]=2 [low]=1 )
143
- floor=${rank[$gate]:-4}
144
- crit=${{ steps.scan.outputs.critical }}
145
- high=${{ steps.scan.outputs.high }}
146
- # Only critical and high are wired as outputs; medium/low never block by default.
147
- blocking=0
148
- [ "$floor" -le 4 ] && blocking=$((blocking + crit))
149
- [ "$floor" -le 3 ] && blocking=$((blocking + high))
150
- if [ "$blocking" -gt 0 ]; then
151
- echo "::error::ClearToShip found $blocking finding(s) at or above '$gate'."
193
+ if [ "$BLOCKING" -gt 0 ]; then
194
+ echo "::error::ClearToShip found ${BLOCKING} finding(s) at or above '${GATE}'."
152
195
  exit 1
153
196
  fi
154
- echo "ClearToShip: clear to ship at '$gate'."
197
+ echo "ClearToShip: clear to ship at '${GATE}'."
@@ -0,0 +1,16 @@
1
+ import type { ModuleIndex } from '../utils/modules.js';
2
+ import type { File } from '@babel/types';
3
+ export interface AuthHelperOptions {
4
+ /** Callee names that prove the caller's identity was checked. */
5
+ authCalls: string[];
6
+ /** Higher-order wrappers that apply auth for the function they wrap. */
7
+ authWrappers: string[];
8
+ index: ModuleIndex;
9
+ /** Per-scan memo, keyed by absolute file path. */
10
+ cache: Map<string, ReadonlySet<string>>;
11
+ }
12
+ /**
13
+ * Names that mean "authenticated" when used inside `file`. Includes helpers
14
+ * defined in the file and symbols imported from first-party modules.
15
+ */
16
+ export declare function authNamesFor(file: string, opts: AuthHelperOptions, preparsed?: File | null, depth?: number, stack?: Set<string>): ReadonlySet<string>;
@@ -0,0 +1,169 @@
1
+ import { read } from '../utils/files.js';
2
+ import { parseSource, calleeName, calleeTail } from '../utils/ast.js';
3
+ /**
4
+ * Recognising an authenticated caller only when the check is written inline
5
+ * inside the action is wrong for the way real apps are built: session
6
+ * verification is factored into `lib/auth.ts` (or a framework helper such as
7
+ * Shopify's `handleSessionToken`) and every action calls that. Reading only the
8
+ * action body then reports each of them as unauthenticated — four false
9
+ * criticals on one dogfooded Shopify app, which is exactly the kind of finding
10
+ * that teaches people to ignore the tool.
11
+ *
12
+ * This module answers the narrower question the scanner actually needs: which
13
+ * *names*, as spelled in this file, stand for "the caller was authenticated"?
14
+ * A name qualifies when the function behind it — defined here, or exported by a
15
+ * first-party module this file imports — itself performs a recognised auth call,
16
+ * directly or through another such helper. Third-party packages are never
17
+ * followed, so a call into `node_modules` still proves nothing on its own.
18
+ */
19
+ /** How many import hops to follow before giving up. */
20
+ const MAX_DEPTH = 3;
21
+ /** Rounds of the same-file fixed point — helpers calling helpers calling helpers. */
22
+ const MAX_ROUNDS = 4;
23
+ const EMPTY = new Set();
24
+ function matchesAny(name, list) {
25
+ for (const candidate of list) {
26
+ if (name === candidate || name.endsWith('.' + candidate))
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ /** Every callee name (dotted and bare) reachable inside one function body. */
32
+ function callNamesIn(node, out, depth = 0) {
33
+ if (!node || typeof node !== 'object' || depth > 400)
34
+ return;
35
+ if (Array.isArray(node)) {
36
+ for (const child of node)
37
+ callNamesIn(child, out, depth + 1);
38
+ return;
39
+ }
40
+ if (typeof node.type !== 'string')
41
+ return;
42
+ if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
43
+ const full = calleeName(node.callee);
44
+ if (full) {
45
+ out.add(full);
46
+ out.add(calleeTail(node.callee));
47
+ }
48
+ }
49
+ for (const key of Object.keys(node)) {
50
+ if (key === 'loc' || key === 'leadingComments' || key === 'trailingComments')
51
+ continue;
52
+ const value = node[key];
53
+ if (value && typeof value === 'object')
54
+ callNamesIn(value, out, depth + 1);
55
+ }
56
+ }
57
+ /** Top-level `function f()` / `const f = () => {}`, exported or not. */
58
+ function collectFunctions(ast) {
59
+ const found = new Map();
60
+ const addFunction = (name, body) => {
61
+ if (!name || !body)
62
+ return;
63
+ const calls = new Set();
64
+ callNamesIn(body, calls);
65
+ found.set(name, calls);
66
+ };
67
+ const fromDeclaration = (decl) => {
68
+ if (!decl)
69
+ return;
70
+ if (decl.type === 'FunctionDeclaration') {
71
+ addFunction(decl.id?.name, decl.body);
72
+ return;
73
+ }
74
+ if (decl.type !== 'VariableDeclaration')
75
+ return;
76
+ for (const d of decl.declarations ?? []) {
77
+ if (d?.id?.type !== 'Identifier')
78
+ continue;
79
+ const init = d.init;
80
+ if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
81
+ addFunction(d.id.name, init.body);
82
+ }
83
+ }
84
+ };
85
+ for (const stmt of ast.program.body) {
86
+ if (stmt.type === 'ExportNamedDeclaration')
87
+ fromDeclaration(stmt.declaration);
88
+ else
89
+ fromDeclaration(stmt);
90
+ }
91
+ return found;
92
+ }
93
+ /**
94
+ * Names that mean "authenticated" when used inside `file`. Includes helpers
95
+ * defined in the file and symbols imported from first-party modules.
96
+ */
97
+ export function authNamesFor(file, opts, preparsed, depth = 0, stack = new Set()) {
98
+ const memo = opts.cache.get(file);
99
+ if (memo)
100
+ return memo;
101
+ // A cycle (a imports b imports a) resolves to nothing rather than looping.
102
+ if (stack.has(file) || depth > MAX_DEPTH)
103
+ return EMPTY;
104
+ let ast = preparsed ?? null;
105
+ if (!ast) {
106
+ const source = read(file);
107
+ if (source === null)
108
+ return EMPTY;
109
+ // Cheap bail-out: a file that mentions none of the auth vocabulary cannot
110
+ // define an auth helper, and most files in a repo are that file.
111
+ if (!opts.authCalls.some((c) => source.includes(c.split('.').pop()))) {
112
+ opts.cache.set(file, EMPTY);
113
+ return EMPTY;
114
+ }
115
+ ast = parseSource(source, file);
116
+ if (!ast) {
117
+ opts.cache.set(file, EMPTY);
118
+ return EMPTY;
119
+ }
120
+ }
121
+ stack.add(file);
122
+ const credited = new Set();
123
+ for (const stmt of ast.program.body) {
124
+ if (stmt.type !== 'ImportDeclaration')
125
+ continue;
126
+ const spec = stmt.source?.value;
127
+ const target = typeof spec === 'string' ? opts.index.resolve(spec, file) : null;
128
+ if (!target)
129
+ continue;
130
+ const exported = authNamesFor(target, opts, null, depth + 1, stack);
131
+ if (exported.size === 0)
132
+ continue;
133
+ for (const s of stmt.specifiers ?? []) {
134
+ const local = s.local?.name;
135
+ if (!local)
136
+ continue;
137
+ const imported = s.type === 'ImportSpecifier'
138
+ ? (s.imported.name ?? s.imported.value)
139
+ : s.type === 'ImportDefaultSpecifier'
140
+ ? 'default'
141
+ : null;
142
+ if (imported && exported.has(imported))
143
+ credited.add(local);
144
+ }
145
+ }
146
+ const functions = collectFunctions(ast);
147
+ for (let round = 0; round < MAX_ROUNDS; round++) {
148
+ let changed = false;
149
+ for (const [name, calls] of functions) {
150
+ if (credited.has(name))
151
+ continue;
152
+ for (const call of calls) {
153
+ if (matchesAny(call, opts.authCalls) ||
154
+ matchesAny(call, opts.authWrappers) ||
155
+ credited.has(call)) {
156
+ credited.add(name);
157
+ changed = true;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ if (!changed)
163
+ break;
164
+ }
165
+ stack.delete(file);
166
+ const result = credited;
167
+ opts.cache.set(file, result);
168
+ return result;
169
+ }
@@ -65,12 +65,48 @@ const WITHHELD = new Map([
65
65
  'a packaging lint (missing "files" field), not a security finding, and wrong for apps ' +
66
66
  'rather than published libraries',
67
67
  ],
68
+ [
69
+ 'VG865',
70
+ 'declares itself a `.npmignore` lint but is registered for the `shell` language, so it ' +
71
+ 'matches almost every line of every shell script — 6 hits on two `scripts/*.sh` files, ' +
72
+ 'all false, and it is a packaging lint rather than a security finding either way',
73
+ ],
68
74
  [
69
75
  'VG1004',
70
76
  'matches every `use server` module with an exported function; it asserts "no rate limiting" ' +
71
77
  'without ever checking for it',
72
78
  ],
73
79
  ]);
80
+ /**
81
+ * Machine-generated dependency lockfiles. Their entries describe the *transitive*
82
+ * graph, which nobody in this repo wrote or can edit directly.
83
+ */
84
+ const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|Pipfile\.lock|poetry\.lock)$/i;
85
+ /**
86
+ * Rules whose signal only exists in a hand-authored manifest. The same JSON
87
+ * shapes appear all over a lockfile describing packages a dependency chose, so
88
+ * a hit there is both unactionable and, measured on the dogfooding corpus,
89
+ * wrong: `fast-glob`, `fast-deep-equal`, `common-tags`, `core-js` and
90
+ * `simple-statistics` all trip the deceptive-prefix list, and the "wildcard
91
+ * version" rule fires on transitive `"node": ">=16"` engine constraints.
92
+ * Declared package names are covered properly by CTS020-CTS027, which ask the
93
+ * registry rather than matching a prefix.
94
+ */
95
+ const MANIFEST_ONLY = new Map([
96
+ ['VG872', 'internal-name heuristic; in a lockfile it names a transitive dependency'],
97
+ ['VG873', 'deceptive-prefix heuristic; matches ordinary transitive package names'],
98
+ ['VG020', 'wildcard version; a transitive range is the dependency author\'s choice'],
99
+ ]);
100
+ /**
101
+ * Per-rule filters for a match shape the upstream regex cannot exclude on its
102
+ * own. Applied to the matched text, not the whole file.
103
+ */
104
+ const MATCH_GUARDS = {
105
+ // A `"link": true` entry is a workspace or pnpm symlink resolved to a path on
106
+ // disk rather than a tarball, so it has no integrity hash by design. Thirty of
107
+ // them in one pnpm-managed lockfile, every one reported as a false critical.
108
+ VG870: (match) => !/"link"\s*:\s*true/.test(match),
109
+ };
74
110
  /** Paths where a match is a fixture or documentation rather than shipped code. */
75
111
  const NON_PRODUCTION_PATH = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$/i;
76
112
  /** Regexes over very large files are where catastrophic backtracking bites. */
@@ -100,11 +136,15 @@ export const communityScanner = {
100
136
  continue;
101
137
  const relPath = rel(ctx.root, file);
102
138
  const fixture = NON_PRODUCTION_PATH.test(relPath);
139
+ const lockfile = LOCKFILE.test(relPath);
103
140
  const suppress = new Suppressions(source);
104
141
  filesScanned++;
105
142
  for (const rule of active) {
106
143
  if (!rule.languages.some((l) => languages.includes(l)))
107
144
  continue;
145
+ if (lockfile && MANIFEST_ONLY.has(rule.id))
146
+ continue;
147
+ const guard = MATCH_GUARDS[rule.id];
108
148
  const re = rule.pattern;
109
149
  re.lastIndex = 0;
110
150
  let m;
@@ -115,6 +155,13 @@ export const communityScanner = {
115
155
  re.lastIndex++;
116
156
  continue;
117
157
  }
158
+ // Skipping a match must not skip the non-global `break` below, or a
159
+ // rule without /g would rescan from zero forever.
160
+ if (guard && !guard(m[0])) {
161
+ if (!re.global)
162
+ break;
163
+ continue;
164
+ }
118
165
  const line = lineAt(source, m.index);
119
166
  const key = `${relPath}:${line}:${rule.id}`;
120
167
  if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
@@ -148,7 +195,8 @@ export const communityScanner = {
148
195
  result.checks.push({
149
196
  label: `Community ruleset (${active.length} rules over ${filesScanned} files)`,
150
197
  passed: result.findings.every((f) => f.severity !== 'critical'),
151
- note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy`,
198
+ note: `${SUPERSEDED.size} superseded by ClearToShip's AST checks, ${WITHHELD.size} withheld as noisy, ` +
199
+ `${MANIFEST_ONLY.size} manifest-only (not run over lockfiles)`,
152
200
  });
153
201
  return result;
154
202
  },
@@ -3,6 +3,7 @@ import { rlsScanner } from './rls.js';
3
3
  import { dependencyScanner } from './dependencies.js';
4
4
  import { secretsScanner } from './secrets.js';
5
5
  import { communityScanner } from './community.js';
6
+ import { logicScanner } from './logic.js';
6
7
  import type { Scanner } from '../types.js';
7
8
  export declare const SCANNERS: Scanner[];
8
- export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
9
+ export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, logicScanner, };
@@ -3,11 +3,13 @@ import { rlsScanner } from './rls.js';
3
3
  import { dependencyScanner } from './dependencies.js';
4
4
  import { secretsScanner } from './secrets.js';
5
5
  import { communityScanner } from './community.js';
6
+ import { logicScanner } from './logic.js';
6
7
  export const SCANNERS = [
7
8
  dependencyScanner,
8
9
  serverActionsScanner,
9
10
  rlsScanner,
10
11
  secretsScanner,
12
+ logicScanner,
11
13
  communityScanner,
12
14
  ];
13
- export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
15
+ export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, logicScanner, };
@@ -0,0 +1,2 @@
1
+ import type { Scanner } from '../types.js';
2
+ export declare const logicScanner: Scanner;