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/ATTRIBUTION.md +115 -0
- package/LICENSES/gitleaks-MIT.txt +21 -0
- package/LICENSES/guardvibe-Apache-2.0.txt +191 -0
- package/LICENSES/guardvibe-NOTICE.txt +4 -0
- package/README.md +125 -25
- package/SECURITY.md +70 -0
- package/action.yml +80 -37
- package/dist/scanners/auth-helpers.d.ts +16 -0
- package/dist/scanners/auth-helpers.js +169 -0
- package/dist/scanners/community.js +49 -1
- package/dist/scanners/index.d.ts +2 -1
- package/dist/scanners/index.js +3 -1
- package/dist/scanners/logic.d.ts +2 -0
- package/dist/scanners/logic.js +226 -0
- package/dist/scanners/server-actions.js +171 -14
- package/dist/utils/modules.d.ts +16 -0
- package/dist/utils/modules.js +67 -0
- package/examples/security-cli.yml +42 -0
- package/examples/security.yml +7 -1
- package/package.json +4 -4
package/action.yml
CHANGED
|
@@ -27,9 +27,12 @@ inputs:
|
|
|
27
27
|
required: false
|
|
28
28
|
default: 'false'
|
|
29
29
|
version:
|
|
30
|
-
description:
|
|
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:
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
ver="$
|
|
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 "
|
|
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 "$
|
|
86
|
-
[ "$
|
|
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 [ "$
|
|
122
|
+
if [ "$INPUT_SARIF" = "true" ]; then
|
|
93
123
|
$CTS "${args[@]}" --sarif --fail-on=none > results.sarif || true
|
|
94
124
|
fi
|
|
95
125
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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
|
-
|
|
141
|
-
|
|
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 '$
|
|
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
|
},
|
package/dist/scanners/index.d.ts
CHANGED
|
@@ -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, };
|
package/dist/scanners/index.js
CHANGED
|
@@ -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, };
|