@clear-capabilities/agentic-security-scanner 0.195.26 → 0.195.28
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/CHANGELOG.md +27 -0
- package/bin/agentic-security.js +39 -7
- package/dist/396.index.js +936 -0
- package/dist/{60f58f89664e53fc2f96.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/7838.index.js +22 -0
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/CLAUDE.md +10 -2
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/custom-rules.js +7 -2
- package/src/posture/drift.js +19 -1
- package/src/posture/provenance/coordinator.js +6 -2
- package/src/posture/provenance/dag-walk.js +7 -3
- package/src/posture/provenance/origin-resolver.js +16 -2
- package/src/posture/provenance/providers/github.js +6 -3
- package/src/posture/provenance/providers/gitlab.js +5 -3
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/rule-compiler/cli.js +76 -21
- package/src/posture/rule-compiler/negative-corpus.js +39 -0
- package/src/posture/rule-compiler/promotion.js +21 -0
- package/src/posture/rule-compiler/regression.js +39 -9
- package/src/posture/rule-compiler/store.js +72 -6
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
package/src/runScan.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
6
7
|
import { listFiles } from './util/glob.js';
|
|
8
|
+
import { buildScanInventory } from './scope/inventory.js';
|
|
7
9
|
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
8
10
|
import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
|
|
9
11
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
10
12
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
11
13
|
import { stampScan } from './posture/ruleset-version.js';
|
|
14
|
+
import { annotateFindingRecords, buildScanRecord } from './pipeline/evidence-record.js';
|
|
15
|
+
import { SCANNER_VERSION } from './posture/version.js';
|
|
12
16
|
import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
|
|
13
17
|
|
|
14
18
|
const DEP_FILE_NAMES = new Set([
|
|
@@ -77,8 +81,23 @@ const DEFAULT_IGNORE = [
|
|
|
77
81
|
...WALK_IGNORE_GLOBS,
|
|
78
82
|
];
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
// Directory names a Git-scoped scan still refuses regardless of Git state:
|
|
85
|
+
// dependency trees and Git's own metadata. The broader DEFAULT_IGNORE list
|
|
86
|
+
// (vendor, test, bin, ...) does NOT apply in Git-scoped mode: tracked code is
|
|
87
|
+
// not discarded because of its folder name (PRD FR 01).
|
|
88
|
+
const GIT_SCOPE_SAFETY_IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
89
|
+
|
|
90
|
+
export async function readTree(root, { ignore = [], gitScope = process.env.AGENTIC_SECURITY_GIT_SCOPE === '1', scope = {} } = {}) {
|
|
91
|
+
let entries;
|
|
92
|
+
let inventory = null;
|
|
93
|
+
if (gitScope) {
|
|
94
|
+
// One inventory decides eligibility before any content is read.
|
|
95
|
+
inventory = buildScanInventory(root, scope);
|
|
96
|
+
const drop = [...GIT_SCOPE_SAFETY_IGNORE, ...ignore];
|
|
97
|
+
entries = inventory.included.filter((rel) => !drop.some((g) => path.matchesGlob(rel, g)));
|
|
98
|
+
} else {
|
|
99
|
+
entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
|
|
100
|
+
}
|
|
82
101
|
const fileContents = {};
|
|
83
102
|
const depFileContents = {};
|
|
84
103
|
const oversize = [];
|
|
@@ -104,6 +123,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
104
123
|
else if (/\.proto$/i.test(base)) depFileContents[rel] = content;
|
|
105
124
|
else if (/\.(?:graphql|gql)$/i.test(base)) depFileContents[rel] = content;
|
|
106
125
|
else if (/\.tf$/i.test(base)) depFileContents[rel] = content;
|
|
126
|
+
else if (/^(?:BUCK|TARGETS)(?:\.v2)?$/.test(base) && /\bhaskell_|prebuilt_haskell_library/.test(content)) depFileContents[rel] = content;
|
|
107
127
|
// A Kubernetes manifest is admitted on CONTENT, not on living under a
|
|
108
128
|
// directory named k8s/ — see isKubernetesManifest. Without this the
|
|
109
129
|
// k8s-admission detector is wired into the dispatch and never invoked by it.
|
|
@@ -113,7 +133,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
113
133
|
// A CloudFormation template is a `.yaml`/`.json` that no path predicate can
|
|
114
134
|
// recognise — same problem as a Kubernetes manifest, same fix, and the same
|
|
115
135
|
// requirement that BOTH gates open: runFullScan re-filters this exact list.
|
|
116
|
-
if (shouldScan(rel) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
|
|
136
|
+
if (shouldScan(rel, !!gitScope) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
|
|
117
137
|
// Auxiliary files: .properties files are referenced by Java rules
|
|
118
138
|
// (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
|
|
119
139
|
// aliases). They are not scannable for vulns themselves, but the
|
|
@@ -127,7 +147,7 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
127
147
|
const { contents } = readExplicitExports(root, manifests);
|
|
128
148
|
for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
|
|
129
149
|
} catch { /* exports are best-effort evidence */ }
|
|
130
|
-
return { fileContents, depFileContents, oversize };
|
|
150
|
+
return { fileContents, depFileContents, oversize, inventory };
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
@@ -174,6 +194,16 @@ export async function languageFilesIgnoredByPolicy(root, fileContents, depFileCo
|
|
|
174
194
|
return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
// FR 02: an explicit package layout for Buck or custom builds, `{ "packages": { "name": ["dir", ...] } }`.
|
|
198
|
+
// Read as data only; nothing in the map is executed and the build is not run.
|
|
199
|
+
function loadHaskellSourceMap(file, root) {
|
|
200
|
+
if (!file) return null;
|
|
201
|
+
const abs = path.resolve(root, file);
|
|
202
|
+
const doc = JSON.parse(fsSync.readFileSync(abs, 'utf8'));
|
|
203
|
+
if (!doc || typeof doc !== 'object' || !doc.packages || typeof doc.packages !== 'object') throw new Error(`Haskell source map ${file} must be {"packages": {"<name>": ["<dir>", ...]}}`);
|
|
204
|
+
return { packages: doc.packages, file: path.relative(root, abs) };
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
export async function runScan(rootDir, opts = {}) {
|
|
178
208
|
const root = path.resolve(rootDir);
|
|
179
209
|
const startedAt = new Date().toISOString();
|
|
@@ -202,6 +232,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
202
232
|
// false claim of coverage.
|
|
203
233
|
let completeScan = true;
|
|
204
234
|
let sizeSkipped = [];
|
|
235
|
+
let scopeInventory = null;
|
|
205
236
|
if (opts.fileContents) {
|
|
206
237
|
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
207
238
|
// construction a subset of the tree, not a scan of it.
|
|
@@ -209,7 +240,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
209
240
|
depFileContents = opts.depFileContents || {};
|
|
210
241
|
completeScan = false;
|
|
211
242
|
} else {
|
|
212
|
-
({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
|
|
243
|
+
({ fileContents, depFileContents, oversize: sizeSkipped, inventory: scopeInventory } = await readTree(root, opts));
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
// Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
|
|
@@ -254,10 +285,33 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
254
285
|
|
|
255
286
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
256
287
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
257
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
|
|
288
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped, haskellSourceMap: loadHaskellSourceMap(opts.haskellSourceMap, root), gitScope: !!scopeInventory }, opts.onProgress || (()=>{}));
|
|
258
289
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
259
290
|
// notify if the operator pinned a different version than what's installed.
|
|
260
291
|
try { stampScan(root, scan); } catch {}
|
|
292
|
+
// FR 01: additive scope record (never replaces an existing field). Skipped paths are
|
|
293
|
+
// capped; the counts are exact.
|
|
294
|
+
if (scopeInventory) {
|
|
295
|
+
const reasons = {};
|
|
296
|
+
for (const x of scopeInventory.skipped) reasons[x.reason] = (reasons[x.reason] || 0) + 1;
|
|
297
|
+
scan.scope = {
|
|
298
|
+
schema: 1,
|
|
299
|
+
policy: scopeInventory.policy,
|
|
300
|
+
includedCount: scopeInventory.included.length,
|
|
301
|
+
// The included list itself lets a later comparison tell "no longer reported because it was fixed" from "no longer reported
|
|
302
|
+
// because a narrower scan did not look". Kept only when small enough not to bloat the record.
|
|
303
|
+
...(scopeInventory.included.length <= 5000 ? { included: scopeInventory.included } : { includedOmitted: true }),
|
|
304
|
+
skippedCount: scopeInventory.skipped.length,
|
|
305
|
+
skippedByReason: reasons,
|
|
306
|
+
skipped: scopeInventory.skipped.slice(0, 500),
|
|
307
|
+
overrides: scopeInventory.overrides,
|
|
308
|
+
coverageConditions: scopeInventory.skipped.filter((x) => x.coverageCondition).slice(0, 100),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// FR 10: additive evidence contract. Finding records carry the source hash and span; the scan record ties the run to a commit,
|
|
312
|
+
// dirty-file digests, the ignore-policy digest and the invocation. Neither replaces an existing field.
|
|
313
|
+
try { annotateFindingRecords(scan.findings, fileContents, { engineVersion: SCANNER_VERSION }); } catch {}
|
|
314
|
+
try { scan.scanRecord = buildScanRecord({ root, scan, meta: { mode: opts.changedSince ? 'incremental' : 'full', durationMs: Date.now() - t0 }, argv: opts.invocation || [], engineVersion: SCANNER_VERSION }); } catch {}
|
|
261
315
|
// Append snapshot to history for /security-trend (non-blocking, never throws)
|
|
262
316
|
try { appendScanSnapshot(scan, root); } catch {}
|
|
263
317
|
return {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// OIDC trust policy evidence found in the repository itself (PRD FR 06).
|
|
2
|
+
//
|
|
3
|
+
// A repository can only show the cloud trust policy when that policy is kept in it (Terraform, CloudFormation, a JSON policy). Absence
|
|
4
|
+
// from the repository is not absence from the cloud account, so `absent` stays needs-review. Classification is of the `sub` (subject)
|
|
5
|
+
// condition for the GitHub OIDC issuer:
|
|
6
|
+
// restrictive every subject pins an exact repository and a ref or environment, with no wildcard beyond the ref part
|
|
7
|
+
// permissive a wildcard repository or owner, or a trust statement for the issuer with no subject condition at all
|
|
8
|
+
// mismatched no subject pins this repository (`ownRepository`, when it is known); a policy that trusts this repository and
|
|
9
|
+
// others as well is not a mismatch, since one role is often shared by several repositories
|
|
10
|
+
// absent no policy for the GitHub issuer exists in the scanned files
|
|
11
|
+
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
15
|
+
|
|
16
|
+
const ISSUER = /token\.actions\.githubusercontent\.com/;
|
|
17
|
+
const SUB_RE = /token\.actions\.githubusercontent\.com(?::|"\s*:\s*\{?\s*")?\s*sub["']?\s*[:=]?\s*[\[\{]?\s*["']([^"'\]\}]+)["']/gi;
|
|
18
|
+
const SUB_LOOSE = /["']?(?:token\.actions\.githubusercontent\.com:)?sub["']?\s*[:=]\s*\[?\s*["'](repo:[^"']+)["']/gi;
|
|
19
|
+
|
|
20
|
+
export function classifyOidcTrust(files, { ownRepository = null } = {}) {
|
|
21
|
+
const policies = [];
|
|
22
|
+
for (const [file, text] of Object.entries(files || {})) {
|
|
23
|
+
if (typeof text !== 'string' || !ISSUER.test(text)) continue;
|
|
24
|
+
if (!/\.(?:tf|json|ya?ml|template|hcl)$/i.test(file)) continue;
|
|
25
|
+
const subs = new Set();
|
|
26
|
+
for (const re of [SUB_RE, SUB_LOOSE]) { re.lastIndex = 0; let m; while ((m = re.exec(text))) subs.add(m[1]); }
|
|
27
|
+
policies.push({ file, subjects: [...subs] });
|
|
28
|
+
}
|
|
29
|
+
if (!policies.length) return { status: 'absent', policies: [], note: 'no trust policy for the GitHub OIDC issuer in the scanned files; the cloud account may still have one' };
|
|
30
|
+
const subjects = policies.flatMap((p) => p.subjects.map((s) => ({ file: p.file, subject: s })));
|
|
31
|
+
if (!subjects.length) return { status: 'permissive', policies, note: 'a trust statement names the GitHub OIDC issuer but no subject condition was found' };
|
|
32
|
+
const bad = subjects.find(({ subject }) => /^\*$|^repo:\*|^repo:[^/:*]+\/\*(?::|$)|:\*$/.test(subject) && !/^repo:[^/:*]+\/[^/:*]+:(?:ref|environment|pull_request)/.test(subject));
|
|
33
|
+
if (bad) return { status: 'permissive', policies, evidence: bad, note: `subject "${bad.subject}" admits more than one repository or any ref` };
|
|
34
|
+
if (ownRepository) {
|
|
35
|
+
const repoOf = (subject) => { const m = /^repo:([^/:*]+\/[^/:*]+)/.exec(subject); return m ? m[1].toLowerCase() : null; };
|
|
36
|
+
const mine = subjects.some(({ subject }) => repoOf(subject) === ownRepository.toLowerCase());
|
|
37
|
+
if (!mine) {
|
|
38
|
+
const other = subjects.find(({ subject }) => repoOf(subject));
|
|
39
|
+
return { status: 'mismatched', policies, evidence: other || subjects[0], note: `no subject pins ${ownRepository}; the policy in the repository is not the one this workflow's token is checked against` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { status: 'restrictive', policies, note: 'every subject pins one repository and a ref or environment' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Apply the classification to OIDC findings in place. Never closes a finding on repository evidence alone. */
|
|
46
|
+
export function applyOidcTrust(findings, files, opts = {}) {
|
|
47
|
+
const c = classifyOidcTrust(files, opts);
|
|
48
|
+
for (const f of findings || []) {
|
|
49
|
+
if (!f || !/^Pipeline: OIDC id-token: write/.test(String(f.vuln || ''))) continue;
|
|
50
|
+
f.oidcTrust = { status: c.status, note: c.note, ...(c.evidence ? { evidence: c.evidence } : {}), ownRepository: opts.ownRepository || null, ownRepositoryBasis: opts.ownRepositoryBasis || (opts.ownRepository ? 'supplied' : 'unknown: the mismatched check was not made') };
|
|
51
|
+
if (c.status === 'permissive' || c.status === 'mismatched') { f.severity = 'high'; f.disposition = 'needs-review'; f.missingEvidence = ['confirm the deployed trust policy matches the one in the repository']; }
|
|
52
|
+
else if (c.status === 'restrictive') { f.missingEvidence = ['confirm the deployed trust policy matches the one in the repository']; f.severity = 'low'; }
|
|
53
|
+
// absent: unchanged, the cloud-side policy is simply not verifiable from here
|
|
54
|
+
}
|
|
55
|
+
return c;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* This repository's GitHub name (`owner/repo`), with the basis it came from: the `origin` remote of the scanned tree, else
|
|
60
|
+
* `GITHUB_REPOSITORY` when the scan runs inside GitHub Actions on its own checkout. Null when neither names a GitHub repository;
|
|
61
|
+
* the mismatched check is then not made, and the report says so.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveOwnRepository(scanRoot, env = process.env) {
|
|
64
|
+
if (scanRoot) {
|
|
65
|
+
const r = spawnSync('git', hardenGitArgs(['config', '--get', 'remote.origin.url']), { cwd: scanRoot, env: hardenGitEnv({}), encoding: 'utf8' });
|
|
66
|
+
const url = r.status === 0 ? String(r.stdout || '').trim() : '';
|
|
67
|
+
const m = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
68
|
+
if (m) return { name: m[1], basis: 'git remote origin' };
|
|
69
|
+
}
|
|
70
|
+
if (env.GITHUB_ACTIONS === 'true' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY || '') && env.GITHUB_WORKSPACE && scanRoot && path.resolve(env.GITHUB_WORKSPACE) === path.resolve(scanRoot)) {
|
|
71
|
+
return { name: env.GITHUB_REPOSITORY, basis: 'GITHUB_REPOSITORY (GitHub Actions)' };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
package/src/sast/pipeline.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { analyzeWorkflow } from './workflow-flow.js';
|
|
1
2
|
// 0.7.0 Feat-9: Pipeline / GitHub Actions integrity detector with PBOM emitter.
|
|
2
3
|
//
|
|
3
4
|
// Catches the canonical CI/CD security mistakes:
|
|
@@ -10,7 +11,8 @@
|
|
|
10
11
|
//
|
|
11
12
|
// Same finding shape as scanIaC; produced separately so the rule set is small and tunable.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
// Workflows, and composite actions kept in the repository (`.github/actions/<name>/action.yml`, or an action.yml at any depth).
|
|
15
|
+
const _GH_WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/.*\.ya?ml$|(?:^|\/)\.github\/actions\/.*\.ya?ml$|(?:^|\/)action\.ya?ml$/i;
|
|
14
16
|
const _NONPROD_RE = /(?:^|\/)(?:tests?|examples?|fixtures?)\//i;
|
|
15
17
|
|
|
16
18
|
|
|
@@ -135,6 +137,14 @@ const PIPELINE_PATTERNS = [
|
|
|
135
137
|
},
|
|
136
138
|
];
|
|
137
139
|
|
|
140
|
+
// Rules the YAML-aware analyzer (workflow-flow.js) now owns. They are kept as line
|
|
141
|
+
// patterns only as the fallback for a file that does not parse as workflow YAML.
|
|
142
|
+
const STRUCTURAL_VULNS = new Set([
|
|
143
|
+
'Pipeline: pull_request_target checks out untrusted pull request code',
|
|
144
|
+
'Pipeline: untrusted github.event input interpolated into shell context',
|
|
145
|
+
'Pipeline: OIDC id-token: write without explicit aud restriction',
|
|
146
|
+
]);
|
|
147
|
+
|
|
138
148
|
export function scanPipeline(fp, raw) {
|
|
139
149
|
if (!_GH_WORKFLOW_RE.test(fp.replace(/\\/g, '/'))) return [];
|
|
140
150
|
if (_NONPROD_RE.test(fp.replace(/\\/g, '/'))) return [];
|
|
@@ -142,7 +152,10 @@ export function scanPipeline(fp, raw) {
|
|
|
142
152
|
const lines = raw.split('\n');
|
|
143
153
|
const findings = [];
|
|
144
154
|
const seen = new Set();
|
|
155
|
+
const structural = analyzeWorkflow(fp, raw);
|
|
156
|
+
if (structural) for (const f of structural) { seen.add(f.id); findings.push(f); }
|
|
145
157
|
for (const p of PIPELINE_PATTERNS) {
|
|
158
|
+
if (structural && STRUCTURAL_VULNS.has(p.vuln)) continue;
|
|
146
159
|
if (p.contextRe) {
|
|
147
160
|
const present = p.contextRe.test(raw);
|
|
148
161
|
if (p.contextNeg && present) continue; // suppress: required context exists
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// YAML-aware GitHub Actions analysis (PRD FR 06).
|
|
2
|
+
//
|
|
3
|
+
// Replaces context-free line matching for two rules with structural analysis:
|
|
4
|
+
// * template injection: is an untrusted value substituted into executable
|
|
5
|
+
// script text, passed as data through env, or later evaluated as code?
|
|
6
|
+
// * OIDC: `id-token: write` is read from the PARSED permissions map (a
|
|
7
|
+
// commented example is not a grant), and is reported as "trust policy not
|
|
8
|
+
// verifiable from the repository", never as a proven missing restriction.
|
|
9
|
+
//
|
|
10
|
+
// Pure: (file path, raw text) in, findings out. Returns null when the file is
|
|
11
|
+
// not parseable workflow YAML so the caller can fall back to the line rules.
|
|
12
|
+
// Nothing here executes workflow content.
|
|
13
|
+
|
|
14
|
+
import { load } from '../util/yaml.js';
|
|
15
|
+
|
|
16
|
+
// Event fields an outsider controls. Dispatch/reusable inputs are handled
|
|
17
|
+
// separately: their author's privilege is assessed, and a `choice` input with
|
|
18
|
+
// declared options is constrained.
|
|
19
|
+
const UNTRUSTED_EVENT = [
|
|
20
|
+
/^github\.event\.(?:issue|pull_request|discussion)\.(?:title|body)$/,
|
|
21
|
+
/^github\.event\.(?:comment|review|review_comment)\.body$/,
|
|
22
|
+
/^github\.event\.pull_request\.head\.(?:ref|label|repo\.default_branch)$/,
|
|
23
|
+
/^github\.event\.(?:head_commit|commits\.\*|commits\[\d+\])\.(?:message|author\.(?:name|email))$/,
|
|
24
|
+
/^github\.event\.head_commit\.(?:message|author\.(?:name|email))$/,
|
|
25
|
+
/^github\.event\.workflow_run\.(?:head_branch|head_commit\.message|display_title)$/,
|
|
26
|
+
/^github\.head_ref$/,
|
|
27
|
+
];
|
|
28
|
+
const INPUT_EXPR = /^(?:github\.event\.inputs|inputs)\.([A-Za-z_][\w-]*)$/;
|
|
29
|
+
const EXPR_RE = /\$\{\{\s*([^}]*?)\s*\}\}/g;
|
|
30
|
+
|
|
31
|
+
// Second interpretation of an env-carried value: the shell re-parses its content as code.
|
|
32
|
+
const EVAL_SINKS = (v) => [
|
|
33
|
+
new RegExp(`\\beval\\b[^\\n;|&]*\\$\\{?${v}\\b`),
|
|
34
|
+
new RegExp(`\\b(?:ba|z|da)?sh\\s+-c\\s+["']?[^\\n]*\\$\\{?${v}\\b`),
|
|
35
|
+
new RegExp(`\\bsource\\s+<\\(\\s*echo\\s+[^)]*\\$\\{?${v}\\b`),
|
|
36
|
+
new RegExp(`\\bInvoke-Expression\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
|
|
37
|
+
new RegExp(`\\biex\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
|
|
38
|
+
new RegExp(`\\bpwsh\\s+-(?:c|command)\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const AGENT_ACTION = /(?:^|\/)(?:claude-code|claude|codex|copilot|gemini|openhands|aider|swe-agent)[\w-]*(?:-action|-agent)?@/i;
|
|
42
|
+
const UNTRUSTED_TRIGGERS = ['issues', 'issue_comment', 'pull_request_target', 'pull_request_review', 'pull_request_review_comment', 'discussion', 'discussion_comment'];
|
|
43
|
+
|
|
44
|
+
function isUntrustedEvent(expr) { return UNTRUSTED_EVENT.some((re) => re.test(expr)); }
|
|
45
|
+
|
|
46
|
+
function untrustedIn(text, inputs) {
|
|
47
|
+
const out = [];
|
|
48
|
+
if (typeof text !== 'string') return out;
|
|
49
|
+
for (const m of text.matchAll(EXPR_RE)) {
|
|
50
|
+
const expr = m[1].trim();
|
|
51
|
+
if (isUntrustedEvent(expr)) { out.push({ expr, origin: expr, kind: 'event-field' }); continue; }
|
|
52
|
+
const im = INPUT_EXPR.exec(expr);
|
|
53
|
+
if (im) {
|
|
54
|
+
const decl = inputs[im[1]];
|
|
55
|
+
// A choice input with declared options is constrained to those literals.
|
|
56
|
+
if (decl && decl.type === 'choice' && Array.isArray(decl.options) && decl.options.every((o) => /^[\w.\- ]*$/.test(String(o)))) continue;
|
|
57
|
+
out.push({ expr, origin: expr, kind: 'dispatch-input', input: im[1] });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectInputs(doc) {
|
|
64
|
+
const on = doc.on ?? doc[true]; // YAML 1.1 reads a bare `on` key as boolean true
|
|
65
|
+
const inputs = {};
|
|
66
|
+
if (on && typeof on === 'object') {
|
|
67
|
+
for (const ev of ['workflow_dispatch', 'workflow_call']) {
|
|
68
|
+
const i = on[ev]?.inputs;
|
|
69
|
+
if (i && typeof i === 'object') Object.assign(inputs, i);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { on, inputs };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function triggers(on) {
|
|
76
|
+
if (typeof on === 'string') return [on];
|
|
77
|
+
if (Array.isArray(on)) return on.map(String);
|
|
78
|
+
if (on && typeof on === 'object') return Object.keys(on);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function lineOf(raw, needle, fromLine = 0) {
|
|
83
|
+
const lines = raw.split('\n');
|
|
84
|
+
for (let i = fromLine; i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
|
|
85
|
+
for (let i = 0; i < fromLine && i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function permissionsOf(p) {
|
|
90
|
+
if (p === 'write-all') return { all: 'write' };
|
|
91
|
+
return p && typeof p === 'object' ? p : {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove shell comment lines so a commented example is not read as a sink. */
|
|
95
|
+
function stripShellComments(script, shell) {
|
|
96
|
+
const hash = /^\s*#/;
|
|
97
|
+
return String(script).split('\n').filter((l) => !hash.test(l)).join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A validation guard that exits before the value is used, e.g. [[ "$X" =~ ^[a-z]+$ ]] || exit 1. */
|
|
101
|
+
function validatedBeforeUse(script, v, useIdx) {
|
|
102
|
+
const lines = script.split('\n');
|
|
103
|
+
for (let i = 0; i < lines.length && i < useIdx; i++) {
|
|
104
|
+
const l = lines[i];
|
|
105
|
+
if (new RegExp(`\\$\\{?${v}\\b`).test(l) && /(?:=~|\bgrep\s+-[A-Za-z]*q|\bcase\b)/.test(l) && /(?:exit\s+\d|return\s+\d|\bthen\b[^\n]*exit|\|\|\s*exit)/.test(l)) return true;
|
|
106
|
+
if (new RegExp(`\\$\\{?${v}\\b`).test(l) && /-notmatch|-match/.test(l) && /exit/i.test(l)) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function analyzeWorkflow(fp, raw) {
|
|
112
|
+
let doc;
|
|
113
|
+
// js-yaml is quadratic on a long run of blank lines. A run that long carries no meaning, so collapse it for parsing only;
|
|
114
|
+
// lines are located in `raw`, not in the collapsed text.
|
|
115
|
+
try { doc = load(raw.replace(/(?:[ \t]*\r?\n){20,}/g, '\n')); } catch { return null; }
|
|
116
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
117
|
+
// A composite action (`runs.using: composite`) has steps but no jobs; its `inputs.*` come from whichever workflow calls it.
|
|
118
|
+
const composite = !doc.jobs && doc.runs && doc.runs.using === 'composite' && Array.isArray(doc.runs.steps);
|
|
119
|
+
if (!composite && typeof doc.jobs !== 'object') return null;
|
|
120
|
+
const { on, inputs } = composite ? { on: {}, inputs: (doc.inputs && typeof doc.inputs === 'object') ? doc.inputs : {} } : collectInputs(doc);
|
|
121
|
+
const trig = triggers(on);
|
|
122
|
+
const wfPerm = permissionsOf(doc.permissions);
|
|
123
|
+
const wfEnv = doc.env && typeof doc.env === 'object' ? doc.env : {};
|
|
124
|
+
const findings = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
127
|
+
|
|
128
|
+
const privilegedTrigger = trig.includes('pull_request_target') || trig.includes('workflow_run');
|
|
129
|
+
const dispatch = trig.includes('workflow_dispatch') || trig.includes('workflow_call');
|
|
130
|
+
|
|
131
|
+
const jobEntries = composite ? [['(composite action)', { steps: doc.runs.steps }]] : Object.entries(doc.jobs || {});
|
|
132
|
+
for (const [jobId, job] of jobEntries) {
|
|
133
|
+
if (!job || typeof job !== 'object') continue;
|
|
134
|
+
const jobPerm = job.permissions !== undefined ? permissionsOf(job.permissions) : wfPerm;
|
|
135
|
+
const jobEnv = { ...wfEnv, ...(job.env && typeof job.env === 'object' ? job.env : {}) };
|
|
136
|
+
const writes = Object.entries(jobPerm).filter(([, v]) => v === 'write').map(([k]) => k);
|
|
137
|
+
const steps = Array.isArray(job.steps) ? job.steps : [];
|
|
138
|
+
|
|
139
|
+
if (jobPerm['id-token'] === 'write') {
|
|
140
|
+
const line = lineOf(raw, 'id-token');
|
|
141
|
+
push({
|
|
142
|
+
id: `pipeline:${fp}:${line}:oidc:${jobId}`, kind: 'iac', severity: 'low',
|
|
143
|
+
vuln: 'Pipeline: OIDC id-token: write, cloud trust policy and audience not verifiable from the repository',
|
|
144
|
+
cwe: 'CWE-1188', stride: 'Elevation of privilege', file: fp, line, snippet: 'id-token: write',
|
|
145
|
+
fix: 'Confirm the cloud-side trust policy requires this repository, a specific audience and a restricted subject (branch or environment). If no step requests a token, remove the permission.',
|
|
146
|
+
workflow: fp, job: jobId, step: null, disposition: 'needs-review',
|
|
147
|
+
missingEvidence: ['cloud trust policy (audience and subject conditions)', 'whether any action in this job requests an OIDC token'],
|
|
148
|
+
sensitiveOperation: 'OIDC token minting', privilegeBoundary: `job permissions: ${writes.join(', ') || 'none'}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
steps.forEach((step, idx) => {
|
|
153
|
+
if (!step || typeof step !== 'object') return;
|
|
154
|
+
const stepName = step.name || step.id || `step-${idx + 1}`;
|
|
155
|
+
const stepEnv = { ...jobEnv, ...(step.env && typeof step.env === 'object' ? step.env : {}) };
|
|
156
|
+
const shell = String(step.shell || job.defaults?.run?.shell || doc.defaults?.run?.shell || 'bash');
|
|
157
|
+
|
|
158
|
+
// Untrusted code checked out under a privileged trigger.
|
|
159
|
+
if (typeof step.uses === 'string' && /^actions\/checkout@/.test(step.uses) && privilegedTrigger) {
|
|
160
|
+
const ref = step.with?.ref;
|
|
161
|
+
if (typeof ref === 'string' && /github\.(?:event\.pull_request\.head\.(?:sha|ref)|head_ref)|workflow_run\.head_(?:sha|branch)/.test(ref)) {
|
|
162
|
+
const line = lineOf(raw, String(ref).trim());
|
|
163
|
+
push({
|
|
164
|
+
id: `pipeline:${fp}:${line}:untrusted-checkout`, kind: 'iac', severity: 'high',
|
|
165
|
+
vuln: 'Pipeline: pull_request_target checks out untrusted pull request code',
|
|
166
|
+
cwe: 'CWE-829', stride: 'Tampering', file: fp, line, snippet: `ref: ${ref}`,
|
|
167
|
+
fix: 'A privileged-trigger workflow runs with repository secrets. Do not check out and build the pull request head in it; use `pull_request`, or split the privileged step.',
|
|
168
|
+
workflow: fp, job: jobId, step: stepName, disposition: 'needs-review',
|
|
169
|
+
inputOrigin: ref, sensitiveOperation: 'checkout of attacker-controlled code', privilegeBoundary: `trigger ${trig.join(', ')}; permissions: ${writes.join(', ') || 'default'}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Remote and unknown actions: a value the analysis cannot follow into the action.
|
|
175
|
+
if (typeof step.uses === 'string' && !step.uses.startsWith('./') && step.with && typeof step.with === 'object') {
|
|
176
|
+
for (const [wk, wv] of Object.entries(step.with)) {
|
|
177
|
+
for (const u of untrustedIn(String(wv), inputs)) {
|
|
178
|
+
const line = lineOf(raw, String(wv).trim().split('\n')[0].slice(0, 40));
|
|
179
|
+
const scriptInput = /^actions\/github-script@/.test(step.uses) && wk === 'script';
|
|
180
|
+
push({
|
|
181
|
+
id: `pipeline:${fp}:${line}:action-input:${jobId}:${idx}:${wk}:${u.expr}`, kind: 'iac',
|
|
182
|
+
severity: scriptInput && u.kind === 'event-field' ? 'critical' : 'low',
|
|
183
|
+
vuln: scriptInput ? 'Pipeline: untrusted github.event input interpolated into github-script source' : 'Pipeline: untrusted input passed to an action this analysis cannot inspect',
|
|
184
|
+
cwe: scriptInput ? 'CWE-94' : 'CWE-20', stride: 'Tampering', file: fp, line, snippet: `${wk}: ${String(wv).trim().split('\n')[0].slice(0, 80)}`,
|
|
185
|
+
fix: scriptInput ? 'Pass the value through `env:` and read it as data (process.env.NAME); never substitute it into the script text.' : 'Confirm how the action uses this input (shell, script or URL) and pin the action to a commit.',
|
|
186
|
+
workflow: fp, job: jobId, step: stepName, inputOrigin: u.origin, inputOriginKind: u.kind,
|
|
187
|
+
sensitiveOperation: scriptInput ? 'JavaScript executed by github-script' : `input "${wk}" of ${step.uses}`,
|
|
188
|
+
privilegeBoundary: `trigger ${trig.join(', ') || 'composite action (caller decides)'}; permissions: ${writes.join(', ') || 'default'}`,
|
|
189
|
+
disposition: scriptInput && u.kind === 'event-field' ? 'detected' : 'needs-review',
|
|
190
|
+
...(scriptInput && u.kind === 'event-field' ? {} : { missingEvidence: [`source and handling of ${step.uses.split('@')[0]}`] }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Artifacts produced by an untrusted run are attacker-controlled data once a privileged workflow_run consumes them.
|
|
197
|
+
if (typeof step.uses === 'string' && /^actions\/download-artifact@/.test(step.uses) && trig.includes('workflow_run')) {
|
|
198
|
+
const line = lineOf(raw, 'download-artifact');
|
|
199
|
+
push({
|
|
200
|
+
id: `pipeline:${fp}:${line}:artifact:${jobId}:${idx}`, kind: 'iac', severity: writes.length ? 'medium' : 'low',
|
|
201
|
+
vuln: 'Pipeline: workflow_run consumes an artifact built by the triggering run',
|
|
202
|
+
cwe: 'CWE-829', stride: 'Tampering', file: fp, line, snippet: step.uses,
|
|
203
|
+
fix: 'Treat artifact content as untrusted data: validate it before use and never execute or source it in a job with write permissions or secrets.',
|
|
204
|
+
workflow: fp, job: jobId, step: stepName, inputOrigin: 'artifact from the triggering workflow run', inputOriginKind: 'artifact',
|
|
205
|
+
sensitiveOperation: 'use of artifact contents in a privileged workflow', privilegeBoundary: `trigger workflow_run; permissions: ${writes.join(', ') || 'default'}`,
|
|
206
|
+
disposition: 'needs-review', missingEvidence: ['whether the triggering run can be started from a fork', 'how the artifact contents are used after download'],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Agent scenario: model-driven action with write authority and attacker-supplied prompt content.
|
|
211
|
+
if (typeof step.uses === 'string' && AGENT_ACTION.test(step.uses) && writes.length && trig.some((t) => UNTRUSTED_TRIGGERS.includes(t))) {
|
|
212
|
+
const line = lineOf(raw, step.uses.slice(0, 30));
|
|
213
|
+
push({
|
|
214
|
+
id: `pipeline:${fp}:${line}:agent:${jobId}:${idx}`, kind: 'iac', severity: 'medium',
|
|
215
|
+
vuln: 'Pipeline: AI agent with write permissions runs on attacker-supplied content',
|
|
216
|
+
cwe: 'CWE-77', stride: 'Elevation of privilege', file: fp, line, snippet: step.uses,
|
|
217
|
+
fix: 'Restrict the agent to read-only permissions, require an approval before it can write, or run it only for trusted authors. Prompt content from issues, comments and pull requests is untrusted.',
|
|
218
|
+
workflow: fp, job: jobId, step: stepName, inputOrigin: `event content from ${trig.filter((t) => UNTRUSTED_TRIGGERS.includes(t)).join(', ')}`, inputOriginKind: 'agent-prompt',
|
|
219
|
+
sensitiveOperation: `agent actions with ${writes.join(', ')} write authority`, privilegeBoundary: `permissions: ${writes.join(', ')}`,
|
|
220
|
+
disposition: 'needs-review', missingEvidence: ['which authors can trigger the workflow', 'whether the agent has an approval step or tool restrictions'],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (typeof step.run !== 'string') return;
|
|
225
|
+
const script = stripShellComments(step.run, shell);
|
|
226
|
+
|
|
227
|
+
// 1. Untrusted expression substituted directly into the script text.
|
|
228
|
+
for (const u of untrustedIn(script, inputs)) {
|
|
229
|
+
const line = lineOf(raw, '${{', lineOf(raw, step.run.split('\n')[0].trim().slice(0, 40)) - 1);
|
|
230
|
+
const privileged = privilegedTrigger || writes.length > 0 || Object.keys(job.secrets || {}).length > 0 || /secrets\./.test(JSON.stringify(step));
|
|
231
|
+
push({
|
|
232
|
+
id: `pipeline:${fp}:${line}:script:${jobId}:${idx}:${u.expr}`, kind: 'iac',
|
|
233
|
+
severity: u.kind === 'dispatch-input' && !privilegedTrigger ? 'medium' : 'critical',
|
|
234
|
+
vuln: 'Pipeline: untrusted github.event input interpolated into shell context',
|
|
235
|
+
cwe: 'CWE-78', stride: 'Tampering', file: fp, line, snippet: `\${{ ${u.expr} }}`,
|
|
236
|
+
fix: 'Pass the value through an environment variable and reference it as data ("$VAR" / $env:VAR); never substitute it into script text.',
|
|
237
|
+
workflow: fp, job: jobId, step: stepName, inputOrigin: u.origin,
|
|
238
|
+
inputOriginKind: u.kind, sensitiveOperation: `shell script (${shell})`,
|
|
239
|
+
privilegeBoundary: `trigger ${trig.join(', ')}; ${privileged ? 'job has write permissions or secrets' : 'job is read-only'}`,
|
|
240
|
+
disposition: u.kind === 'dispatch-input' ? 'needs-review' : 'detected',
|
|
241
|
+
...(u.kind === 'dispatch-input' ? { missingEvidence: ['privilege of the dispatch input author relative to the job'] } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2. env-carried untrusted value, then re-interpreted as code.
|
|
246
|
+
for (const [name, val] of Object.entries(stepEnv)) {
|
|
247
|
+
const us = untrustedIn(String(val), inputs);
|
|
248
|
+
if (!us.length || !/^[A-Za-z_]\w*$/.test(name)) continue;
|
|
249
|
+
const sinks = EVAL_SINKS(name);
|
|
250
|
+
const hit = sinks.map((re) => re.exec(script)).find(Boolean);
|
|
251
|
+
if (!hit) continue;
|
|
252
|
+
const useIdx = script.slice(0, hit.index).split('\n').length - 1;
|
|
253
|
+
const guarded = validatedBeforeUse(script, name, useIdx);
|
|
254
|
+
const line = lineOf(raw, hit[0].trim().slice(0, 30));
|
|
255
|
+
push({
|
|
256
|
+
id: `pipeline:${fp}:${line}:env-eval:${jobId}:${idx}:${name}`, kind: 'iac',
|
|
257
|
+
severity: guarded ? 'low' : 'critical',
|
|
258
|
+
vuln: 'Pipeline: untrusted github.event input interpolated into shell context (env value later evaluated as code)',
|
|
259
|
+
cwe: 'CWE-78', stride: 'Tampering', file: fp, line, snippet: hit[0].trim(),
|
|
260
|
+
fix: `Env placement is not a sanitizer: \$${name} is parsed as code by this command. Treat it as data only (fixed command, argument array), or validate it against an allow-list first.`,
|
|
261
|
+
workflow: fp, job: jobId, step: stepName, inputOrigin: us[0].origin, inputOriginKind: us[0].kind,
|
|
262
|
+
sensitiveOperation: `second interpretation of $${name} (${shell})`,
|
|
263
|
+
privilegeBoundary: `trigger ${trig.join(', ')}; permissions: ${writes.join(', ') || 'default'}`,
|
|
264
|
+
disposition: guarded ? 'needs-review' : 'detected',
|
|
265
|
+
guards: guarded ? [`validation of $${name} before use`] : [],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
void dispatch;
|
|
271
|
+
return findings;
|
|
272
|
+
}
|
package/src/sca/basis.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Dependency finding basis (PRD FR 08). Records WHY a dependency finding exists
|
|
2
|
+
// and how strong the evidence about the dependency itself is, kept apart from
|
|
3
|
+
// reachability. A version match is a version match: unknown reachability is
|
|
4
|
+
// recorded as unknown and is never a reason to drop or call the finding safe.
|
|
5
|
+
//
|
|
6
|
+
// Version bases, weakest to strongest. None of them is deployment evidence:
|
|
7
|
+
// declared-constraint a range or pin in a manifest
|
|
8
|
+
// locked an entry in a lock file
|
|
9
|
+
// resolved-graph a resolved dependency graph (not produced by this scanner today)
|
|
10
|
+
// deployment-evidence an artifact or runtime inventory (never inferred from the repository)
|
|
11
|
+
|
|
12
|
+
const LOCK_FILES = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|Pipfile\.lock|go\.sum|Gemfile\.lock|composer\.lock|gradle\.lockfile|packages\.lock\.json|stack\.yaml\.lock|cabal\.project\.freeze|flake\.lock|mix\.lock|pubspec\.lock)$/;
|
|
13
|
+
|
|
14
|
+
export function versionBasisOf(file) {
|
|
15
|
+
return LOCK_FILES.test(String(file || '')) ? 'locked' : 'declared-constraint';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rangesOf(sc) {
|
|
19
|
+
if (Array.isArray(sc.affectedRanges) && sc.affectedRanges.length) return sc.affectedRanges;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Add `basis` to every dependency vulnerability finding. Pure with respect to severity and visibility. */
|
|
24
|
+
export function annotateScaBasis(supplyChain) {
|
|
25
|
+
for (const sc of supplyChain || []) {
|
|
26
|
+
if (!sc || sc.type !== 'vulnerable_dep') continue;
|
|
27
|
+
const vb = versionBasisOf(sc.file);
|
|
28
|
+
const gaps = [];
|
|
29
|
+
if (!rangesOf(sc)) gaps.push('affected range not recorded for this advisory');
|
|
30
|
+
if (!sc.advisoryModified) gaps.push('advisory timestamp not recorded');
|
|
31
|
+
if (!sc.fixedVersions || !sc.fixedVersions.length) gaps.push('no fixed version known');
|
|
32
|
+
if (vb === 'declared-constraint') gaps.push('version comes from a declared constraint; the installed version may differ');
|
|
33
|
+
gaps.push('no deployment evidence: a dependency listed here is not proof that it is deployed');
|
|
34
|
+
sc.basis = {
|
|
35
|
+
schema: 'sca-basis/1',
|
|
36
|
+
ecosystem: sc.ecosystem || null,
|
|
37
|
+
package: sc.name || null,
|
|
38
|
+
resolvedVersion: sc.version || null,
|
|
39
|
+
versionBasis: vb,
|
|
40
|
+
source: { file: sc.file || null, line: sc.line ?? null, direct: sc.isDirect ?? null, chain: sc.depChain || [] },
|
|
41
|
+
advisory: {
|
|
42
|
+
id: sc.osvId || null,
|
|
43
|
+
aliases: sc.cveAliases || [],
|
|
44
|
+
affectedRanges: rangesOf(sc),
|
|
45
|
+
fixedVersions: sc.fixedVersions || [],
|
|
46
|
+
source: sc.advisorySource || 'osv.dev',
|
|
47
|
+
modified: sc.advisoryModified || null,
|
|
48
|
+
published: sc.advisoryPublished || null,
|
|
49
|
+
},
|
|
50
|
+
matchBasis: 'version-range',
|
|
51
|
+
reachability: sc.functionReachable || (sc.reachable ? 'import-reachable' : 'unknown'),
|
|
52
|
+
reachabilityNote: 'reachability is optional context and never removes a valid version match',
|
|
53
|
+
deploymentEvidence: 'none',
|
|
54
|
+
gaps,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return supplyChain;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Merge findings that are the same advisory under different identifiers (an
|
|
62
|
+
* OSV/GHSA id and its CVE alias) for the same package version. The merged
|
|
63
|
+
* finding keeps every identifier and every affected occurrence, so no
|
|
64
|
+
* component or file is lost. Returns the new list; inputs are not mutated.
|
|
65
|
+
*/
|
|
66
|
+
export function dedupeAdvisoryAliases(supplyChain) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const groups = new Map(); // component key -> [{ids:Set, primary}]
|
|
69
|
+
for (const sc of supplyChain || []) {
|
|
70
|
+
if (!sc || sc.type !== 'vulnerable_dep') { out.push(sc); continue; }
|
|
71
|
+
const ck = `${sc.ecosystem}\0${sc.name}\0${sc.version}`;
|
|
72
|
+
const ids = new Set([sc.osvId, ...(sc.cveAliases || [])].filter(Boolean));
|
|
73
|
+
const list = groups.get(ck) || groups.set(ck, []).get(ck);
|
|
74
|
+
const hit = list.find((g) => [...ids].some((i) => g.ids.has(i)));
|
|
75
|
+
const occ = { file: sc.file || null, line: sc.line ?? null, direct: sc.isDirect ?? null, chain: sc.depChain || [] };
|
|
76
|
+
if (!hit) {
|
|
77
|
+
const primary = { ...sc, occurrences: [occ], advisoryIds: [...ids].sort() };
|
|
78
|
+
list.push({ ids, primary });
|
|
79
|
+
out.push(primary);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const i of ids) hit.ids.add(i);
|
|
83
|
+
hit.primary.advisoryIds = [...hit.ids].sort();
|
|
84
|
+
if (!hit.primary.occurrences.some((o) => o.file === occ.file && o.line === occ.line)) hit.primary.occurrences.push(occ);
|
|
85
|
+
if (sc.osvId && sc.osvId !== hit.primary.osvId) (hit.primary.mergedAdvisories ||= []).push(sc.osvId);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|