@clear-capabilities/agentic-security-scanner 0.195.27 → 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 +14 -0
- package/bin/agentic-security.js +35 -4
- package/dist/{90ebba38d8860ec77376.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- 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/artifact-registry.js +2 -0
- package/src/posture/drift.js +19 -1
- package/src/posture/reachability-filter.js +3 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// CLI flags for the scan scope (PRD FR 01 / FR 09). Pure: argv in, plain
|
|
2
|
+
// object out. Repeatable flags (`--include-path a --include-path b`) are why
|
|
3
|
+
// this is not read from the CLI's generic last-one-wins flag map.
|
|
4
|
+
|
|
5
|
+
const VALUE_FLAGS = new Set(['--include-path', '--exclude-path', '--include-ignored-path', '--ignore-source']);
|
|
6
|
+
const BOOL_FLAGS = new Set(['--explain-scope', '--respect-gitignore', '--no-respect-gitignore']);
|
|
7
|
+
|
|
8
|
+
export function parseScopeFlags(argv) {
|
|
9
|
+
const out = { active: false, explain: false, errors: [], scope: { respectGitignore: true, ignoreSource: 'local', includePaths: [], excludePaths: [], includeIgnoredPaths: [] } };
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
let [k, v] = argv[i].startsWith('--') ? argv[i].split(/=(.*)/s, 2) : [argv[i]];
|
|
12
|
+
if (BOOL_FLAGS.has(k)) {
|
|
13
|
+
out.active = true;
|
|
14
|
+
if (k === '--explain-scope') out.explain = true;
|
|
15
|
+
else out.scope.respectGitignore = k === '--respect-gitignore';
|
|
16
|
+
} else if (VALUE_FLAGS.has(k)) {
|
|
17
|
+
out.active = true;
|
|
18
|
+
if (v === undefined) v = argv[++i];
|
|
19
|
+
if (v === undefined || v.startsWith('--')) { out.errors.push(`${k} needs a value`); continue; }
|
|
20
|
+
if (k === '--ignore-source') {
|
|
21
|
+
if (v === 'all') out.scope.ignoreSource = 'local';
|
|
22
|
+
else if (v === 'repository') out.scope.ignoreSource = 'repository';
|
|
23
|
+
else out.errors.push(`--ignore-source must be "all" or "repository", got "${v}"`);
|
|
24
|
+
} else if (k === '--include-path') out.scope.includePaths.push(v);
|
|
25
|
+
else if (k === '--exclude-path') out.scope.excludePaths.push(v);
|
|
26
|
+
else out.scope.includeIgnoredPaths.push(v);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Human-readable scope preview: included paths, excluded subtrees, reasons, overrides, policy. */
|
|
33
|
+
export function renderScopePreview(inv, { maxLines = 40 } = {}) {
|
|
34
|
+
const L = [];
|
|
35
|
+
const p = inv.policy;
|
|
36
|
+
L.push(`Scope mode: ${p.mode}${p.respectGitignore ? '' : ' (git ignore rules disabled)'}; tracked-file semantics: ${p.trackedFileSemantics ? 'yes' : 'no'}`);
|
|
37
|
+
if (p.ruleDigest) L.push(`Effective ignore-rule digest: ${p.ruleDigest}`);
|
|
38
|
+
for (const d of p.disclosures) L.push(`Disclosure: ${d}`);
|
|
39
|
+
L.push(`Included: ${inv.included.length} file(s)`);
|
|
40
|
+
for (const f of inv.included.slice(0, maxLines)) L.push(` + ${f}`);
|
|
41
|
+
if (inv.included.length > maxLines) L.push(` ... ${inv.included.length - maxLines} more`);
|
|
42
|
+
const byReason = new Map();
|
|
43
|
+
for (const s of inv.skipped) (byReason.get(s.reason) || byReason.set(s.reason, []).get(s.reason)).push(s);
|
|
44
|
+
L.push(`Skipped: ${inv.skipped.length} entr${inv.skipped.length === 1 ? 'y' : 'ies'}`);
|
|
45
|
+
for (const [reason, items] of [...byReason].sort()) {
|
|
46
|
+
L.push(` ${reason}: ${items.length}`);
|
|
47
|
+
for (const s of items.slice(0, 5)) L.push(` - ${s.path}${s.segment ? ` (segment ${s.segment})` : ''}`);
|
|
48
|
+
if (items.length > 5) L.push(` ... ${items.length - 5} more`);
|
|
49
|
+
}
|
|
50
|
+
if (inv.overrides.length) {
|
|
51
|
+
L.push(`Overrides: ${inv.overrides.length}`);
|
|
52
|
+
for (const o of inv.overrides) L.push(` ! ${o.path} overrode ${o.overrode}`);
|
|
53
|
+
}
|
|
54
|
+
return L.join('\n') + '\n';
|
|
55
|
+
}
|