@dependably/npm-check 1.8.0 → 1.10.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/README.md +47 -0
- package/bin/cli.js +93 -1
- package/package.json +24 -6
- package/src/audit-config.js +127 -75
- package/src/audit.js +235 -35
- package/src/exceptions.js +87 -70
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +140 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +36 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +486 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/specifier.js +48 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +493 -0
- package/src/facts/version.js +11 -0
- package/src/facts/workspace.js +183 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +14 -6
- package/src/schema.js +54 -0
package/src/audit.js
CHANGED
|
@@ -4,8 +4,8 @@ import path from 'path';
|
|
|
4
4
|
import { forEachPackageEntry, detectLockfileFlavor } from './format-library.js';
|
|
5
5
|
import { validatePackageLock } from './validator.js';
|
|
6
6
|
import { validatePackageJson } from './package-json-validator.js';
|
|
7
|
-
import { validateNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
|
|
8
|
-
import { validatePnpmWorkspace } from './pnpm-workspace-validator.js';
|
|
7
|
+
import { validateNpmrc, parseNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
|
|
8
|
+
import { validatePnpmWorkspace, parsePnpmWorkspace } from './pnpm-workspace-validator.js';
|
|
9
9
|
import { isPlaceholder } from './integrity.js';
|
|
10
10
|
import { classifyRange } from './pinner.js';
|
|
11
11
|
import { walkOverrides } from './overrides.js';
|
|
@@ -666,6 +666,187 @@ const validPnpmFieldRule = {
|
|
|
666
666
|
}
|
|
667
667
|
};
|
|
668
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Lockfile portability: every `resolved` URL must point at a host this project
|
|
671
|
+
* pins to.
|
|
672
|
+
*
|
|
673
|
+
* This is deliberately NOT the same question as `secure-resolved` /
|
|
674
|
+
* `no-remote-deps`, which both consult `allowedRegistryHosts` to ask "is this
|
|
675
|
+
* host a legitimate, trusted registry?". Trust and portability are orthogonal:
|
|
676
|
+
* an org's own private mirror is entirely trusted, yet a lockfile resolving
|
|
677
|
+
* from it cannot be installed by anyone outside that network (a public CI
|
|
678
|
+
* runner, an external contributor, a GitHub build). A shared
|
|
679
|
+
* `allowedRegistryHosts` also unions across config levels, so it can only ever
|
|
680
|
+
* grow more permissive — correct for a trust allowlist, but useless as a pin,
|
|
681
|
+
* which must be able to narrow.
|
|
682
|
+
*
|
|
683
|
+
* Opt-in: with no `hosts` configured the rule is a no-op, so projects that
|
|
684
|
+
* genuinely install from a private registry are unaffected.
|
|
685
|
+
*/
|
|
686
|
+
// Trim/lowercase the configured pin list, dropping non-string and blank entries.
|
|
687
|
+
// An empty result means the rule is unconfigured, i.e. off.
|
|
688
|
+
function normalizePinnedHosts(hosts) {
|
|
689
|
+
if (!Array.isArray(hosts)) return [];
|
|
690
|
+
return hosts.filter((h) => typeof h === 'string' && h.trim()).map((h) => h.trim().toLowerCase());
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Git/file/link/workspace entries resolve outside the registry by definition —
|
|
694
|
+
// no-git-deps / secure-resolved own those.
|
|
695
|
+
function resolvesOutsideRegistry({ isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) {
|
|
696
|
+
return Boolean(isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Hostname of an entry's http(s) `resolved` URL, or null when it has none or the
|
|
700
|
+
// URL is unparseable (secure-resolved flags that; not this rule's job).
|
|
701
|
+
function resolvedHostname(entry) {
|
|
702
|
+
const resolved = entry && entry.resolved;
|
|
703
|
+
if (!resolved || !/^https?:/i.test(resolved)) return null;
|
|
704
|
+
try {
|
|
705
|
+
return new URL(resolved).hostname.toLowerCase();
|
|
706
|
+
} catch {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const resolvedRegistryPinRule = {
|
|
712
|
+
id: 'resolved-registry-pin',
|
|
713
|
+
description: 'Resolved URLs must point only at the registry hosts this project pins to',
|
|
714
|
+
defaultSeverity: 'error',
|
|
715
|
+
// pnpm lockfiles carry no `resolved` URLs (the registry is implied by config),
|
|
716
|
+
// so there is nothing to pin.
|
|
717
|
+
flavors: ['npm'],
|
|
718
|
+
check({ lockfile, options }) {
|
|
719
|
+
const findings = [];
|
|
720
|
+
// Unconfigured == off. Pinning is a per-project decision, not a default.
|
|
721
|
+
const pinned = normalizePinnedHosts(options.hosts);
|
|
722
|
+
if (pinned.length === 0 || !lockfile.packages) return findings;
|
|
723
|
+
|
|
724
|
+
forEachPackageEntry(lockfile, (packageEntry) => {
|
|
725
|
+
if (resolvesOutsideRegistry(packageEntry)) return;
|
|
726
|
+
const hostname = resolvedHostname(packageEntry.entry);
|
|
727
|
+
if (hostname === null || pinned.includes(hostname)) return;
|
|
728
|
+
const { key, name } = packageEntry;
|
|
729
|
+
findings.push({
|
|
730
|
+
packagePath: key,
|
|
731
|
+
message: `${name || key} resolves from "${hostname}", which is not a pinned registry host (${pinned.join(', ')}) — the lockfile will not install where that host is unreachable`
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
return findings;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// Supply-chain "cooldown": refuse to install versions published less than N ago.
|
|
739
|
+
// A compromised maintainer account's malicious release is typically detected and
|
|
740
|
+
// unpublished within hours, so a cooldown means you simply never resolve it.
|
|
741
|
+
//
|
|
742
|
+
// The two package managers disagree on BOTH key name and unit, which is the whole
|
|
743
|
+
// reason this normalizes to days before comparing:
|
|
744
|
+
// npm >= 11.10 : `.npmrc` min-release-age (DAYS)
|
|
745
|
+
// pnpm >= 10.16 : `pnpm-workspace.yaml` minimumReleaseAge (MINUTES)
|
|
746
|
+
// pnpm reads only auth/registry settings from .npmrc, so its cooldown is never
|
|
747
|
+
// there; npm has no equivalent yaml, so its cooldown is never in the workspace file.
|
|
748
|
+
const MINUTES_PER_DAY = 1440;
|
|
749
|
+
|
|
750
|
+
// Configured cooldown in DAYS for an npm project, or null when unset/unparseable.
|
|
751
|
+
function npmCooldownDays(npmrcPath) {
|
|
752
|
+
let content;
|
|
753
|
+
try {
|
|
754
|
+
content = fs.readFileSync(npmrcPath, 'utf8');
|
|
755
|
+
} catch {
|
|
756
|
+
return null; // no committed .npmrc → no committed policy
|
|
757
|
+
}
|
|
758
|
+
// parseNpmrc yields an entry list, keys lowercased; last assignment wins (ini).
|
|
759
|
+
const entry = parseNpmrc(content).filter((e) => e.key === 'min-release-age').pop();
|
|
760
|
+
if (!entry) return null;
|
|
761
|
+
const days = Number(entry.value);
|
|
762
|
+
return Number.isFinite(days) ? days : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Configured cooldown in DAYS for a pnpm project, or null when unset/unparseable.
|
|
766
|
+
function pnpmCooldownDays(workspacePath) {
|
|
767
|
+
let content;
|
|
768
|
+
try {
|
|
769
|
+
content = fs.readFileSync(workspacePath, 'utf8');
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
let doc;
|
|
774
|
+
try {
|
|
775
|
+
doc = parsePnpmWorkspace(content);
|
|
776
|
+
} catch {
|
|
777
|
+
return null; // malformed YAML — valid-pnpm-workspace owns that finding
|
|
778
|
+
}
|
|
779
|
+
const raw = doc && doc.minimumReleaseAge;
|
|
780
|
+
if (raw === undefined) return null;
|
|
781
|
+
const minutes = Number(raw);
|
|
782
|
+
return Number.isFinite(minutes) ? minutes / MINUTES_PER_DAY : null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// A blanket exclusion silently voids the policy, so it is worth its own finding.
|
|
786
|
+
function blanketExclusions(workspacePath) {
|
|
787
|
+
let doc;
|
|
788
|
+
try {
|
|
789
|
+
doc = parsePnpmWorkspace(fs.readFileSync(workspacePath, 'utf8'));
|
|
790
|
+
} catch {
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
const list = doc && doc.minimumReleaseAgeExclude;
|
|
794
|
+
if (!Array.isArray(list)) return [];
|
|
795
|
+
return list.filter((p) => typeof p === 'string' && (p === '*' || p === '**'));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const minReleaseAgeRule = {
|
|
799
|
+
id: 'min-release-age',
|
|
800
|
+
description: 'A minimum release-age cooldown must be configured, so a freshly published (possibly compromised) version is never installed',
|
|
801
|
+
defaultSeverity: 'warn',
|
|
802
|
+
flavors: ['npm', 'pnpm'],
|
|
803
|
+
check({ filePath, options, flavor }) {
|
|
804
|
+
const { minDays = 3 } = options;
|
|
805
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
806
|
+
const isPnpm = flavor === 'pnpm';
|
|
807
|
+
const configFile = isPnpm ? 'pnpm-workspace.yaml' : '.npmrc';
|
|
808
|
+
// `npmrcPath` lets a caller point at a .npmrc outside the lockfile's dir
|
|
809
|
+
// (same option valid-npmrc takes); it is meaningless on the pnpm path.
|
|
810
|
+
const npmrcPath = options.npmrcPath ? path.resolve(options.npmrcPath) : path.join(dir, '.npmrc');
|
|
811
|
+
const configPath = isPnpm ? path.join(dir, 'pnpm-workspace.yaml') : npmrcPath;
|
|
812
|
+
|
|
813
|
+
const configured = isPnpm ? pnpmCooldownDays(configPath) : npmCooldownDays(configPath);
|
|
814
|
+
const setting = isPnpm ? 'minimumReleaseAge' : 'min-release-age';
|
|
815
|
+
const findings = [];
|
|
816
|
+
|
|
817
|
+
if (configured === null) {
|
|
818
|
+
const unit = isPnpm ? `${minDays * MINUTES_PER_DAY} (minutes)` : `${minDays} (days)`;
|
|
819
|
+
findings.push({
|
|
820
|
+
packagePath: configFile,
|
|
821
|
+
message: `no release-age cooldown configured — set "${setting}" to at least ${unit} in ${configFile} so a version published moments ago is never installed`
|
|
822
|
+
});
|
|
823
|
+
} else if (configured < minDays) {
|
|
824
|
+
findings.push({
|
|
825
|
+
packagePath: configFile,
|
|
826
|
+
message: `release-age cooldown is ${formatDays(configured)}, below the required minimum of ${formatDays(minDays)} ("${setting}" in ${configFile})`
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (isPnpm) {
|
|
831
|
+
for (const pattern of blanketExclusions(configPath)) {
|
|
832
|
+
findings.push({
|
|
833
|
+
packagePath: configFile,
|
|
834
|
+
message: `"minimumReleaseAgeExclude" contains the blanket pattern "${pattern}", which exempts every package and voids the cooldown`
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return findings;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// Render a day count the way a reader configures it: whole days, or minutes when
|
|
843
|
+
// the value is under a day (which is how a pnpm `minimumReleaseAge` will land here).
|
|
844
|
+
function formatDays(days) {
|
|
845
|
+
if (days >= 1) return `${Number.isInteger(days) ? days : days.toFixed(2)} day${days === 1 ? '' : 's'}`;
|
|
846
|
+
const minutes = Math.round(days * MINUTES_PER_DAY);
|
|
847
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
669
850
|
export const rules = [
|
|
670
851
|
lockfileVersionRule,
|
|
671
852
|
validStructureRule,
|
|
@@ -675,6 +856,7 @@ export const rules = [
|
|
|
675
856
|
installScriptsRule,
|
|
676
857
|
noGitDepsRule,
|
|
677
858
|
noRemoteDepsRule,
|
|
859
|
+
resolvedRegistryPinRule,
|
|
678
860
|
pinnedVersionsRule,
|
|
679
861
|
lockfileSyncRule,
|
|
680
862
|
noOrphanPackagesRule,
|
|
@@ -682,7 +864,8 @@ export const rules = [
|
|
|
682
864
|
noFundRule,
|
|
683
865
|
validNpmrcRule,
|
|
684
866
|
validPnpmWorkspaceRule,
|
|
685
|
-
validPnpmFieldRule
|
|
867
|
+
validPnpmFieldRule,
|
|
868
|
+
minReleaseAgeRule
|
|
686
869
|
];
|
|
687
870
|
|
|
688
871
|
/**
|
|
@@ -810,30 +993,45 @@ export function runAudit(target, config = {}) {
|
|
|
810
993
|
* @param {object} options - { format: 'stylish' | 'json' }
|
|
811
994
|
* @returns {string}
|
|
812
995
|
*/
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
996
|
+
// Machine-readable half of formatAuditReport.
|
|
997
|
+
function formatAuditJson(report, suppressed) {
|
|
998
|
+
const meta = report.exceptionsMeta || {};
|
|
999
|
+
return JSON.stringify({
|
|
1000
|
+
filePath: report.filePath,
|
|
1001
|
+
pass: report.pass,
|
|
1002
|
+
summary: report.summary,
|
|
1003
|
+
findings: report.findings,
|
|
1004
|
+
suppressed,
|
|
1005
|
+
exceptionsMeta: {
|
|
1006
|
+
unused: (meta.unused || []).map((e) => e._raw),
|
|
1007
|
+
expired: (meta.expired || []).map((e) => e._raw)
|
|
1008
|
+
}
|
|
1009
|
+
}, null, 2);
|
|
1010
|
+
}
|
|
816
1011
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
expired: (report.exceptionsMeta && report.exceptionsMeta.expired || []).map((e) => e._raw)
|
|
827
|
-
}
|
|
828
|
-
}, null, 2);
|
|
829
|
-
}
|
|
1012
|
+
// One aligned `severity rule path message` line per finding.
|
|
1013
|
+
function findingLines(findings) {
|
|
1014
|
+
const ruleWidth = Math.max(...findings.map((f) => f.ruleId.length));
|
|
1015
|
+
return findings.map((finding) => {
|
|
1016
|
+
const sev = finding.severity === 'error' ? 'error' : 'warn ';
|
|
1017
|
+
const loc = finding.packagePath ? `${finding.packagePath} ` : '';
|
|
1018
|
+
return ` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`;
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
830
1021
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
1022
|
+
// The closing `N problems (E errors, W warnings)` tally.
|
|
1023
|
+
function totalsLine(summary, suppressedCount) {
|
|
1024
|
+
const { errors, warnings, total } = summary;
|
|
1025
|
+
const problemWord = total === 1 ? 'problem' : 'problems';
|
|
1026
|
+
const errorWord = errors === 1 ? 'error' : 'errors';
|
|
1027
|
+
const warningWord = warnings === 1 ? 'warning' : 'warnings';
|
|
1028
|
+
const suffix = suppressedCount > 0 ? ` — ${suppressedCount} suppressed by .dependably` : '';
|
|
1029
|
+
return `${total} ${problemWord} (${errors} ${errorWord}, ${warnings} ${warningWord})${suffix}`;
|
|
1030
|
+
}
|
|
834
1031
|
|
|
835
|
-
|
|
836
|
-
|
|
1032
|
+
// Human-readable (ESLint-like) half of formatAuditReport.
|
|
1033
|
+
function formatAuditStylish(report, suppressed, showSuppressed) {
|
|
1034
|
+
const lines = [report.filePath];
|
|
837
1035
|
|
|
838
1036
|
if (report.findings.length === 0) {
|
|
839
1037
|
lines.push(suppressed.length > 0 ? ` no problems found (${suppressed.length} suppressed by .dependably)` : ' no problems found');
|
|
@@ -841,22 +1039,24 @@ export function formatAuditReport(report, options = {}) {
|
|
|
841
1039
|
return lines.join('\n');
|
|
842
1040
|
}
|
|
843
1041
|
|
|
844
|
-
|
|
845
|
-
for (const finding of report.findings) {
|
|
846
|
-
const sev = finding.severity === 'error' ? 'error' : 'warn ';
|
|
847
|
-
const loc = finding.packagePath ? `${finding.packagePath} ` : '';
|
|
848
|
-
lines.push(` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const { errors, warnings, total } = report.summary;
|
|
852
|
-
const problemWord = total === 1 ? 'problem' : 'problems';
|
|
853
|
-
const suffix = suppressed.length > 0 ? ` — ${suppressed.length} suppressed by .dependably` : '';
|
|
1042
|
+
lines.push(...findingLines(report.findings));
|
|
854
1043
|
lines.push('');
|
|
855
|
-
lines.push(
|
|
1044
|
+
lines.push(totalsLine(report.summary, suppressed.length));
|
|
856
1045
|
appendSuppressed(lines, suppressed, showSuppressed);
|
|
857
1046
|
return lines.join('\n');
|
|
858
1047
|
}
|
|
859
1048
|
|
|
1049
|
+
export function formatAuditReport(report, options = {}) {
|
|
1050
|
+
const { format = 'stylish', showSuppressed = false } = options;
|
|
1051
|
+
const suppressed = report.suppressed || [];
|
|
1052
|
+
|
|
1053
|
+
if (format === 'json') return formatAuditJson(report, suppressed);
|
|
1054
|
+
if (format !== 'stylish') {
|
|
1055
|
+
throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
|
|
1056
|
+
}
|
|
1057
|
+
return formatAuditStylish(report, suppressed, showSuppressed);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
860
1060
|
// Optionally list the suppressed findings (with their exception reason) below the
|
|
861
1061
|
// summary, so `--show-suppressed` keeps the audit trail visible.
|
|
862
1062
|
function appendSuppressed(lines, suppressed, showSuppressed) {
|
package/src/exceptions.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// src/exceptions.js
|
|
2
2
|
//
|
|
3
|
-
// Reference implementation of the `.dependably` exception grammar
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Reference implementation of the `.dependably` exception grammar, specified in
|
|
4
|
+
// §6 of the config spec at
|
|
5
|
+
// https://gitlab.northwardlabs.ca/moonlitlabs/dependably-spec. This is the module
|
|
6
|
+
// the C# and Python ports mirror; keep it behavior-compatible with the vendored
|
|
7
|
+
// conformance fixtures under conformance/dependably/.
|
|
7
8
|
//
|
|
8
9
|
// An exception suppresses SPECIFIC findings so a run does not fail on them,
|
|
9
10
|
// without excluding whole files (`exclude`) or disabling a rule globally
|
|
@@ -109,89 +110,106 @@ function splitPackageSelector(pkg) {
|
|
|
109
110
|
* @param {string} [opts.configPath]
|
|
110
111
|
* @returns {Array<{rule, reason, expires, source, selectors, _raw}>}
|
|
111
112
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
// An exception entry must be an object carrying non-empty `rule` and `reason`.
|
|
114
|
+
function validateExceptionShape(entry, index, at) {
|
|
115
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
116
|
+
throw new ExceptionConfigError(`exception #${index} must be an object`, 'INVALID_EXCEPTIONS', at);
|
|
117
|
+
}
|
|
118
|
+
if (typeof entry.rule !== 'string' || entry.rule.trim() === '') {
|
|
119
|
+
throw new ExceptionConfigError(`exception #${index} is missing "rule"`, 'EXCEPTION_MISSING_RULE', at);
|
|
120
|
+
}
|
|
121
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
|
|
122
|
+
throw new ExceptionConfigError(
|
|
123
|
+
`exception for rule "${entry.rule}" is missing a non-empty "reason"`,
|
|
124
|
+
'EXCEPTION_MISSING_REASON',
|
|
125
|
+
at
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
116
129
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
// At least one selector, each a non-empty string. A selector this tool's findings
|
|
131
|
+
// never carry is an error in the tool's OWN section but is tolerated in `common`
|
|
132
|
+
// (it simply never matches).
|
|
133
|
+
function validateExceptionSelectors(entry, source, applicableSelectors, at) {
|
|
134
|
+
const present = SELECTORS.filter((s) => entry[s] !== undefined);
|
|
135
|
+
if (present.length === 0) {
|
|
136
|
+
throw new ExceptionConfigError(
|
|
137
|
+
`exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
|
|
138
|
+
'EXCEPTION_NO_SELECTOR',
|
|
139
|
+
at
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const sel of present) {
|
|
143
|
+
if (typeof entry[sel] !== 'string' || entry[sel].trim() === '') {
|
|
126
144
|
throw new ExceptionConfigError(
|
|
127
|
-
`exception for rule "${entry.rule}"
|
|
128
|
-
'
|
|
145
|
+
`exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
|
|
146
|
+
'EXCEPTION_BAD_SELECTOR',
|
|
129
147
|
at
|
|
130
148
|
);
|
|
131
149
|
}
|
|
132
|
-
|
|
133
|
-
const present = SELECTORS.filter((s) => entry[s] !== undefined);
|
|
134
|
-
if (present.length === 0) {
|
|
150
|
+
if (source === 'own' && !applicableSelectors.includes(sel)) {
|
|
135
151
|
throw new ExceptionConfigError(
|
|
136
|
-
`exception
|
|
137
|
-
'
|
|
152
|
+
`exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
|
|
153
|
+
'EXCEPTION_BAD_SELECTOR',
|
|
138
154
|
at
|
|
139
155
|
);
|
|
140
156
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
throw new ExceptionConfigError(
|
|
144
|
-
`exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
|
|
145
|
-
'EXCEPTION_BAD_SELECTOR',
|
|
146
|
-
at
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
// A selector this tool's findings never carry is an error in the tool's
|
|
150
|
-
// OWN section but is tolerated in `common` (it simply never matches).
|
|
151
|
-
if (source === 'own' && !applicableSelectors.includes(sel)) {
|
|
152
|
-
throw new ExceptionConfigError(
|
|
153
|
-
`exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
|
|
154
|
-
'EXCEPTION_BAD_SELECTOR',
|
|
155
|
-
at
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
// `expires`, when present, must be a valid YYYY-MM-DD date.
|
|
161
|
+
function validateExceptionExpires(entry, at) {
|
|
162
|
+
if (entry.expires === undefined) return;
|
|
163
|
+
if (typeof entry.expires !== 'string' || !EXPIRES_RE.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
|
|
164
|
+
throw new ExceptionConfigError(
|
|
165
|
+
`exception "expires" for rule "${entry.rule}" must be a valid YYYY-MM-DD date`,
|
|
166
|
+
'EXCEPTION_BAD_EXPIRES',
|
|
167
|
+
at
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
169
171
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
176
|
-
|
|
172
|
+
// An unknown rule id is an error in the tool's own section (spec §8); in `common`
|
|
173
|
+
// it belongs to a sibling tool and is tolerated.
|
|
174
|
+
function validateExceptionRule(entry, source, knownRules, at) {
|
|
175
|
+
if (source === 'own' && knownRules && !knownRules.includes(entry.rule)) {
|
|
176
|
+
throw new ExceptionConfigError(
|
|
177
|
+
`Unknown rule "${entry.rule}" in exception (known rules: ${knownRules.join(', ')})`,
|
|
178
|
+
'UNKNOWN_RULE',
|
|
179
|
+
at
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildExceptionSelectors(entry) {
|
|
185
|
+
const selectors = { rule: entry.rule };
|
|
186
|
+
if (entry.package !== undefined) selectors.package = splitPackageSelector(entry.package);
|
|
187
|
+
if (entry.path !== undefined) selectors.path = entry.path;
|
|
188
|
+
if (entry.symbol !== undefined) selectors.symbol = entry.symbol;
|
|
189
|
+
if (entry.id !== undefined) selectors.id = entry.id;
|
|
190
|
+
return selectors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseExceptions(raw, opts = {}) {
|
|
194
|
+
const { source = 'own', applicableSelectors = SELECTORS, knownRules = null, configPath = null } = opts;
|
|
195
|
+
const entries = ensureArray(raw, { configPath });
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
197
|
+
return entries.map((entry, index) => {
|
|
198
|
+
const at = { configPath, source, index };
|
|
199
|
+
validateExceptionShape(entry, index, at);
|
|
200
|
+
validateExceptionSelectors(entry, source, applicableSelectors, at);
|
|
201
|
+
validateExceptionExpires(entry, at);
|
|
202
|
+
validateExceptionRule(entry, source, knownRules, at);
|
|
183
203
|
|
|
184
|
-
|
|
204
|
+
return {
|
|
185
205
|
rule: entry.rule,
|
|
186
206
|
reason: entry.reason,
|
|
187
207
|
expires: entry.expires || null,
|
|
188
208
|
source,
|
|
189
|
-
selectors,
|
|
209
|
+
selectors: buildExceptionSelectors(entry),
|
|
190
210
|
_raw: entry
|
|
191
|
-
}
|
|
211
|
+
};
|
|
192
212
|
});
|
|
193
|
-
|
|
194
|
-
return out;
|
|
195
213
|
}
|
|
196
214
|
|
|
197
215
|
// --- matching ---
|
|
@@ -235,8 +253,7 @@ export function matchException(exception, finding) {
|
|
|
235
253
|
if (s.package !== undefined && !matchPackage(s.package, finding)) return false;
|
|
236
254
|
if (s.path !== undefined && !matchGlob(s.path, finding.path)) return false;
|
|
237
255
|
if (s.symbol !== undefined && !matchSymbol(s.symbol, finding.symbol)) return false;
|
|
238
|
-
|
|
239
|
-
return true;
|
|
256
|
+
return !(s.id !== undefined && s.id !== finding.id);
|
|
240
257
|
}
|
|
241
258
|
|
|
242
259
|
/**
|